commit f143bce2738ac5eb409775b991bcc805f4ed8ec0 Author: Celes Renata Date: Fri May 8 15:55:01 2026 -0700 fix: add wayland dev headers and scanner for pywayland build on NixOS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d090a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Nix build results +result +result-* + +# Nix development +.direnv/ +.envrc + +# Cache directories +.cache/ +*.tmp + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temporary files +*.bak +*.orig + +# External repositories (for reference only) +dots-hyprland/ +dots-hyprland-wiki/ diff --git a/CONFIGURATION_GUIDE.md b/CONFIGURATION_GUIDE.md new file mode 100644 index 0000000..d2ff2a8 --- /dev/null +++ b/CONFIGURATION_GUIDE.md @@ -0,0 +1,245 @@ +# ๐ŸŽฏ dots-hyprland Static Configuration Guide + +## ๐Ÿ“ Overview + +Since you're using **declarative mode**, configuration files are read-only in the Nix store. To customize static values, modify the source files in your flake and rebuild. + +## ๐Ÿ”ง Key Configuration Files + +### 1. ๐Ÿ“ฑ Quickshell Main Config +**File**: `configs/quickshell/ii/modules/common/Config.qml` + +#### ๐ŸŽจ Appearance Settings +```qml +property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property bool transparency: false + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + } +} +``` + +#### ๐Ÿ–ฅ๏ธ Bar Configuration +```qml +property JsonObject bar: JsonObject { + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool borderless: false + property string topLeftIcon: "spark" // Options: distro, spark + property bool showBackground: true + property bool verbose: true + + 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 JsonObject workspaces: JsonObject { + property bool monochromeIcons: true + property int shown: 10 + property bool showAppIcons: true + property bool alwaysShowNumbers: false + property int showNumberDelay: 300 // milliseconds + } +} +``` + +#### ๐Ÿ”‹ Battery Settings +```qml +property JsonObject battery: JsonObject { + property int low: 20 + property int critical: 5 + property bool automaticSuspend: true + property int suspend: 3 +} +``` + +#### ๐Ÿš€ Applications +```qml +property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6 kcm_bluetooth" + property string network: "plasmawindowed org.kde.plasma.networkmanagement" + 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 +} +``` + +#### โฐ Time Format +```qml +property JsonObject time: JsonObject { + property string format: "hh:mm" + property string dateFormat: "ddd, dd/MM" +} +``` + +### 2. ๐Ÿ–ผ๏ธ Hyprland Configuration +**File**: `configs/hypr/general.conf.template` + +#### ๐ŸŽจ Visual Settings +```conf +general { + gaps_in = @GAPS_IN@ # Inner gaps (default: 4) + gaps_out = @GAPS_OUT@ # Outer gaps (default: 7) + gaps_workspaces = 50 # Workspace gaps + + border_size = @BORDER_SIZE@ # Border width (default: 2) + resize_on_border = true + + allow_tearing = @ALLOW_TEARING@ # For gaming +} + +decoration { + rounding = @ROUNDING@ # Corner rounding (default: 16) + + blur { + enabled = @BLUR_ENABLED@ # Background blur + xray = true + } +} +``` + +### 3. ๐Ÿ–ฅ๏ธ Terminal Configuration +**File**: `configs/applications/foot.ini.template` + +#### ๐Ÿ“ Terminal Settings +```ini +[main] +term=xterm-256color +login-shell=yes +app-id=foot +title=foot + +[scrollback] +lines=1000 # Scrollback buffer size +multiplier=3.0 + +[cursor] +style=beam # Options: block, beam, underline +blink=no +beam-thickness=1.5 + +[colors] +alpha=0.95 # Terminal transparency +``` + +### 4. ๐ŸŽฏ Fuzzel Launcher +**File**: `configs/matugen/templates/fuzzel/fuzzel_theme.ini` + +#### ๐Ÿš€ Launcher Settings +```ini +[main] +terminal=foot +layer=overlay +width=40 +horizontal-pad=40 +vertical-pad=8 +inner-pad=5 +``` + +## ๐Ÿ”„ How to Apply Changes + +### Method 1: Edit and Rebuild +1. **Edit** the configuration files in `~/sources/celesrenata/end-4-flakes/configs/` +2. **Commit** your changes: `git add . && git commit -m "Update static config"` +3. **Rebuild**: `nix build .#homeConfigurations.declarative.activationPackage` +4. **Activate**: `./result/activate` + +### Method 2: Switch to Writable Mode +If you want to edit configs directly without rebuilding: + +```bash +# Build writable configuration +nix build .#homeConfigurations.writable.activationPackage +./result/activate + +# Run setup script +~/.local/bin/initialSetup.sh + +# Edit configs directly in ~/.config/ +``` + +## ๐ŸŽจ Common Customizations + +### Change Terminal to Kitty +In `configs/quickshell/ii/modules/common/Config.qml`: +```qml +property string terminal: "kitty -1" +``` + +### Move Bar to Bottom +```qml +property bool bottom: true +``` + +### Disable Transparency +```qml +property bool transparency: false +``` + +### Change Time Format to 12-hour +```qml +property string format: "hh:mm AP" +``` + +### Increase Terminal Scrollback +In `configs/applications/foot.ini.template`: +```ini +[scrollback] +lines=10000 +``` + +### Change Workspace Count +```qml +property int shown: 5 // Show only 5 workspaces +``` + +## ๐Ÿ” Finding More Options + +- **Quickshell Config**: `configs/quickshell/ii/modules/common/Config.qml` (lines 1-300) +- **Hyprland Settings**: `configs/hypr/*.conf.template` files +- **Application Configs**: `configs/applications/` directory +- **Theming Templates**: `configs/matugen/templates/` directory + +## ๐Ÿ’ก Pro Tips + +1. **Search for specific settings**: `grep -r "property.*terminal" configs/` +2. **Check template variables**: Look for `@VARIABLE@` patterns in `.template` files +3. **Test changes**: Use writable mode for quick testing, then apply to declarative mode +4. **Backup configs**: Git tracks all changes, so you can always revert + +## ๐Ÿš€ Quick Start Examples + +### Minimal Gaming Setup +```qml +// In Config.qml +property bool transparency: false +property bool showBackground: false +property int shown: 3 // Only 3 workspaces +``` + +### Productivity Setup +```qml +// In Config.qml +property bool showScreenSnip: true +property bool showColorPicker: true +property string format: "HH:mm:ss" +property string dateFormat: "dddd, MMMM dd, yyyy" +``` + +### Minimalist Setup +```qml +// In Config.qml +property bool borderless: true +property bool showBackground: false +property bool monochromeIcons: true +property bool verbose: false +``` diff --git a/NIXOS_CONFIGURATION_GUIDE.md b/NIXOS_CONFIGURATION_GUIDE.md new file mode 100644 index 0000000..08b09ce --- /dev/null +++ b/NIXOS_CONFIGURATION_GUIDE.md @@ -0,0 +1,342 @@ +# ๐ŸŽฏ NixOS Configuration Guide for dots-hyprland + +## ๐Ÿ“ The NixOS Way + +In NixOS, you **don't edit configuration files directly**. Instead, you configure everything through **Nix expressions** that generate the configuration files. + +## ๐Ÿ”ง Current State vs Ideal State + +### โŒ Current State (Basic) +The current module only exposes basic options: +```nix +programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "essential"; + mode = "declarative"; +}; +``` + +### โœ… Ideal State (Rich Configuration) +What we're building - full NixOS-style configuration: +```nix +programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "essential"; + mode = "declarative"; + + # Quickshell configuration + quickshell = { + bar = { + bottom = false; + topLeftIcon = "spark"; + utilButtons = { + showColorPicker = true; + showScreenSnip = true; + showMicToggle = false; + }; + workspaces = { + shown = 10; + showAppIcons = true; + }; + }; + + battery = { + low = 20; + critical = 5; + }; + + apps = { + terminal = "foot"; + }; + + time = { + format = "hh:mm"; + dateFormat = "ddd, dd/MM"; + }; + }; + + # Hyprland configuration + hyprland = { + general = { + gapsIn = 4; + gapsOut = 7; + borderSize = 2; + }; + + decoration = { + rounding = 16; + blurEnabled = true; + }; + + monitors = [ + "eDP-1,1920x1080@60,0x0,1" + ]; + }; +}; +``` + +## ๐Ÿš€ How to Configure (The NixOS Way) + +### Method 1: In Your Flake Configuration +Edit your flake.nix homeConfigurations: + +```nix +homeConfigurations.declarative = home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [ + self.homeManagerModules.default + { + home.username = "celes"; + home.homeDirectory = "/home/celes"; + home.stateVersion = "24.05"; + + programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "essential"; + mode = "declarative"; + + # Your custom configuration here + quickshell.bar.utilButtons.showColorPicker = true; + quickshell.apps.terminal = "foot"; + hyprland.general.gapsIn = 6; + }; + } + ]; +}; +``` + +### Method 2: Separate Configuration File +Create `config/my-dots-config.nix`: + +```nix +{ config, lib, pkgs, ... }: + +{ + programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "essential"; + mode = "declarative"; + + quickshell = { + appearance = { + transparency = true; + fakeScreenRounding = 1; + }; + + bar = { + bottom = true; # Move bar to bottom + topLeftIcon = "distro"; + cornerStyle = 1; # Float style + + utilButtons = { + showColorPicker = true; + showScreenSnip = true; + showMicToggle = true; + showDarkModeToggle = true; + }; + + workspaces = { + shown = 5; # Only show 5 workspaces + monochromeIcons = false; + alwaysShowNumbers = true; + }; + }; + + battery = { + low = 25; + critical = 10; + automaticSuspend = false; + }; + + apps = { + terminal = "foot"; + taskManager = "htop"; + }; + + time = { + format = "HH:mm:ss"; # 24-hour with seconds + dateFormat = "dddd, MMMM dd, yyyy"; + }; + }; + + hyprland = { + general = { + gapsIn = 6; + gapsOut = 10; + borderSize = 3; + allowTearing = true; # For gaming + }; + + decoration = { + rounding = 12; + blurEnabled = false; # Disable for performance + }; + + gestures = { + workspaceSwipe = true; + }; + + monitors = [ + "eDP-1,1920x1080@60,0x0,1" + "HDMI-A-1,1920x1080@60,1920x0,1" + ]; + }; + }; +} +``` + +Then import it in your flake: +```nix +modules = [ + self.homeManagerModules.default + ./config/my-dots-config.nix + { + home.username = "celes"; + home.homeDirectory = "/home/celes"; + home.stateVersion = "24.05"; + } +]; +``` + +## ๐Ÿ”„ Development Workflow + +### 1. Add New Configuration Options +Edit `modules/components/quickshell-config.nix` to add new options: + +```nix +newFeature = mkOption { + type = types.bool; + default = false; + description = "Enable new feature"; +}; +``` + +### 2. Update Configuration Generation +Update the config generation to use the new option: + +```nix +property bool newFeature: ${boolToString cfg.newFeature} +``` + +### 3. Test and Apply +```bash +# Test the configuration +nix build .#homeConfigurations.declarative.activationPackage + +# Apply changes +./result/activate +``` + +## ๐ŸŽจ Common Configuration Examples + +### Gaming Setup +```nix +programs.dots-hyprland = { + quickshell = { + appearance.transparency = false; + bar = { + showBackground = false; + workspaces.shown = 3; + }; + }; + + hyprland = { + general.allowTearing = true; + decoration.blurEnabled = false; + }; +}; +``` + +### Productivity Setup +```nix +programs.dots-hyprland = { + quickshell = { + bar = { + utilButtons = { + showScreenSnip = true; + showColorPicker = true; + }; + workspaces.shown = 10; + }; + + time = { + format = "HH:mm:ss"; + dateFormat = "dddd, MMMM dd, yyyy"; + }; + }; + + hyprland = { + general = { + gapsIn = 2; + gapsOut = 4; + }; + }; +}; +``` + +### Minimalist Setup +```nix +programs.dots-hyprland = { + quickshell = { + bar = { + borderless = true; + showBackground = false; + verbose = false; + workspaces = { + monochromeIcons = true; + showAppIcons = false; + }; + }; + }; + + hyprland = { + decoration.rounding = 0; + }; +}; +``` + +## ๐Ÿ” Available Options + +### Quickshell Options +- `appearance.*` - Visual appearance settings +- `bar.*` - Top bar configuration +- `battery.*` - Battery management +- `apps.*` - Application commands +- `time.*` - Time and date formatting + +### Hyprland Options +- `general.*` - Window gaps, borders, tearing +- `decoration.*` - Rounding, blur, shadows +- `gestures.*` - Touchpad gestures +- `monitors` - Monitor configuration + +## ๐Ÿ’ก Benefits of This Approach + +1. **Type Safety**: Nix validates your configuration +2. **Documentation**: Built-in option descriptions +3. **Defaults**: Sensible defaults with easy overrides +4. **Reproducibility**: Same config = same result +5. **Rollbacks**: Easy to revert changes +6. **Modularity**: Mix and match configurations + +## ๐Ÿšง Current Status + +The rich configuration system is **partially implemented**. The basic structure is there, but we need to: + +1. โœ… Create option definitions (done above) +2. โณ Wire up the option values to config generation +3. โณ Test all options work correctly +4. โณ Add more granular options + +## ๐ŸŽฏ Next Steps + +1. **Test the new modules**: Add them to your flake and test +2. **Expand options**: Add more configuration options as needed +3. **Generate configs**: Wire up the options to actual config file generation +4. **Document**: Add examples and documentation + +This is the **proper NixOS way** - declarative, type-safe, and reproducible! ๐ŸŽ‰ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5e1389 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# dots-hyprland for NixOS (Self-Contained) + +A complete, self-contained NixOS adaptation of [end-4's dots-hyprland](https://github.com/end-4/dots-hyprland) desktop environment, bringing the beautiful "illogical-impulse" style to NixOS with full declarative configuration. + +## ๐ŸŽฏ Project Status: Self-Contained & Complete โœ… + +**Current Achievement: Fully Self-Contained Desktop Environment** + +- โœ… **Self-Contained**: All configurations included locally (no external dependencies) +- โœ… **Quickshell Integration** - Official flake support with local configs +- โœ… **Hyprland Configuration** - Complete window manager setup with Material You theming +- โœ… **Essential Applications** - foot terminal, fuzzel launcher, nautilus file manager +- โœ… **Home Manager Integration** - Fully declarative configuration +- โœ… **Package Management** - All dependencies properly integrated +- โœ… **Development Environment** - Ready for advanced features + +## ๐Ÿš€ Quick Start + +### Prerequisites +- NixOS with flakes enabled +- Home Manager (optional but recommended) + +### Installation + +```bash +# Clone the repository +git clone git@github.com:celesrenata/end-4-flakes.git +cd end-4-flakes + +# Build and activate Home Manager configuration +nix build .#homeConfigurations.declarative.activationPackage +./result/activate + +# Or use with your existing Home Manager setup +# Add to your flake inputs: +# dots-hyprland.url = "github:celesrenata/end-4-flakes"; +``` + +### Development + +```bash +# Enter development environment +nix develop + +# Available development tools: +# - update-flake: Manage flake inputs +# - compare-modes: Compare declarative vs writable configuration modes +# - test-python-env: Test Python virtual environment setup +# - test-quickshell: Test quickshell configuration + +# Flake management examples: +update-flake status # Show current flake status +update-flake update # Update all flake inputs +update-flake verify # Test that configurations build +update-flake help # Show all available options +``` + +## ๐Ÿ“‹ Features + +### โœ… Implemented (Phase 3) +- **Hyprland Window Manager** - Complete configuration with Material You theming +- **foot Terminal** - Tokyo Night color scheme, JetBrainsMono Nerd Font +- **fuzzel Launcher** - Material You themed application launcher +- **Essential Keybinds** - All core window management and application shortcuts +- **Package Integration** - Declarative package management through Nix +- **Home Manager Support** - Full integration with Home Manager modules + +### ๐Ÿ”„ In Progress (Phase 4) +- **AI Integration** - Gemini and Ollama support +- **Advanced Widgets** - Overview with live previews, sidebars +- **Comprehensive Theming** - Dynamic Material You color generation +- **Quality of Life** - Screen corners, session management, cheatsheet + +### ๐Ÿ“… Planned (Future Phases) +- **NixOS System Integration** - Full system-level configuration +- **Testing & Validation** - Comprehensive test suite +- **Community & Maintenance** - Documentation, contribution guidelines + +## ๐Ÿ”„ Flake Management + +The project includes a comprehensive flake management utility for keeping your configuration synchronized with GitHub: + +### Quick Commands + +```bash +# Check current status +update-flake status + +# Update all flake inputs +update-flake update + +# Update only dots-hyprland source +update-flake update-source + +# Verify configurations build +update-flake verify + +# Update and verify in one command +update-flake update --auto-verify +``` + +### Advanced Usage + +```bash +# Pin to a specific commit +update-flake pin abc123def + +# Switch to tracking a different branch +update-flake branch main + +# Dry run to see what would happen +update-flake update --dry-run +``` + +The utility automatically detects synchronization status and provides clear feedback about your flake's relationship to the GitHub repository. + +## ๐ŸŽจ Configuration + +### Basic Configuration + +```nix +{ + programs.dots-hyprland = { + enable = true; + style = "illogical-impulse"; + + components = { + hyprland = true; + quickshell = true; + theming = false; # Phase 4 + ai = false; # Phase 4 + audio = true; + }; + + features = { + overview = true; + sidebar = false; # Phase 4 + notifications = true; + mediaControls = true; + }; + + keybinds = { + modifier = "SUPER"; + terminal = "foot"; + }; + }; +} +``` + +### Keybinds + +| Key Combination | Action | +|----------------|--------| +| `SUPER + Return` | Open terminal | +| `SUPER + Space` | Open application launcher | +| `SUPER + Q` | Close window | +| `SUPER + E` | Open file manager | +| `SUPER + F` | Toggle fullscreen | +| `SUPER + V` | Toggle floating | +| `SUPER + 1-0` | Switch to workspace | +| `SUPER + Shift + 1-0` | Move window to workspace | + +## ๐Ÿ—๏ธ Architecture + +### Self-Contained Structure +``` +โ”œโ”€โ”€ flake.nix # Main flake definition (clean & minimal) +โ”œโ”€โ”€ flake.lock # Locked dependencies +โ”œโ”€โ”€ configs/ # All dots-hyprland configurations +โ”‚ โ”œโ”€โ”€ hypr/ # Hyprland configuration +โ”‚ โ”œโ”€โ”€ quickshell/ # Quickshell widgets and config +โ”‚ โ”œโ”€โ”€ applications/ # Application configurations +โ”‚ โ”œโ”€โ”€ scripts/ # Utility scripts +โ”‚ โ””โ”€โ”€ matugen/ # Material You theming +โ”œโ”€โ”€ modules/ # NixOS/Home Manager modules +โ”‚ โ”œโ”€โ”€ home-manager.nix # Main Home Manager integration +โ”‚ โ”œโ”€โ”€ python-environment.nix # Python venv setup +โ”‚ โ”œโ”€โ”€ configuration.nix # Declarative config management +โ”‚ โ”œโ”€โ”€ writable-mode.nix # Writable mode setup +โ”‚ โ””โ”€โ”€ components/ # Component modules +โ””โ”€โ”€ packages/ # Utility packages and scripts + โ”œโ”€โ”€ default.nix # Package definitions + โ”œโ”€โ”€ dots-hyprland-packages.nix # Package mappings + โ””โ”€โ”€ scripts/ # Development utilities +``` + +### Key Benefits +- **๐Ÿ”’ Self-Contained**: No external repository dependencies +- **๐Ÿ“ฆ Version Controlled**: All configs tracked in single repository +- **๐Ÿ”ง Maintainable**: Clean separation of concerns +- **๐Ÿš€ Fast**: No network dependencies during build +- **๐ŸŽฏ Focused**: Only essential files included + +## ๐ŸŽฏ Gameplan Progress + +This project follows a systematic 7-phase development approach: + +- [x] **Phase 1: Dependency Analysis** - All dependencies mapped to NixOS +- [x] **Phase 2: Module Structure** - Complete flake and module architecture +- [x] **Phase 3: Core Implementation** - โœ… **CURRENT MILESTONE** +- [ ] **Phase 4: Advanced Features** - AI, advanced widgets, comprehensive theming +- [ ] **Phase 5: NixOS Adaptations** - Full NixOS integration patterns +- [ ] **Phase 6: Testing & Validation** - Comprehensive testing suite +- [ ] **Phase 7: Community & Maintenance** - Documentation, contribution guidelines + +## ๐Ÿค Contributing + +This project is in active development. Contributions are welcome! + +### Development Setup +1. Clone the repository +2. Run `nix develop` to enter the development environment +3. Make your changes +4. Test with `nix build .#homeConfigurations.example.activationPackage` +5. Submit a pull request + +## ๐Ÿ“„ License + +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. + +## ๐Ÿ™ Acknowledgments + +- **end-4** - Original dots-hyprland creator +- **outfoxxed** - Quickshell developer (official Nix flake support was crucial!) +- **NixOS Community** - For the amazing ecosystem +- **Hyprland Team** - For the fantastic window manager + +## ๐Ÿ“ž Support + +- **Issues**: Report bugs and request features via GitHub Issues +- **Discussions**: General questions and ideas via GitHub Discussions +- **Community**: Join the NixOS and Hyprland communities for broader support + +--- + +**Status**: Phase 3 Complete - Core desktop environment functional and ready for advanced features! ๐Ÿš€ diff --git a/configs/applications/foot.ini.template b/configs/applications/foot.ini.template new file mode 100644 index 0000000..5b9dc1d --- /dev/null +++ b/configs/applications/foot.ini.template @@ -0,0 +1,137 @@ +[main] +term=xterm-256color +login-shell=yes +app-id=foot +title=foot +locked-title=no + +[bell] +urgent=no +notify=no +visual=no +command= +command-focused=no + +[scrollback] +lines=1000 +multiplier=3.0 +indicator-position=relative +indicator-format="" + +[url] +launch=xdg-open ${url} +label-letters=sadfjklewcmpgh +osc8-underline=url-mode +protocols=http, https, ftp, ftps, file, gemini, gopher +uri-characters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.,~:;/?#@!$&%*+="'()[] + +[cursor] +style=beam +color=@CURSOR_COLOR@ +blink=no +beam-thickness=1.5 +underline-thickness= + +[mouse] +hide-when-typing=no +alternate-scroll-mode=yes + +[colors] +alpha=0.95 +background=@BACKGROUND_COLOR@ +foreground=@FOREGROUND_COLOR@ + +# Material You color palette - will be injected by theming system +regular0=@COLOR0@ +regular1=@COLOR1@ +regular2=@COLOR2@ +regular3=@COLOR3@ +regular4=@COLOR4@ +regular5=@COLOR5@ +regular6=@COLOR6@ +regular7=@COLOR7@ + +bright0=@COLOR8@ +bright1=@COLOR9@ +bright2=@COLOR10@ +bright3=@COLOR11@ +bright4=@COLOR12@ +bright5=@COLOR13@ +bright6=@COLOR14@ +bright7=@COLOR15@ + +[csd] +preferred=server +size=26 +font=@FONT_FAMILY@ +color=@FOREGROUND_COLOR@ +hide-when-maximized=no +double-click-to-maximize=yes +border-width=0 +border-color=@BORDER_COLOR@ +button-width=26 +button-color=@BUTTON_COLOR@ +button-minimize-color=@BUTTON_MINIMIZE_COLOR@ +button-maximize-color=@BUTTON_MAXIMIZE_COLOR@ +button-close-color=@BUTTON_CLOSE_COLOR@ + +[key-bindings] +scrollback-up-page=Shift+Page_Up +scrollback-up-half-page=none +scrollback-up-line=none +scrollback-down-page=Shift+Page_Down +scrollback-down-half-page=none +scrollback-down-line=none +clipboard-copy=Control+Shift+c XF86Copy +clipboard-paste=Control+Shift+v XF86Paste +primary-paste=Shift+Insert +search-start=Control+Shift+r +font-increase=Control+plus Control+equal Control+KP_Add +font-decrease=Control+minus Control+KP_Subtract +font-reset=Control+0 Control+KP_0 +spawn-terminal=Control+Shift+n +minimize=none +maximize=none +fullscreen=F11 +pipe-visible=[sh -c "xurls | fuzzel | xargs -r firefox"] none +pipe-scrollback=[sh -c "xurls | fuzzel | xargs -r firefox"] none +pipe-selected=[xargs -r firefox] none +show-urls-launch=Control+Shift+u +show-urls-copy=none +show-urls-persistent=none +prompt-prev=Control+Shift+z +prompt-next=Control+Shift+x +unicode-input=Control+Shift+u +noop=none + +[search-bindings] +cancel=Control+g Control+c Escape +commit=Return +find-prev=Control+r +find-next=Control+s +cursor-left=Left Control+b +cursor-left-word=Control+Left Mod1+b +cursor-right=Right Control+f +cursor-right-word=Control+Right Mod1+f +cursor-home=Home Control+a +cursor-end=End Control+e +delete-prev=BackSpace +delete-prev-word=Mod1+BackSpace Control+BackSpace +delete-next=Delete +delete-next-word=Mod1+d Control+Delete +extend-to-word-boundary=Control+w +extend-to-next-whitespace=Control+Shift+w +clipboard-paste=Control+v Control+Shift+v Control+y XF86Paste +primary-paste=Shift+Insert +unicode-input=none + +[mouse-bindings] +selection-override-modifiers=Shift +primary-paste=BTN_MIDDLE +select-begin=BTN_LEFT +select-begin-block=Control+BTN_LEFT +select-extend=BTN_RIGHT +select-extend-character-wise=Control+BTN_RIGHT +select-word=BTN_LEFT-2 +select-word-whitespace=Control+BTN_LEFT-2 +select-row=BTN_LEFT-3 diff --git a/configs/hypr/colors.conf.template b/configs/hypr/colors.conf.template new file mode 100644 index 0000000..382deb9 --- /dev/null +++ b/configs/hypr/colors.conf.template @@ -0,0 +1,40 @@ +# Material You colors for Hyprland +# Generated dynamically from wallpaper + +# Slurp (selection tool) colors +exec = export SLURP_ARGS='-d -c @SLURP_BORDER_COLOR@ -b @SLURP_BACKGROUND_COLOR@ -s 00000000' + +general { + col.active_border = @ACTIVE_BORDER_COLOR@ + col.inactive_border = @INACTIVE_BORDER_COLOR@ +} + +misc { + background_color = @BACKGROUND_COLOR@ +} + +plugin { + hyprbars { + # Font configuration + bar_text_font = @BAR_FONT@ + bar_height = @BAR_HEIGHT@ + bar_padding = 10 + bar_button_padding = 5 + bar_precedence_over_border = true + bar_part_of_window = true + + bar_color = @BAR_BACKGROUND_COLOR@ + col.text = @BAR_TEXT_COLOR@ + + # Window control buttons (R -> L) + hyprbars-button = @BUTTON_COLOR@, 13, ๓ฐ–ญ, hyprctl dispatch killactive + hyprbars-button = @BUTTON_COLOR@, 13, ๓ฐ–ฏ, hyprctl dispatch fullscreen 1 + hyprbars-button = @BUTTON_COLOR@, 13, ๓ฐ–ฐ, hyprctl dispatch movetoworkspacesilent special + } +} + +# Special window border colors +windowrulev2 = bordercolor @PINNED_BORDER_COLOR@ @PINNED_BORDER_COLOR_INACTIVE@,pinned:1 + +# Additional color variables for scripts +@ADDITIONAL_COLORS@ diff --git a/configs/hypr/env.conf.template b/configs/hypr/env.conf.template new file mode 100644 index 0000000..0ee4ac9 --- /dev/null +++ b/configs/hypr/env.conf.template @@ -0,0 +1,27 @@ +# Environment variables for dots-hyprland + +# ######### Input method ########## +# See https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland +env = QT_IM_MODULE, fcitx +env = XMODIFIERS, @im=fcitx +env = SDL_IM_MODULE, fcitx +env = GLFW_IM_MODULE, ibus +env = INPUT_METHOD, fcitx + +# ############ Wayland ############# +env = ELECTRON_OZONE_PLATFORM_HINT, auto + +# ############ Themes ############# +env = QT_QPA_PLATFORM, wayland +env = QT_QPA_PLATFORMTHEME, @QT_THEME@ +env = XDG_MENU_PREFIX, plasma- + +# ######## Hardware specific ######### +@NVIDIA_ENV@ +@AMD_ENV@ + +# ######## Virtual environment ######### +env = ILLOGICAL_IMPULSE_VIRTUAL_ENV, @DATA_DIR@/.venv + +# ######## NixOS specific ######### +env = NIXOS_OZONE_WL, 1 diff --git a/configs/hypr/execs.conf.template b/configs/hypr/execs.conf.template new file mode 100644 index 0000000..f1cb713 --- /dev/null +++ b/configs/hypr/execs.conf.template @@ -0,0 +1,29 @@ +# Startup executables for dots-hyprland + +# Core desktop components +exec-once = quickshell -c $qsConfig & +exec-once = @HYPRIDLE_BIN@ + +# Authentication and security +exec-once = @GNOME_KEYRING_BIN@ --start --components=secrets +exec-once = @POLKIT_AGENT_BIN@ + +# System services +exec-once = dbus-update-activation-environment --all +exec-once = sleep 1 && dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP + +# Input method +@INPUT_METHOD_EXEC@ + +# Audio system +@AUDIO_EXEC@ + +# Clipboard management +exec-once = @WL_PASTE_BIN@ --type text --watch @CLIPHIST_BIN@ store +exec-once = @WL_PASTE_BIN@ --type image --watch @CLIPHIST_BIN@ store + +# Cursor theme +exec-once = hyprctl setcursor @CURSOR_THEME@ @CURSOR_SIZE@ + +# User custom execs +@CUSTOM_EXECS@ diff --git a/configs/hypr/general.conf.template b/configs/hypr/general.conf.template new file mode 100644 index 0000000..4584812 --- /dev/null +++ b/configs/hypr/general.conf.template @@ -0,0 +1,155 @@ +# General Hyprland configuration for dots-hyprland + +# MONITOR CONFIG +@MONITOR_CONFIG@ + +# Gestures (Hyprland 0.51+ syntax) +gestures { + gesture = 3, horizontal, workspace +} + +general { + # Gaps and border + gaps_in = @GAPS_IN@ + gaps_out = @GAPS_OUT@ + gaps_workspaces = 50 + + border_size = @BORDER_SIZE@ + col.active_border = @ACTIVE_BORDER_COLOR@ + col.inactive_border = @INACTIVE_BORDER_COLOR@ + resize_on_border = true + + no_focus_fallback = true + + allow_tearing = @ALLOW_TEARING@ + + snap { + enabled = true + } +} + +dwindle { + preserve_split = true + smart_split = false + smart_resizing = false +} + +decoration { + rounding = @ROUNDING@ + + blur { + enabled = @BLUR_ENABLED@ + xray = true + special = false + new_optimizations = true + size = @BLUR_SIZE@ + passes = @BLUR_PASSES@ + brightness = 1 + noise = 0.01 + contrast = 1 + popups = true + popups_ignorealpha = 0.6 + input_methods = true + input_methods_ignorealpha = 0.8 + } + + shadow { + enabled = @SHADOW_ENABLED@ + ignore_window = true + range = 30 + offset = 0 2 + render_power = 4 + color = @SHADOW_COLOR@ + } + + # Dim + dim_inactive = @DIM_INACTIVE@ + dim_strength = 0.025 + dim_special = 0.07 +} + +animations { + enabled = @ANIMATIONS_ENABLED@ + # Material Design curves + bezier = emphasizedDecel, 0.05, 0.7, 0.1, 1 + bezier = emphasizedAccel, 0.3, 0, 0.8, 0.15 + bezier = standardDecel, 0, 0, 0, 1 + bezier = menu_decel, 0.1, 1, 0, 1 + bezier = menu_accel, 0.52, 0.03, 0.72, 0.08 + + # Window animations + animation = windowsIn, 1, 3, emphasizedDecel, popin 80% + animation = windowsOut, 1, 2, emphasizedDecel, popin 90% + animation = windowsMove, 1, 3, emphasizedDecel, slide + animation = border, 1, 10, emphasizedDecel + + # Layer animations + animation = layersIn, 1, 2.7, emphasizedDecel, popin 93% + animation = layersOut, 1, 2.4, menu_accel, popin 94% + animation = fadeLayersIn, 1, 0.5, menu_decel + animation = fadeLayersOut, 1, 2.7, menu_accel + + # Workspace animations + animation = workspaces, 1, 7, menu_decel, slide + animation = specialWorkspaceIn, 1, 2.8, emphasizedDecel, slidevert + animation = specialWorkspaceOut, 1, 1.2, emphasizedAccel, slidevert +} + +input { + kb_layout = @KEYBOARD_LAYOUT@ + numlock_by_default = true + repeat_delay = 250 + repeat_rate = 35 + + follow_mouse = 1 + off_window_axis_events = 2 + + touchpad { + natural_scroll = @NATURAL_SCROLL@ + disable_while_typing = true + clickfinger_behavior = true + scroll_factor = 0.5 + } +} + +misc { + disable_hyprland_logo = true + disable_splash_rendering = true + vfr = 1 + vrr = @VRR_ENABLED@ + mouse_move_enables_dpms = true + key_press_enables_dpms = true + animate_manual_resizes = false + animate_mouse_windowdragging = false + enable_swallow = @WINDOW_SWALLOW@ + swallow_regex = (foot|kitty|alacritty|Alacritty) + new_window_takes_over_fullscreen = 2 + allow_session_lock_restore = true + session_lock_xray = true + initial_workspace_tracking = false + focus_on_activate = true +} + +binds { + scroll_event_delay = 0 + hide_special_on_workspace_change = true +} + +cursor { + zoom_factor = 1 + zoom_rigid = false +} + +# Overview plugin +plugin { + hyprexpo { + columns = 3 + gap_size = 5 + bg_col = @OVERVIEW_BG_COLOR@ + workspace_method = first 1 + + enable_gesture = @OVERVIEW_GESTURE@ + gesture_distance = 300 + gesture_positive = false + } +} diff --git a/configs/hypr/hypridle.conf.template b/configs/hypr/hypridle.conf.template new file mode 100644 index 0000000..74342d8 --- /dev/null +++ b/configs/hypr/hypridle.conf.template @@ -0,0 +1,37 @@ +# hypridle configuration for dots-hyprland +# Automatic screen locking and power management + +general { + lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances. + before_sleep_cmd = loginctl lock-session # lock before suspend. + after_sleep_cmd = hyprctl dispatch dpms on # to avoid having to press a key twice to turn on the display. +} + +listener { + timeout = 150 # 2.5min + on-timeout = brightnessctl -s set 10 # set monitor backlight to minimum, avoid 0 on OLED monitor. + on-resume = brightnessctl -r # monitor backlight restore. +} + +# turn off keyboard backlight, comment out this section if you dont have a keyboard backlight. +listener { + timeout = 150 # 2.5min + on-timeout = brightnessctl -sd rgb:kbd_backlight set 0 # turn off keyboard backlight. + on-resume = brightnessctl -rd rgb:kbd_backlight # turn on keyboard backlight. +} + +listener { + timeout = 300 # 5min + on-timeout = loginctl lock-session # lock screen when timeout has passed +} + +listener { + timeout = 330 # 5.5min + on-timeout = hyprctl dispatch dpms off # screen off when timeout has passed + on-resume = hyprctl dispatch dpms on # screen on when activity is detected after timeout has fired. +} + +listener { + timeout = 1800 # 30min + on-timeout = systemctl suspend # suspend pc +} diff --git a/configs/hypr/hyprland.conf.template b/configs/hypr/hyprland.conf.template new file mode 100644 index 0000000..a13a448 --- /dev/null +++ b/configs/hypr/hyprland.conf.template @@ -0,0 +1,18 @@ +# dots-hyprland NixOS Configuration +# Generated from template - do not edit directly + +# Quickshell configuration +$qsConfig = ii +exec = hyprctl dispatch submap global # DO NOT REMOVE THIS OR YOU WON'T BE ABLE TO USE ANY KEYBIND +submap = global # This is required for catchall to work + +# Source configuration files +source=~/.config/hypr/env.conf +source=~/.config/hypr/execs.conf +source=~/.config/hypr/general.conf +source=~/.config/hypr/rules.conf +source=~/.config/hypr/colors.conf +source=~/.config/hypr/keybinds.conf + +# Custom user overrides (if they exist) +source=~/.config/hypr/custom.conf diff --git a/configs/hypr/keybinds.conf.template b/configs/hypr/keybinds.conf.template new file mode 100644 index 0000000..b581a06 --- /dev/null +++ b/configs/hypr/keybinds.conf.template @@ -0,0 +1,219 @@ +# Lines ending with `# [hidden]` won't be shown on cheatsheet +# Lines starting with #! are section headings + +#! +##! Shell +# These absolutely need to be on top, or they won't work consistently +bindid = $Secondary, Space, Toggle overview, global, quickshell:overviewToggleRelease # Toggle overview/launcher +bind = $Secondary, Space, exec, hyprctl dispatch global quickshell:overviewToggle -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || @FUZZEL_BIN@ # [hidden] Launcher (fallback) +binditn = Super, catchall, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Ctrl, Super_L, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:272, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:273, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:274, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:275, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:276, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:277, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse_up, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse_down,global, quickshell:overviewToggleReleaseInterrupt # [hidden] + +bindit = ,Super_L, global, quickshell:workspaceNumber # [hidden] +bindd = Super, V, Clipboard history >> clipboard, global, quickshell:overviewClipboardToggle # Clipboard history >> clipboard +bindd = Super, Period, Emoji >> clipboard, global, quickshell:overviewEmojiToggle # Emoji >> clipboard +bindd = Super, Tab, Toggle overview, global, quickshell:overviewToggle # [hidden] Toggle overview/launcher (alt) +bindd = Super, A, Toggle left sidebar, global, quickshell:sidebarLeftToggle # Toggle left sidebar +bind = Super+Alt, A, global, quickshell:sidebarLeftToggleDetach # [hidden] +bind = Super, B, global, quickshell:sidebarLeftToggle # [hidden] +bind = Super, O, global, quickshell:sidebarLeftToggle # [hidden] +bindd = Super, N, Toggle right sidebar, global, quickshell:sidebarRightToggle # Toggle right sidebar +bindd = Super, Slash, Toggle cheatsheet, global, quickshell:cheatsheetToggle # Toggle cheatsheet +bindd = Super, K, Toggle on-screen keyboard, global, quickshell:oskToggle # Toggle on-screen keyboard +bindd = Super, M, Toggle media controls, global, quickshell:mediaControlsToggle # Toggle media controls +bindd = Ctrl+Alt, Delete, Toggle session menu, global, quickshell:sessionToggle # Toggle session menu +bindd = Super, J, Toggle bar, global, quickshell:barToggle # Toggle bar +bind = Ctrl+Alt, Delete, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill wlogout || @WLOGOUT_BIN@ -p layer-shell # [hidden] Session menu (fallback) +bind = Shift+Super+Alt, Slash, exec, @QUICKSHELL_BIN@ -p ~/.config/quickshell/$qsConfig/welcome.qml # [hidden] Launch welcome app + +bindle=, XF86MonBrightnessUp, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call brightness increment || @BRIGHTNESSCTL_BIN@ s 5%+ # [hidden] +bindle=, XF86MonBrightnessDown, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call brightness decrement || @BRIGHTNESSCTL_BIN@ s 5%- # [hidden] +bindle=, XF86AudioRaiseVolume, exec, @WPCTL_BIN@ set-volume -l 1 @DEFAULT_AUDIO_SINK@ 2%+ # [hidden] +bindle=, XF86AudioLowerVolume, exec, @WPCTL_BIN@ set-volume @DEFAULT_AUDIO_SINK@ 2%- # [hidden] + +bindl = ,XF86AudioMute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SINK@ toggle # [hidden] +bindld = Super+Shift,M, Toggle mute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SINK@ toggle # [hidden] +bindl = Alt ,XF86AudioMute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SOURCE@ toggle # [hidden] +bindl = ,XF86AudioMicMute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SOURCE@ toggle # [hidden] +bindld = Super+Alt,M, Toggle mic, exec, @WPCTL_BIN@ set-mute @DEFAULT_SOURCE@ toggle # [hidden] +bindd = Ctrl+Super, T, Change wallpaper, exec, ~/.config/quickshell/$qsConfig/scripts/colors/switchwall.sh # Change wallpaper +bind = Ctrl+Super, R, exec, killall ags agsv1 gjs ydotool qs quickshell; @QUICKSHELL_BIN@ -c $qsConfig & # Restart widgets + +##! Utilities +# Screenshot, Record, OCR, Color picker, Clipboard history +bindd = Super, V, Copy clipboard history entry, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || @CLIPHIST_BIN@ list | @FUZZEL_BIN@ --match-mode fzf --dmenu | @CLIPHIST_BIN@ decode | @WL_COPY_BIN@ # [hidden] Clipboard history >> clipboard (fallback) +bindd = Super, Period, Copy an emoji, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || ~/.config/hypr/scripts/fuzzel-emoji.sh copy # [hidden] Emoji >> clipboard (fallback) +bindd = Super+Shift, S, Screen snip, exec, @QUICKSHELL_BIN@ -p ~/.config/quickshell/$qsConfig/screenshot.qml || pidof slurp || @HYPRSHOT_BIN@ --freeze --clipboard-only --mode region --silent # Screen snip +# OCR +bindd = Super+Shift, T, Character recognition,exec,@GRIM_BIN@ -g "$(@SLURP_BIN@ $SLURP_ARGS)" "tmp.png" && @TESSERACT_BIN@ "tmp.png" - | @WL_COPY_BIN@ && rm "tmp.png" # [hidden] +# Color picker +bindd = Super+Shift, C, Color picker, exec, @HYPRPICKER_BIN@ -a # Pick color (Hex) >> clipboard +# Fullscreen screenshot +bindld = ,Print, Screenshot >> clipboard ,exec,@GRIM_BIN@ - | @WL_COPY_BIN@ # Screenshot >> clipboard +bindld = Ctrl,Print, Screenshot >> clipboard & save, exec, mkdir -p $(xdg-user-dir PICTURES)/Screenshots && @GRIM_BIN@ $(xdg-user-dir PICTURES)/Screenshots/Screenshot_"$(date '+%Y-%m-%d_%H.%M.%S')".png # Screenshot >> clipboard & file +# Recording stuff +bindd = Super+Alt, R, Record region (no sound), exec, ~/.config/hypr/scripts/record.sh # Record region (no sound) +bindd = Ctrl+Alt, R, Record screen (no sound), exec, ~/.config/hypr/scripts/record.sh --fullscreen # [hidden] Record screen (no sound) +bindd = Super+Shift+Alt, R, Record screen (with sound), exec, ~/.config/hypr/scripts/record.sh --fullscreen-sound # Record screen (with sound) +# AI +bindd = Super+Shift+Alt, mouse:273, Generate AI summary for selected text, exec, ~/.config/hypr/scripts/ai/primary-buffer-query.sh # AI summary for selected text + +#! +##! Window +# Focusing +bindm = Super, mouse:272, movewindow # Move +bindm = Super, mouse:274, movewindow # [hidden] +bindm = Super, mouse:273, resizewindow # Resize +#/# bind = Super, โ†/โ†‘/โ†’/โ†“,, # Focus in direction +bind = Super, Left, movefocus, l # [hidden] +bind = Super, Right, movefocus, r # [hidden] +bind = Super, Up, movefocus, u # [hidden] +bind = Super, Down, movefocus, d # [hidden] +bind = Super, BracketLeft, movefocus, l # [hidden] +bind = Super, BracketRight, movefocus, r # [hidden] +#/# bind = Super+Shift, โ†/โ†‘/โ†’/โ†“,, # Move in direction +bind = Super+Shift, Left, movewindow, l # [hidden] +bind = Super+Shift, Right, movewindow, r # [hidden] +bind = Super+Shift, Up, movewindow, u # [hidden] +bind = Super+Shift, Down, movewindow, d # [hidden] +bind = Alt, F4, killactive, # [hidden] Close (Windows) +bind = Super, Q, killactive, # Close +bind = Super+Shift+Alt, Q, exec, hyprctl kill # Forcefully zap a window + +# Window split ratio +#/# binde = Super, ;/',, # Adjust split ratio +binde = Super, Semicolon, splitratio, -0.1 # [hidden] +binde = Super, Apostrophe, splitratio, +0.1 # [hidden] +# Positioning mode +bind = Super+Alt, Space, togglefloating, # Float/Tile +bind = Super, D, fullscreen, 1 # Maximize +bind = Super, F, fullscreen, 0 # Fullscreen +bind = Super+Alt, F, fullscreenstate, 0 3 # Fullscreen spoof +bind = Super, P, pin # Pin + +#/# bind = Super+Alt, Hash,, # Send to workspace # (1, 2, 3,...) +bind = Super+Alt, 1, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 1 # [hidden] +bind = Super+Alt, 2, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 2 # [hidden] +bind = Super+Alt, 3, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 3 # [hidden] +bind = Super+Alt, 4, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 4 # [hidden] +bind = Super+Alt, 5, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 5 # [hidden] +bind = Super+Alt, 6, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 6 # [hidden] +bind = Super+Alt, 7, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 7 # [hidden] +bind = Super+Alt, 8, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 8 # [hidden] +bind = Super+Alt, 9, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 9 # [hidden] +bind = Super+Alt, 0, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 10 # [hidden] + +# #/# bind = Super+Shift, Scroll โ†‘/โ†“,, # Send to workspace left/right +bind = Super+Shift, mouse_down, movetoworkspace, r-1 # [hidden] +bind = Super+Shift, mouse_up, movetoworkspace, r+1 # [hidden] +bind = Super+Alt, mouse_down, movetoworkspace, -1 # [hidden] +bind = Super+Alt, mouse_up, movetoworkspace, +1 # [hidden] + +#/# bind = Super+Shift, Page_โ†‘/โ†“,, # Send to workspace left/right +bind = Super+Alt, Page_Down, movetoworkspace, +1 # [hidden] +bind = Super+Alt, Page_Up, movetoworkspace, -1 # [hidden] +bind = Super+Shift, Page_Down, movetoworkspace, r+1 # [hidden] +bind = Super+Shift, Page_Up, movetoworkspace, r-1 # [hidden] +bind = Ctrl+Super+Shift, Right, movetoworkspace, r+1 # [hidden] +bind = Ctrl+Super+Shift, Left, movetoworkspace, r-1 # [hidden] + +bind = Super+Alt, S, movetoworkspacesilent, special # Send to scratchpad + +bind = Ctrl+Super, S, togglespecialworkspace, # [hidden] +bind = Alt, Tab, cyclenext # [hidden] sus keybind +bind = Alt, Tab, bringactivetotop, # [hidden] bring it to the top + +##! Workspace +# Switching +#/# bind = Super, Hash,, # Focus workspace # (1, 2, 3,...) +bind = Super, 1, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 1 # [hidden] +bind = Super, 2, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 2 # [hidden] +bind = Super, 3, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 3 # [hidden] +bind = Super, 4, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 4 # [hidden] +bind = Super, 5, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 5 # [hidden] +bind = Super, 6, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 6 # [hidden] +bind = Super, 7, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 7 # [hidden] +bind = Super, 8, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 8 # [hidden] +bind = Super, 9, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 9 # [hidden] +bind = Super, 0, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 10 # [hidden] + +#/# bind = Ctrl+Super, โ†/โ†’,, # Focus left/right +bind = Ctrl+Super, Right, workspace, r+1 # [hidden] +bind = Ctrl+Super, Left, workspace, r-1 # [hidden] +#/# bind = Ctrl+Super+Alt, โ†/โ†’,, # [hidden] Focus busy left/right +bind = Ctrl+Super+Alt, Right, workspace, m+1 # [hidden] +bind = Ctrl+Super+Alt, Left, workspace, m-1 # [hidden] +#/# bind = Super, Page_โ†‘/โ†“,, # Focus left/right +bind = Super, Page_Down, workspace, +1 # [hidden] +bind = Super, Page_Up, workspace, -1 # [hidden] +bind = Ctrl+Super, Page_Down, workspace, r+1 # [hidden] +bind = Ctrl+Super, Page_Up, workspace, r-1 # [hidden] +#/# bind = Super, Scroll โ†‘/โ†“,, # Focus left/right +bind = Super, mouse_up, workspace, +1 # [hidden] +bind = Super, mouse_down, workspace, -1 # [hidden] +bind = Ctrl+Super, mouse_up, workspace, r+1 # [hidden] +bind = Ctrl+Super, mouse_down, workspace, r-1 # [hidden] +## Special +bind = Super, S, togglespecialworkspace, # Toggle scratchpad +bind = Super, mouse:275, togglespecialworkspace, # [hidden] +bind = Ctrl+Super, BracketLeft, workspace, -1 # [hidden] +bind = Ctrl+Super, BracketRight, workspace, +1 # [hidden] +bind = Ctrl+Super, Up, workspace, r-5 # [hidden] +bind = Ctrl+Super, Down, workspace, r+5 # [hidden] + +#! +# Testing +bind = Super+Alt, f11, exec, bash -c 'RANDOM_IMAGE=$(find ~/Pictures -type f | grep -v -i "nipple" | grep -v -i "pussy" | shuf -n 1); ACTION=$(notify-send "Test notification with body image" "This notification should contain your user account image and Discord icon. Oh and here is a random image in your Pictures folder: \"Testing" -a "Hyprland keybind" -p -h "string:image-path:/var/lib/AccountsService/icons/$USER" -t 6000 -i "discord" -A "openImage=Open profile image" -A "action2=Open the random image" -A "action3=Useless button"); [[ $ACTION == *openImage ]] && xdg-open "/var/lib/AccountsService/icons/$USER"; [[ $ACTION == *action2 ]] && xdg-open \"$RANDOM_IMAGE\"' # [hidden] +bind = Super+Alt, f12, exec, bash -c 'RANDOM_IMAGE=$(find ~/Pictures -type f | grep -v -i "nipple" | grep -v -i "pussy" | shuf -n 1); ACTION=$(notify-send "Test notification" "This notification should contain a random image in your Pictures folder and Discord icon.\nFlick right to dismiss!" -a "Discord (fake)" -p -h "string:image-path:$RANDOM_IMAGE" -t 6000 -i "discord" -A "openImage=Open profile image" -A "action2=Useless button" -A "action3=Cry more"); [[ $ACTION == *openImage ]] && xdg-open "/var/lib/AccountsService/icons/$USER"' # [hidden] +bind = Super+Alt, Equal, exec, notify-send "Urgent notification" "Ah hell no" -u critical -a 'Hyprland keybind' # [hidden] + +##! Session +bindd = Super, L, Lock, exec, loginctl lock-session # Lock +bind = Super+Shift, L, exec, loginctl lock-session # [hidden] +bindld = Super+Shift, L, Suspend system, exec, sleep 0.1 && systemctl suspend || loginctl suspend # Sleep +bindd = Ctrl+Shift+Alt+Super, Delete, Shutdown, exec, systemctl poweroff || loginctl poweroff # [hidden] Power off + +##! Screen +# Zoom +binde = Super, Minus, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call zoom zoomOut # Zoom out +binde = Super, Equal, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call zoom zoomIn # Zoom in +binde = Super, Minus, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || ~/.config/hypr/scripts/zoom.sh decrease 0.1 # [hidden] Zoom out +binde = Super, Equal, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || ~/.config/hypr/scripts/zoom.sh increase 0.1 # [hidden] Zoom in + +##! Media +bindl= Super+Shift, N, exec, @PLAYERCTL_BIN@ next || @PLAYERCTL_BIN@ position `bc <<< "100 * $(@PLAYERCTL_BIN@ metadata mpris:length) / 1000000 / 100"` # Next track +bindl= ,XF86AudioNext, exec, @PLAYERCTL_BIN@ next || @PLAYERCTL_BIN@ position `bc <<< "100 * $(@PLAYERCTL_BIN@ metadata mpris:length) / 1000000 / 100"` # [hidden] +bindl= ,XF86AudioPrev, exec, @PLAYERCTL_BIN@ previous # [hidden] +bind = Super+Shift+Alt, mouse:275, exec, @PLAYERCTL_BIN@ previous # [hidden] +bind = Super+Shift+Alt, mouse:276, exec, @PLAYERCTL_BIN@ next || @PLAYERCTL_BIN@ position `bc <<< "100 * $(@PLAYERCTL_BIN@ metadata mpris:length) / 1000000 / 100"` # [hidden] +bindl= Super+Shift, B, exec, @PLAYERCTL_BIN@ previous # Previous track +bindl= Super+Shift, P, exec, @PLAYERCTL_BIN@ play-pause # Play/pause media +bindl= ,XF86AudioPlay, exec, @PLAYERCTL_BIN@ play-pause # [hidden] +bindl= ,XF86AudioPause, exec, @PLAYERCTL_BIN@ play-pause # [hidden] + +##! Apps +bind = Super, Return, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TERMINAL_APPS@" # Terminal +bind = Super, T, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TERMINAL_APPS@" # [hidden] Terminal (alt) +bind = Ctrl+Alt, T, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TERMINAL_APPS@" # [hidden] Terminal (Ubuntu style) +bind = Super, E, exec, ~/.config/hypr/scripts/launch_first_available.sh "@FILE_MANAGER_APPS@" # File manager +bind = Super, W, exec, ~/.config/hypr/scripts/launch_first_available.sh "@BROWSER_APPS@" # Browser +bind = Super, C, exec, ~/.config/hypr/scripts/launch_first_available.sh "@CODE_EDITOR_APPS@" # Code editor +bind = Super+Shift, W, exec, ~/.config/hypr/scripts/launch_first_available.sh "@OFFICE_APPS@" # Office software +bind = Super, X, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TEXT_EDITOR_APPS@" # Text editor +bind = Ctrl+Super, V, exec, ~/.config/hypr/scripts/launch_first_available.sh "@VOLUME_MIXER_APPS@" # Volume mixer +bind = Super, I, exec, XDG_CURRENT_DESKTOP=gnome ~/.config/hypr/scripts/launch_first_available.sh "@SETTINGS_APPS@" # Settings app +bind = Ctrl+Shift, Escape, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TASK_MANAGER_APPS@" # Task manager + +# Cursed stuff +## Make window not amogus large +bind = Ctrl+Super, Backslash, resizeactive, exact 640 480 # [hidden] + +@CUSTOM_KEYBINDS@ diff --git a/configs/hypr/keybinds.conf.template.backup b/configs/hypr/keybinds.conf.template.backup new file mode 100644 index 0000000..d00d920 --- /dev/null +++ b/configs/hypr/keybinds.conf.template.backup @@ -0,0 +1,219 @@ +# Lines ending with `# [hidden]` won't be shown on cheatsheet +# Lines starting with #! are section headings + +#! +##! Shell +# These absolutely need to be on top, or they won't work consistently +bindid = Super, Super_L, Toggle overview, global, quickshell:overviewToggleRelease # Toggle overview/launcher +bind = Super, Super_L, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || @FUZZEL_BIN@ # [hidden] Launcher (fallback) +binditn = Super, catchall, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Ctrl, Super_L, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:272, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:273, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:274, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:275, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:276, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse:277, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse_up, global, quickshell:overviewToggleReleaseInterrupt # [hidden] +bind = Super, mouse_down,global, quickshell:overviewToggleReleaseInterrupt # [hidden] + +bindit = ,Super_L, global, quickshell:workspaceNumber # [hidden] +bindd = Super, V, Clipboard history >> clipboard, global, quickshell:overviewClipboardToggle # Clipboard history >> clipboard +bindd = Super, Period, Emoji >> clipboard, global, quickshell:overviewEmojiToggle # Emoji >> clipboard +bindd = Super, Tab, Toggle overview, global, quickshell:overviewToggle # [hidden] Toggle overview/launcher (alt) +bindd = Super, A, Toggle left sidebar, global, quickshell:sidebarLeftToggle # Toggle left sidebar +bind = Super+Alt, A, global, quickshell:sidebarLeftToggleDetach # [hidden] +bind = Super, B, global, quickshell:sidebarLeftToggle # [hidden] +bind = Super, O, global, quickshell:sidebarLeftToggle # [hidden] +bindd = Super, N, Toggle right sidebar, global, quickshell:sidebarRightToggle # Toggle right sidebar +bindd = Super, Slash, Toggle cheatsheet, global, quickshell:cheatsheetToggle # Toggle cheatsheet +bindd = Super, K, Toggle on-screen keyboard, global, quickshell:oskToggle # Toggle on-screen keyboard +bindd = Super, M, Toggle media controls, global, quickshell:mediaControlsToggle # Toggle media controls +bindd = Ctrl+Alt, Delete, Toggle session menu, global, quickshell:sessionToggle # Toggle session menu +bindd = Super, J, Toggle bar, global, quickshell:barToggle # Toggle bar +bind = Ctrl+Alt, Delete, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill wlogout || @WLOGOUT_BIN@ -p layer-shell # [hidden] Session menu (fallback) +bind = Shift+Super+Alt, Slash, exec, @QUICKSHELL_BIN@ -p ~/.config/quickshell/$qsConfig/welcome.qml # [hidden] Launch welcome app + +bindle=, XF86MonBrightnessUp, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call brightness increment || @BRIGHTNESSCTL_BIN@ s 5%+ # [hidden] +bindle=, XF86MonBrightnessDown, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call brightness decrement || @BRIGHTNESSCTL_BIN@ s 5%- # [hidden] +bindle=, XF86AudioRaiseVolume, exec, @WPCTL_BIN@ set-volume -l 1 @DEFAULT_AUDIO_SINK@ 2%+ # [hidden] +bindle=, XF86AudioLowerVolume, exec, @WPCTL_BIN@ set-volume @DEFAULT_AUDIO_SINK@ 2%- # [hidden] + +bindl = ,XF86AudioMute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SINK@ toggle # [hidden] +bindld = Super+Shift,M, Toggle mute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SINK@ toggle # [hidden] +bindl = Alt ,XF86AudioMute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SOURCE@ toggle # [hidden] +bindl = ,XF86AudioMicMute, exec, @WPCTL_BIN@ set-mute @DEFAULT_SOURCE@ toggle # [hidden] +bindld = Super+Alt,M, Toggle mic, exec, @WPCTL_BIN@ set-mute @DEFAULT_SOURCE@ toggle # [hidden] +bindd = Ctrl+Super, T, Change wallpaper, exec, ~/.config/quickshell/$qsConfig/scripts/colors/switchwall.sh # Change wallpaper +bind = Ctrl+Super, R, exec, killall ags agsv1 gjs ydotool qs quickshell; @QUICKSHELL_BIN@ -c $qsConfig & # Restart widgets + +##! Utilities +# Screenshot, Record, OCR, Color picker, Clipboard history +bindd = Super, V, Copy clipboard history entry, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || @CLIPHIST_BIN@ list | @FUZZEL_BIN@ --match-mode fzf --dmenu | @CLIPHIST_BIN@ decode | @WL_COPY_BIN@ # [hidden] Clipboard history >> clipboard (fallback) +bindd = Super, Period, Copy an emoji, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || ~/.config/hypr/scripts/fuzzel-emoji.sh copy # [hidden] Emoji >> clipboard (fallback) +bindd = Super+Shift, S, Screen snip, exec, @QUICKSHELL_BIN@ -p ~/.config/quickshell/$qsConfig/screenshot.qml || pidof slurp || @HYPRSHOT_BIN@ --freeze --clipboard-only --mode region --silent # Screen snip +# OCR +bindd = Super+Shift, T, Character recognition,exec,@GRIM_BIN@ -g "$(@SLURP_BIN@ $SLURP_ARGS)" "tmp.png" && @TESSERACT_BIN@ "tmp.png" - | @WL_COPY_BIN@ && rm "tmp.png" # [hidden] +# Color picker +bindd = Super+Shift, C, Color picker, exec, @HYPRPICKER_BIN@ -a # Pick color (Hex) >> clipboard +# Fullscreen screenshot +bindld = ,Print, Screenshot >> clipboard ,exec,@GRIM_BIN@ - | @WL_COPY_BIN@ # Screenshot >> clipboard +bindld = Ctrl,Print, Screenshot >> clipboard & save, exec, mkdir -p $(xdg-user-dir PICTURES)/Screenshots && @GRIM_BIN@ $(xdg-user-dir PICTURES)/Screenshots/Screenshot_"$(date '+%Y-%m-%d_%H.%M.%S')".png # Screenshot >> clipboard & file +# Recording stuff +bindd = Super+Alt, R, Record region (no sound), exec, ~/.config/hypr/scripts/record.sh # Record region (no sound) +bindd = Ctrl+Alt, R, Record screen (no sound), exec, ~/.config/hypr/scripts/record.sh --fullscreen # [hidden] Record screen (no sound) +bindd = Super+Shift+Alt, R, Record screen (with sound), exec, ~/.config/hypr/scripts/record.sh --fullscreen-sound # Record screen (with sound) +# AI +bindd = Super+Shift+Alt, mouse:273, Generate AI summary for selected text, exec, ~/.config/hypr/scripts/ai/primary-buffer-query.sh # AI summary for selected text + +#! +##! Window +# Focusing +bindm = Super, mouse:272, movewindow # Move +bindm = Super, mouse:274, movewindow # [hidden] +bindm = Super, mouse:273, resizewindow # Resize +#/# bind = Super, โ†/โ†‘/โ†’/โ†“,, # Focus in direction +bind = Super, Left, movefocus, l # [hidden] +bind = Super, Right, movefocus, r # [hidden] +bind = Super, Up, movefocus, u # [hidden] +bind = Super, Down, movefocus, d # [hidden] +bind = Super, BracketLeft, movefocus, l # [hidden] +bind = Super, BracketRight, movefocus, r # [hidden] +#/# bind = Super+Shift, โ†/โ†‘/โ†’/โ†“,, # Move in direction +bind = Super+Shift, Left, movewindow, l # [hidden] +bind = Super+Shift, Right, movewindow, r # [hidden] +bind = Super+Shift, Up, movewindow, u # [hidden] +bind = Super+Shift, Down, movewindow, d # [hidden] +bind = Alt, F4, killactive, # [hidden] Close (Windows) +bind = Super, Q, killactive, # Close +bind = Super+Shift+Alt, Q, exec, hyprctl kill # Forcefully zap a window + +# Window split ratio +#/# binde = Super, ;/',, # Adjust split ratio +binde = Super, Semicolon, splitratio, -0.1 # [hidden] +binde = Super, Apostrophe, splitratio, +0.1 # [hidden] +# Positioning mode +bind = Super+Alt, Space, togglefloating, # Float/Tile +bind = Super, D, fullscreen, 1 # Maximize +bind = Super, F, fullscreen, 0 # Fullscreen +bind = Super+Alt, F, fullscreenstate, 0 3 # Fullscreen spoof +bind = Super, P, pin # Pin + +#/# bind = Super+Alt, Hash,, # Send to workspace # (1, 2, 3,...) +bind = Super+Alt, 1, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 1 # [hidden] +bind = Super+Alt, 2, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 2 # [hidden] +bind = Super+Alt, 3, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 3 # [hidden] +bind = Super+Alt, 4, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 4 # [hidden] +bind = Super+Alt, 5, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 5 # [hidden] +bind = Super+Alt, 6, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 6 # [hidden] +bind = Super+Alt, 7, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 7 # [hidden] +bind = Super+Alt, 8, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 8 # [hidden] +bind = Super+Alt, 9, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 9 # [hidden] +bind = Super+Alt, 0, exec, ~/.config/hypr/scripts/workspace_action.sh movetoworkspacesilent 10 # [hidden] + +# #/# bind = Super+Shift, Scroll โ†‘/โ†“,, # Send to workspace left/right +bind = Super+Shift, mouse_down, movetoworkspace, r-1 # [hidden] +bind = Super+Shift, mouse_up, movetoworkspace, r+1 # [hidden] +bind = Super+Alt, mouse_down, movetoworkspace, -1 # [hidden] +bind = Super+Alt, mouse_up, movetoworkspace, +1 # [hidden] + +#/# bind = Super+Shift, Page_โ†‘/โ†“,, # Send to workspace left/right +bind = Super+Alt, Page_Down, movetoworkspace, +1 # [hidden] +bind = Super+Alt, Page_Up, movetoworkspace, -1 # [hidden] +bind = Super+Shift, Page_Down, movetoworkspace, r+1 # [hidden] +bind = Super+Shift, Page_Up, movetoworkspace, r-1 # [hidden] +bind = Ctrl+Super+Shift, Right, movetoworkspace, r+1 # [hidden] +bind = Ctrl+Super+Shift, Left, movetoworkspace, r-1 # [hidden] + +bind = Super+Alt, S, movetoworkspacesilent, special # Send to scratchpad + +bind = Ctrl+Super, S, togglespecialworkspace, # [hidden] +bind = Alt, Tab, cyclenext # [hidden] sus keybind +bind = Alt, Tab, bringactivetotop, # [hidden] bring it to the top + +##! Workspace +# Switching +#/# bind = Super, Hash,, # Focus workspace # (1, 2, 3,...) +bind = Super, 1, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 1 # [hidden] +bind = Super, 2, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 2 # [hidden] +bind = Super, 3, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 3 # [hidden] +bind = Super, 4, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 4 # [hidden] +bind = Super, 5, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 5 # [hidden] +bind = Super, 6, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 6 # [hidden] +bind = Super, 7, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 7 # [hidden] +bind = Super, 8, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 8 # [hidden] +bind = Super, 9, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 9 # [hidden] +bind = Super, 0, exec, ~/.config/hypr/scripts/workspace_action.sh workspace 10 # [hidden] + +#/# bind = Ctrl+Super, โ†/โ†’,, # Focus left/right +bind = Ctrl+Super, Right, workspace, r+1 # [hidden] +bind = Ctrl+Super, Left, workspace, r-1 # [hidden] +#/# bind = Ctrl+Super+Alt, โ†/โ†’,, # [hidden] Focus busy left/right +bind = Ctrl+Super+Alt, Right, workspace, m+1 # [hidden] +bind = Ctrl+Super+Alt, Left, workspace, m-1 # [hidden] +#/# bind = Super, Page_โ†‘/โ†“,, # Focus left/right +bind = Super, Page_Down, workspace, +1 # [hidden] +bind = Super, Page_Up, workspace, -1 # [hidden] +bind = Ctrl+Super, Page_Down, workspace, r+1 # [hidden] +bind = Ctrl+Super, Page_Up, workspace, r-1 # [hidden] +#/# bind = Super, Scroll โ†‘/โ†“,, # Focus left/right +bind = Super, mouse_up, workspace, +1 # [hidden] +bind = Super, mouse_down, workspace, -1 # [hidden] +bind = Ctrl+Super, mouse_up, workspace, r+1 # [hidden] +bind = Ctrl+Super, mouse_down, workspace, r-1 # [hidden] +## Special +bind = Super, S, togglespecialworkspace, # Toggle scratchpad +bind = Super, mouse:275, togglespecialworkspace, # [hidden] +bind = Ctrl+Super, BracketLeft, workspace, -1 # [hidden] +bind = Ctrl+Super, BracketRight, workspace, +1 # [hidden] +bind = Ctrl+Super, Up, workspace, r-5 # [hidden] +bind = Ctrl+Super, Down, workspace, r+5 # [hidden] + +#! +# Testing +bind = Super+Alt, f11, exec, bash -c 'RANDOM_IMAGE=$(find ~/Pictures -type f | grep -v -i "nipple" | grep -v -i "pussy" | shuf -n 1); ACTION=$(notify-send "Test notification with body image" "This notification should contain your user account image and Discord icon. Oh and here is a random image in your Pictures folder: \"Testing" -a "Hyprland keybind" -p -h "string:image-path:/var/lib/AccountsService/icons/$USER" -t 6000 -i "discord" -A "openImage=Open profile image" -A "action2=Open the random image" -A "action3=Useless button"); [[ $ACTION == *openImage ]] && xdg-open "/var/lib/AccountsService/icons/$USER"; [[ $ACTION == *action2 ]] && xdg-open \"$RANDOM_IMAGE\"' # [hidden] +bind = Super+Alt, f12, exec, bash -c 'RANDOM_IMAGE=$(find ~/Pictures -type f | grep -v -i "nipple" | grep -v -i "pussy" | shuf -n 1); ACTION=$(notify-send "Test notification" "This notification should contain a random image in your Pictures folder and Discord icon.\nFlick right to dismiss!" -a "Discord (fake)" -p -h "string:image-path:$RANDOM_IMAGE" -t 6000 -i "discord" -A "openImage=Open profile image" -A "action2=Useless button" -A "action3=Cry more"); [[ $ACTION == *openImage ]] && xdg-open "/var/lib/AccountsService/icons/$USER"' # [hidden] +bind = Super+Alt, Equal, exec, notify-send "Urgent notification" "Ah hell no" -u critical -a 'Hyprland keybind' # [hidden] + +##! Session +bindd = Super, L, Lock, exec, loginctl lock-session # Lock +bind = Super+Shift, L, exec, loginctl lock-session # [hidden] +bindld = Super+Shift, L, Suspend system, exec, sleep 0.1 && systemctl suspend || loginctl suspend # Sleep +bindd = Ctrl+Shift+Alt+Super, Delete, Shutdown, exec, systemctl poweroff || loginctl poweroff # [hidden] Power off + +##! Screen +# Zoom +binde = Super, Minus, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call zoom zoomOut # Zoom out +binde = Super, Equal, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call zoom zoomIn # Zoom in +binde = Super, Minus, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || ~/.config/hypr/scripts/zoom.sh decrease 0.1 # [hidden] Zoom out +binde = Super, Equal, exec, @QUICKSHELL_BIN@ -c $qsConfig ipc call TEST_ALIVE || ~/.config/hypr/scripts/zoom.sh increase 0.1 # [hidden] Zoom in + +##! Media +bindl= Super+Shift, N, exec, @PLAYERCTL_BIN@ next || @PLAYERCTL_BIN@ position `bc <<< "100 * $(@PLAYERCTL_BIN@ metadata mpris:length) / 1000000 / 100"` # Next track +bindl= ,XF86AudioNext, exec, @PLAYERCTL_BIN@ next || @PLAYERCTL_BIN@ position `bc <<< "100 * $(@PLAYERCTL_BIN@ metadata mpris:length) / 1000000 / 100"` # [hidden] +bindl= ,XF86AudioPrev, exec, @PLAYERCTL_BIN@ previous # [hidden] +bind = Super+Shift+Alt, mouse:275, exec, @PLAYERCTL_BIN@ previous # [hidden] +bind = Super+Shift+Alt, mouse:276, exec, @PLAYERCTL_BIN@ next || @PLAYERCTL_BIN@ position `bc <<< "100 * $(@PLAYERCTL_BIN@ metadata mpris:length) / 1000000 / 100"` # [hidden] +bindl= Super+Shift, B, exec, @PLAYERCTL_BIN@ previous # Previous track +bindl= Super+Shift, P, exec, @PLAYERCTL_BIN@ play-pause # Play/pause media +bindl= ,XF86AudioPlay, exec, @PLAYERCTL_BIN@ play-pause # [hidden] +bindl= ,XF86AudioPause, exec, @PLAYERCTL_BIN@ play-pause # [hidden] + +##! Apps +bind = Super, Return, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TERMINAL_APPS@" # Terminal +bind = Super, T, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TERMINAL_APPS@" # [hidden] Terminal (alt) +bind = Ctrl+Alt, T, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TERMINAL_APPS@" # [hidden] Terminal (Ubuntu style) +bind = Super, E, exec, ~/.config/hypr/scripts/launch_first_available.sh "@FILE_MANAGER_APPS@" # File manager +bind = Super, W, exec, ~/.config/hypr/scripts/launch_first_available.sh "@BROWSER_APPS@" # Browser +bind = Super, C, exec, ~/.config/hypr/scripts/launch_first_available.sh "@CODE_EDITOR_APPS@" # Code editor +bind = Super+Shift, W, exec, ~/.config/hypr/scripts/launch_first_available.sh "@OFFICE_APPS@" # Office software +bind = Super, X, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TEXT_EDITOR_APPS@" # Text editor +bind = Ctrl+Super, V, exec, ~/.config/hypr/scripts/launch_first_available.sh "@VOLUME_MIXER_APPS@" # Volume mixer +bind = Super, I, exec, XDG_CURRENT_DESKTOP=gnome ~/.config/hypr/scripts/launch_first_available.sh "@SETTINGS_APPS@" # Settings app +bind = Ctrl+Shift, Escape, exec, ~/.config/hypr/scripts/launch_first_available.sh "@TASK_MANAGER_APPS@" # Task manager + +# Cursed stuff +## Make window not amogus large +bind = Ctrl+Super, Backslash, resizeactive, exact 640 480 # [hidden] + +@CUSTOM_KEYBINDS@ diff --git a/configs/hypr/rules.conf.template b/configs/hypr/rules.conf.template new file mode 100644 index 0000000..1dc1522 --- /dev/null +++ b/configs/hypr/rules.conf.template @@ -0,0 +1,161 @@ +# ######## Window rules ######## + +# Global transparency (uncomment to apply to all windows) +@GLOBAL_TRANSPARENCY@ + +# Disable blur for xwayland context menus +windowrulev2 = noblur,class:^()$,title:^()$ + +# Floating windows +windowrulev2 = float, class:^(blueberry\.py)$ +windowrulev2 = float, class:^(guifetch)$ # FlafyDev/guifetch +windowrulev2 = float, class:^(pavucontrol)$ +windowrulev2 = size 45%, class:^(pavucontrol)$ +windowrulev2 = center, class:^(pavucontrol)$ +windowrulev2 = float, class:^(org.pulseaudio.pavucontrol)$ +windowrulev2 = size 45%, class:^(org.pulseaudio.pavucontrol)$ +windowrulev2 = center, class:^(org.pulseaudio.pavucontrol)$ +windowrulev2 = float, class:^(nm-connection-editor)$ +windowrulev2 = size 45%, class:^(nm-connection-editor)$ +windowrulev2 = center, class:^(nm-connection-editor)$ +windowrulev2 = float, class:.*plasmawindowed.* +windowrulev2 = float, class:kcm_.* +windowrulev2 = float, class:.*bluedevilwizard +windowrulev2 = float, title:.*Welcome +windowrulev2 = float, title:^(illogical-impulse Settings)$ +windowrulev2 = float, class:org.freedesktop.impl.portal.desktop.kde +windowrulev2 = float, class:^(Zotero)$ +windowrulev2 = size 45%, class:^(Zotero)$ + +# Special positioning +# kde-material-you-colors spawns a window when changing dark/light theme +windowrulev2 = float, class:^(plasma-changeicons)$ +windowrulev2 = noinitialfocus, class:^(plasma-changeicons)$ +windowrulev2 = move 999999 999999, class:^(plasma-changeicons)$ +# Dolphin copy dialog +windowrulev2 = move 40 80, title:^(Copying โ€” Dolphin)$ + +# Force tiling for specific apps +windowrulev2 = tile, class:^dev\.warp\.Warp$ + +# Picture-in-Picture +windowrulev2 = float, title:^([Pp]icture[-\s]?[Ii]n[-\s]?[Pp]icture)(.*)$ +windowrulev2 = keepaspectratio, title:^([Pp]icture[-\s]?[Ii]n[-\s]?[Pp]icture)(.*)$ +windowrulev2 = move 73% 72%, title:^([Pp]icture[-\s]?[Ii]n[-\s]?[Pp]icture)(.*)$ +windowrulev2 = size 25%, title:^([Pp]icture[-\s]?[Ii]n[-\s]?[Pp]icture)(.*)$ +windowrulev2 = pin, title:^([Pp]icture[-\s]?[Ii]n[-\s]?[Pp]icture)(.*)$ + +# Dialog windows โ€“ float+center these windows +windowrulev2 = center, title:^(Open File)(.*)$ +windowrulev2 = center, title:^(Select a File)(.*)$ +windowrulev2 = center, title:^(Choose wallpaper)(.*)$ +windowrulev2 = center, title:^(Open Folder)(.*)$ +windowrulev2 = center, title:^(Save As)(.*)$ +windowrulev2 = center, title:^(Library)(.*)$ +windowrulev2 = center, title:^(File Upload)(.*)$ +windowrulev2 = center, title:^(.*)(wants to save)$ +windowrulev2 = center, title:^(.*)(wants to open)$ +windowrulev2 = float, title:^(Open File)(.*)$ +windowrulev2 = float, title:^(Select a File)(.*)$ +windowrulev2 = float, title:^(Choose wallpaper)(.*)$ +windowrulev2 = float, title:^(Open Folder)(.*)$ +windowrulev2 = float, title:^(Save As)(.*)$ +windowrulev2 = float, title:^(Library)(.*)$ +windowrulev2 = float, title:^(File Upload)(.*)$ +windowrulev2 = float, title:^(.*)(wants to save)$ +windowrulev2 = float, title:^(.*)(wants to open)$ + +# --- Tearing for gaming --- +windowrulev2 = immediate, title:.*\.exe +windowrulev2 = immediate, title:.*minecraft.* +windowrulev2 = immediate, class:^(steam_app).* + +# No shadow for tiled windows +windowrulev2 = noshadow, floating:0 + +# ######## Workspace rules ######## +workspace = special:special, gapsout:30 + +# ######## Layer rules ######## +layerrule = xray 1, .* + +# No animations for specific layers +layerrule = noanim, walker +layerrule = noanim, selection +layerrule = noanim, overview +layerrule = noanim, anyrun +layerrule = noanim, indicator.* +layerrule = noanim, osk +layerrule = noanim, hyprpicker +layerrule = noanim, noanim + +# GTK layer shell +layerrule = blur, gtk-layer-shell +layerrule = ignorezero, gtk-layer-shell + +# Launcher +layerrule = blur, launcher +layerrule = ignorealpha 0.5, launcher + +# Notifications +layerrule = blur, notifications +layerrule = ignorealpha 0.69, notifications + +# Logout dialog +layerrule = blur, logout_dialog # wlogout + +# AGS layers +layerrule = animation slide left, sideleft.* +layerrule = animation slide right, sideright.* +layerrule = blur, session[0-9]* +layerrule = blur, bar[0-9]* +layerrule = ignorealpha 0.6, bar[0-9]* +layerrule = blur, barcorner.* +layerrule = ignorealpha 0.6, barcorner.* +layerrule = blur, dock[0-9]* +layerrule = ignorealpha 0.6, dock[0-9]* +layerrule = blur, indicator.* +layerrule = ignorealpha 0.6, indicator.* +layerrule = blur, overview[0-9]* +layerrule = ignorealpha 0.6, overview[0-9]* +layerrule = blur, cheatsheet[0-9]* +layerrule = ignorealpha 0.6, cheatsheet[0-9]* +layerrule = blur, sideright[0-9]* +layerrule = ignorealpha 0.6, sideright[0-9]* +layerrule = blur, sideleft[0-9]* +layerrule = ignorealpha 0.6, sideleft[0-9]* +layerrule = blur, osk[0-9]* +layerrule = ignorealpha 0.6, osk[0-9]* + +# Quickshell layers +layerrule = blurpopups, quickshell:.* +layerrule = blur, quickshell:.* +layerrule = ignorealpha 0.79, quickshell:.* +layerrule = animation slide top, quickshell:bar +layerrule = animation fade, quickshell:screenCorners +layerrule = animation slide right, quickshell:sidebarRight +layerrule = animation slide left, quickshell:sidebarLeft +layerrule = animation slide bottom, quickshell:osk +layerrule = animation slide bottom, quickshell:dock +layerrule = blur, quickshell:session +layerrule = noanim, quickshell:session +layerrule = ignorealpha 0, quickshell:session +layerrule = animation fade, quickshell:notificationPopup +layerrule = blur, quickshell:backgroundWidgets +layerrule = ignorealpha 0.05, quickshell:backgroundWidgets +layerrule = noanim, quickshell:screenshot +layerrule = animation popin 120%, quickshell:screenCorners +layerrule = noanim, quickshell:lockWindowPusher + +# Launchers need to be FAST +layerrule = noanim, quickshell:overview +layerrule = noanim, gtk4-layer-shell + +# Outfoxxed's shell layers +layerrule = blur, shell:bar +layerrule = ignorezero, shell:bar +layerrule = blur, shell:notifications +layerrule = ignorealpha 0.1, shell:notifications + +# Custom window rules +@CUSTOM_WINDOW_RULES@ diff --git a/configs/hypr/scripts/launch_first_available.sh b/configs/hypr/scripts/launch_first_available.sh new file mode 100644 index 0000000..24d7885 --- /dev/null +++ b/configs/hypr/scripts/launch_first_available.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Launch the first available application from a list +for cmd in "$@"; do + eval "command -v ${cmd%% *}" >/dev/null 2>&1 || continue + eval "$cmd" & + exit +done +exit 1 diff --git a/configs/hypr/scripts/workspace_action.sh b/configs/hypr/scripts/workspace_action.sh new file mode 100644 index 0000000..abedaa5 --- /dev/null +++ b/configs/hypr/scripts/workspace_action.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Workspace action script for dots-hyprland +hyprctl dispatch "$1" $(((($(hyprctl activeworkspace -j | jq -r .id) - 1) / 10) * 10 + $2)) diff --git a/configs/hyprland-keybinds.conf b/configs/hyprland-keybinds.conf new file mode 100644 index 0000000..2bdcfac --- /dev/null +++ b/configs/hyprland-keybinds.conf @@ -0,0 +1,214 @@ +# dots-hyprland keybindings adapted for quickshell +# Based on /home/celes/.config/hypr/custom/keybinds.conf + +# Key definitions +$Primary = Super +$Secondary = Control +$Tertiary = Shift +$Alternate = Alt +$MenuButton = Menu + +# ################### It just worksโ„ข keybinds ################### +# Volume +bindl = ,XF86AudioMute, exec, pactl set-sink-mute @DEFAULT_SINK@ toggle +bindle = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ +bindle = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- + +# Brightness (adapted for quickshell) +bindle = , XF86MonBrightnessUp, exec, brightnessctl set '12.75+' +bindle = , XF86MonBrightnessDown, exec, brightnessctl set '12.75-' + +# ####################################### Applications ######################################## +# Core applications +bind = $Primary, Return, exec, foot +bind = $Primary, E, exec, nautilus +bind = $Primary, W, exec, firefox +bind = $Primary, C, exec, code + +# Music applications +bind = $Primary$Secondary, M, exec, tidal-hifi +bind = $Primary$Secondary$Tertiary, M, exec, env -u NIXOS_OZONE_WL cider --use-gl=desktop +bind = $Primary$Secondary$Alternate, M, exec, spotify + +# Communication +bind = $Primary$Secondary, O, exec, vesktop + +# Terminals +bind = $Primary$Secondary, H, exec, foot +bind = $Primary$Secondary$Tertiary, T, exec, foot sleep 0.01 && nmtui + +# File managers +bind = $Primary$Secondary, J, exec, thunar +bind = $Primary$Secondary$Tertiary, J, exec, nautilus + +# Browsers +bind = $Primary$Secondary, B, exec, firefox +bind = $Primary$Secondary$Tertiary, B, exec, chromium + +# Editors +bind = $Primary$Secondary, X, exec, subl +bind = $Primary$Secondary, C, exec, code +bind = $Primary$Secondary$Tertiary, C, exec, jetbrains-toolbox + +# Calculator +bind = $Primary$Secondary, 3, exec, ~/.local/bin/wofi-calc +bind = ,XF86Calculator, exec, ~/.local/bin/wofi-calc + +# System settings +bind = $Primary$Secondary, I, exec, XDG_CURRENT_DESKTOP="gnome" gnome-control-center +bind = $Primary$Secondary, V, exec, pavucontrol +bind = $Primary$Tertiary, Home, exec, gnome-system-monitor +bind = $Primary$Alternate, Insert, exec, foot -F btop + +# Actions +bind = $Primary$Secondary, Period, exec, pkill fuzzel || ~/.local/bin/fuzzel-emoji +bind = $Alternate, F4, killactive, +bind = $Secondary$Alternate, Space, togglefloating, +bind = $Secondary$Alternate, Q, exec, hyprctl kill +bind = $Primary$Tertiary$Alternate, Delete, exec, pkill wlogout || wlogout -p layer-shell +bind = $Primary$Tertiary$Alternate$Secondary, Delete, exec, systemctl poweroff + +# Screenshot, Record, OCR, Color picker, Clipboard history +bind = $Secondary$Tertiary, D, exec, ~/.local/bin/rubyshot | wl-copy +bindl = ,Print, exec, grim - | wl-copy +bind = $Secondary$Tertiary, 4, exec, grim -g "$(slurp -d -c D1E5F4BB -b 1B232866 -s 00000000)" - | wl-copy +bind = $Secondary$Tertiary, 5, exec, ~/.config/ags/scripts/record-script.sh +bind = $Secondary$Alternate, 5, exec, ~/.config/ags/scripts/record-script.sh --sound +bind = $Secondary$Tertiary$Alternate, 5, exec, ~/.config/ags/scripts/record-script.sh --fullscreen-sound + +bind = $Secondary$Alternate, C, exec, hyprpicker -a +bind = $Primary$Alternate, Space, exec, cliphist list | wofi -Iim --dmenu | cliphist decode | wl-copy && wtype -M ctrl v -M ctrl +bind = $Secondary$Alternate, V, exec, cliphist list | wofi -Iim --dmenu | cliphist decode | wl-copy && wtype -M ctrl v -M ctrl + +# Text-to-image OCR +bind = $Primary$Secondary$Tertiary, S, exec, grim -g "$(slurp -d -c D1E5F4BB -b 1B232866 -s 00000000)" "tmp.png" && tesseract "tmp.png" - | wl-copy && rm "tmp.png" +bind = $Secondary$Tertiary, T, exec, grim -g "$(slurp -d -c D1E5F4BB -b 1B232866 -s 00000000)" "tmp.png" && tesseract -l eng "tmp.png" - | wl-copy && rm "tmp.png" +bind = $Secondary$Tertiary, J, exec, grim -g "$(slurp -d -c D1E5F4BB -b 1B232866 -s 00000000)" "tmp.png" && tesseract -l jpn "tmp.png" - | wl-copy && rm "tmp.png" + +# Media controls +bind = $Secondary$Tertiary, N, exec, playerctl next || playerctl position `bc <<< "100 * $(playerctl metadata mpris:length) / 1000000 / 100"` +bindl = , XF86AudioNext, exec, playerctl next +bindl = , XF86AudioPrev, exec, playerctl previous +bindl = , XF86AudioPlay, exec, playerctl play-pause +bind = $Secondary$Tertiary, B, exec, playerctl previous +bind = $Secondary$Tertiary, P, exec, playerctl play-pause + +# Lock screen +bind = $Primary$Secondary, L, exec, hyprlock + +# App launcher +bind = $Primary$Secondary, Slash, exec, pkill anyrun || anyrun + +# ##################################### Quickshell keybinds ##################################### +# Quickshell reload and reset +bindr = $Primary$Secondary, R, exec, hyprctl reload; pkill quickshell; pkill activewin.sh; pkill activews.sh; pkill gohypr; pkill bash; pkill ydotool; ~/.local/bin/quickshell-reset.sh; qs & + +# Color generation and theming +bind = $Primary$Secondary, T, exec, /home/celes/sources/celesrenata/end-4-dev/generate-colors.sh + +# Quickshell widgets (adapted from AGS) +bind = $Alternate, Tab, exec, quickshell -c overview +bind = $Secondary, Space, exec, quickshell -c overview +bind = $Secondary$Alternate, Slash, exec, quickshell -c cheatsheet +bind = $Secondary, B, exec, quickshell -c sideleft +bind = $Secondary, N, exec, quickshell -c sideright +bind = $Secondary, K, exec, quickshell -c osk +bind = $Primary$Alternate, Delete, exec, quickshell -c session + +# Quickshell indicators and popups +bindle = , XF86AudioRaiseVolume, exec, quickshell -c indicator-volume +bindle = , XF86AudioLowerVolume, exec, quickshell -c indicator-volume +bindle = , XF86MonBrightnessUp, exec, quickshell -c indicator-brightness +bindle = , XF86MonBrightnessDown, exec, quickshell -c indicator-brightness + +# Close quickshell windows on Super release +bindr = $Primary, $Primary_R, exec, quickshell -c close-all + +# ########################### Keybinds for Hyprland ############################ +# Swap windows +bind = $Secondary$Tertiary, left, movewindow, l +bind = $Secondary$Tertiary, right, movewindow, r +bind = $Secondary$Tertiary, up, movewindow, u +bind = $Secondary$Tertiary, down, movewindow, d + +# Move focus +bind = $Secondary, left, movefocus, l +bind = $Secondary, right, movefocus, r +bind = $Alternate, up, movefocus, u +bind = $Alternate, down, movefocus, d +bind = $Secondary, BracketLeft, movefocus, l +bind = $Secondary, BracketRight, movefocus, r + +# Workspace navigation +bind = $Primary$Secondary, right, workspace, +1 +bind = $Primary$Secondary, left, workspace, -1 +bind = $Primary$Secondary, BracketLeft, workspace, -1 +bind = $Primary$Secondary, BracketRight, workspace, +1 +bind = $Primary$Secondary, up, workspace, -5 +bind = $Primary$Secondary, down, workspace, +5 +bind = $Secondary, Page_Down, workspace, +1 +bind = $Secondary, Page_Up, workspace, -1 + +# Move windows to workspaces +bind = $Secondary$Alternate, Page_Down, movetoworkspace, +1 +bind = $Secondary$Alternate, Page_Up, movetoworkspace, -1 +bind = $Primary$Secondary$Tertiary, Right, movetoworkspace, +1 +bind = $Primary$Secondary$Tertiary, Left, movetoworkspace, -1 + +# Window split ratio +binde = $Primary$Secondary, Minus, splitratio, -0.1 +binde = $Primary$Secondary, Equal, splitratio, 0.1 +binde = $Secondary, Semicolon, splitratio, -0.1 +binde = $Secondary, Apostrophe, splitratio, 0.1 + +# Fullscreen +bind = $Primary$Secondary, F, fullscreen, 0 +bind = $Primary$Secondary, D, fullscreen, 1 +bind = $Secondary$Alternate, F, fullscreenstate, 0 + +# Workspace switching (1-10) +bind = $Secondary, 1, workspace, 1 +bind = $Secondary, 2, workspace, 2 +bind = $Secondary, 3, workspace, 3 +bind = $Secondary, 4, workspace, 4 +bind = $Secondary, 5, workspace, 5 +bind = $Secondary, 6, workspace, 6 +bind = $Secondary, 7, workspace, 7 +bind = $Secondary, 8, workspace, 8 +bind = $Secondary, 9, workspace, 9 +bind = $Secondary, 0, workspace, 10 +bind = $Primary$Secondary, S, togglespecialworkspace, + +# Move window to workspace silently +bind = $Secondary $Alternate, 1, movetoworkspacesilent, 1 +bind = $Secondary $Alternate, 2, movetoworkspacesilent, 2 +bind = $Secondary $Alternate, 3, movetoworkspacesilent, 3 +bind = $Secondary $Alternate, 4, movetoworkspacesilent, 4 +bind = $Secondary $Alternate, 5, movetoworkspacesilent, 5 +bind = $Secondary $Alternate, 6, movetoworkspacesilent, 6 +bind = $Secondary $Alternate, 7, movetoworkspacesilent, 7 +bind = $Secondary $Alternate, 8, movetoworkspacesilent, 8 +bind = $Secondary $Alternate, 9, movetoworkspacesilent, 9 +bind = $Secondary $Alternate, 0, movetoworkspacesilent, 10 +bind = $Secondary$Alternate, S, movetoworkspacesilent, special + +# Mouse workspace switching +bind = $Secondary, mouse_up, workspace, +1 +bind = $Secondary, mouse_down, workspace, -1 +bind = $Primary$Secondary, mouse_up, workspace, +1 +bind = $Primary$Secondary, mouse_down, workspace, -1 + +# Mouse window controls +bindm = $Primary, mouse:273, resizewindow +bindm = $Primary$Secondary, mouse:273, resizewindow +bindm = ,mouse:274, movewindow +bindm = $Secondary, mouse:273, movewindow +bind = $Primary$Secondary, Backslash, resizeactive, exact 640 480 + +# Alt+Tab window switching +bind = $Alternate, Tab, cyclenext +bind = $Alternate, Tab, bringactivetotop + +# Testing and debugging +bind = $Secondary$Alternate, f12, exec, notify-send "Millis since epoch" "$(date +%s%N | cut -b1-13)" -a 'Hyprland keybind' +bind = $Secondary$Alternate, Equal, exec, notify-send "Urgent notification" "Ah hell no" -u critical -a 'Hyprland keybind' diff --git a/configs/matugen/templates/colors.json b/configs/matugen/templates/colors.json new file mode 100644 index 0000000..d06ab6b --- /dev/null +++ b/configs/matugen/templates/colors.json @@ -0,0 +1,50 @@ +{ + "background": "{{colors.background.default.hex}}", + "error": "{{colors.error.default.hex}}", + "error_container": "{{colors.error_container.default.hex}}", + "inverse_on_surface": "{{colors.inverse_on_surface.default.hex}}", + "inverse_primary": "{{colors.inverse_primary.default.hex}}", + "inverse_surface": "{{colors.inverse_surface.default.hex}}", + "on_background": "{{colors.on_background.default.hex}}", + "on_error": "{{colors.on_error.default.hex}}", + "on_error_container": "{{colors.on_error_container.default.hex}}", + "on_primary": "{{colors.on_primary.default.hex}}", + "on_primary_container": "{{colors.on_primary_container.default.hex}}", + "on_primary_fixed": "{{colors.on_primary_fixed.default.hex}}", + "on_primary_fixed_variant": "{{colors.on_primary_fixed_variant.default.hex}}", + "on_secondary": "{{colors.on_secondary.default.hex}}", + "on_secondary_container": "{{colors.on_secondary_container.default.hex}}", + "on_secondary_fixed": "{{colors.on_secondary_fixed.default.hex}}", + "on_secondary_fixed_variant": "{{colors.on_secondary_fixed_variant.default.hex}}", + "on_surface": "{{colors.on_surface.default.hex}}", + "on_surface_variant": "{{colors.on_surface_variant.default.hex}}", + "on_tertiary": "{{colors.on_tertiary.default.hex}}", + "on_tertiary_container": "{{colors.on_tertiary_container.default.hex}}", + "on_tertiary_fixed": "{{colors.on_tertiary_fixed.default.hex}}", + "on_tertiary_fixed_variant": "{{colors.on_tertiary_fixed_variant.default.hex}}", + "outline": "{{colors.outline.default.hex}}", + "outline_variant": "{{colors.outline_variant.default.hex}}", + "primary": "{{colors.primary.default.hex}}", + "primary_container": "{{colors.primary_container.default.hex}}", + "primary_fixed": "{{colors.primary_fixed.default.hex}}", + "primary_fixed_dim": "{{colors.primary_fixed_dim.default.hex}}", + "scrim": "{{colors.scrim.default.hex}}", + "secondary": "{{colors.secondary.default.hex}}", + "secondary_container": "{{colors.secondary_container.default.hex}}", + "secondary_fixed": "{{colors.secondary_fixed.default.hex}}", + "secondary_fixed_dim": "{{colors.secondary_fixed_dim.default.hex}}", + "shadow": "{{colors.shadow.default.hex}}", + "surface": "{{colors.surface.default.hex}}", + "surface_bright": "{{colors.surface_bright.default.hex}}", + "surface_container": "{{colors.surface_container.default.hex}}", + "surface_container_high": "{{colors.surface_container_high.default.hex}}", + "surface_container_highest": "{{colors.surface_container_highest.default.hex}}", + "surface_container_low": "{{colors.surface_container_low.default.hex}}", + "surface_container_lowest": "{{colors.surface_container_lowest.default.hex}}", + "surface_dim": "{{colors.surface_dim.default.hex}}", + "surface_variant": "{{colors.surface_variant.default.hex}}", + "tertiary": "{{colors.tertiary.default.hex}}", + "tertiary_container": "{{colors.tertiary_container.default.hex}}", + "tertiary_fixed": "{{colors.tertiary_fixed.default.hex}}", + "tertiary_fixed_dim": "{{colors.tertiary_fixed_dim.default.hex}}" +} diff --git a/configs/matugen/templates/foot/foot.ini b/configs/matugen/templates/foot/foot.ini new file mode 100644 index 0000000..86eb9ef --- /dev/null +++ b/configs/matugen/templates/foot/foot.ini @@ -0,0 +1,63 @@ +# Material You themed foot terminal configuration + +[main] +shell=fish +term=xterm-256color +title=foot +font=JetBrainsMono Nerd Font:size=11 +letter-spacing=0 +dpi-aware=no +pad=25x25 +bold-text-in-bright=no + +[scrollback] +lines=10000 + +[cursor] +style=beam +blink=no +beam-thickness=1.5 + +[key-bindings] +scrollback-up-page=Page_Up +scrollback-down-page=Page_Down +clipboard-copy=Control+c +clipboard-paste=Control+v +search-start=Control+f +font-increase=Control+plus Control+equal Control+KP_Add +font-decrease=Control+minus Control+KP_Subtract +font-reset=Control+0 Control+KP_0 + +[search-bindings] +cancel=Escape +find-prev=Shift+F3 +find-next=F3 Control+G +delete-prev-word=Control+BackSpace + +[text-bindings] +\x03=Control+Shift+c + +[colors] +cursor={{colors.primary.default.hex}} {{colors.on_primary.default.hex}} +alpha=0.95 +background={{colors.background.default.hex}} +foreground={{colors.on_background.default.hex}} + +# Material You color palette +regular0={{colors.surface_dim.default.hex}} +regular1={{colors.error.default.hex}} +regular2={{colors.secondary.default.hex}} +regular3={{colors.tertiary.default.hex}} +regular4={{colors.primary.default.hex}} +regular5={{colors.error_container.default.hex}} +regular6={{colors.primary_container.default.hex}} +regular7={{colors.on_surface.default.hex}} + +bright0={{colors.surface_variant.default.hex}} +bright1={{colors.error.default.hex}} +bright2={{colors.secondary.default.hex}} +bright3={{colors.tertiary.default.hex}} +bright4={{colors.primary.default.hex}} +bright5={{colors.error_container.default.hex}} +bright6={{colors.primary_container.default.hex}} +bright7={{colors.on_background.default.hex}} diff --git a/configs/matugen/templates/fuzzel/fuzzel_theme.ini b/configs/matugen/templates/fuzzel/fuzzel_theme.ini new file mode 100644 index 0000000..d508977 --- /dev/null +++ b/configs/matugen/templates/fuzzel/fuzzel_theme.ini @@ -0,0 +1,9 @@ +# Material You theme for Fuzzel launcher +[colors] +background={{colors.background.default.hex_stripped}}ff +text={{colors.on_background.default.hex_stripped}}ff +selection={{colors.surface_variant.default.hex_stripped}}ff +selection-text={{colors.on_surface_variant.default.hex_stripped}}ff +border={{colors.surface_variant.default.hex_stripped}}dd +match={{colors.primary.default.hex_stripped}}ff +selection-match={{colors.primary.default.hex_stripped}}ff diff --git a/configs/matugen/templates/gtk/gtk-colors.css b/configs/matugen/templates/gtk/gtk-colors.css new file mode 100644 index 0000000..fb9bc4d --- /dev/null +++ b/configs/matugen/templates/gtk/gtk-colors.css @@ -0,0 +1,42 @@ +/* +* GTK Colors - Material You Theme +* Generated with Matugen +*/ + +@define-color accent_color {{colors.primary.default.hex}}; +@define-color accent_fg_color {{colors.on_primary.default.hex}}; +@define-color accent_bg_color {{colors.primary.default.hex}}; +@define-color destructive_color {{colors.error.default.hex}}; +@define-color destructive_bg_color {{colors.error.default.hex}}; +@define-color destructive_fg_color {{colors.on_error.default.hex}}; +@define-color success_color {{colors.secondary.default.hex}}; +@define-color success_bg_color {{colors.secondary.default.hex}}; +@define-color success_fg_color {{colors.on_secondary.default.hex}}; +@define-color warning_color {{colors.tertiary.default.hex}}; +@define-color warning_bg_color {{colors.tertiary.default.hex}}; +@define-color warning_fg_color {{colors.on_tertiary.default.hex}}; +@define-color error_color {{colors.error.default.hex}}; +@define-color error_bg_color {{colors.error.default.hex}}; +@define-color error_fg_color {{colors.on_error.default.hex}}; +@define-color window_bg_color {{colors.background.default.hex}}; +@define-color window_fg_color {{colors.on_background.default.hex}}; +@define-color view_bg_color {{colors.surface.default.hex}}; +@define-color view_fg_color {{colors.on_surface.default.hex}}; +@define-color headerbar_bg_color {{colors.surface_dim.default.hex}}; +@define-color headerbar_fg_color {{colors.on_surface.default.hex}}; +@define-color headerbar_border_color {{colors.outline_variant.default.hex}}; +@define-color headerbar_backdrop_color {{colors.surface_dim.default.hex}}; +@define-color headerbar_shade_color {{colors.shadow.default.hex}}; +@define-color card_bg_color {{colors.surface.default.hex}}; +@define-color card_fg_color {{colors.on_surface.default.hex}}; +@define-color card_shade_color {{colors.shadow.default.hex}}; +@define-color dialog_bg_color {{colors.surface.default.hex}}; +@define-color dialog_fg_color {{colors.on_surface.default.hex}}; +@define-color popover_bg_color {{colors.surface_dim.default.hex}}; +@define-color popover_fg_color {{colors.on_surface.default.hex}}; +@define-color shade_color {{colors.shadow.default.hex}}; +@define-color scrollbar_outline_color {{colors.outline_variant.default.hex}}; +@define-color sidebar_bg_color @window_bg_color; +@define-color sidebar_fg_color @window_fg_color; +@define-color sidebar_border_color @window_bg_color; +@define-color sidebar_backdrop_color @window_bg_color; diff --git a/configs/matugen/templates/hyprland/colors.conf b/configs/matugen/templates/hyprland/colors.conf new file mode 100644 index 0000000..105ab22 --- /dev/null +++ b/configs/matugen/templates/hyprland/colors.conf @@ -0,0 +1,37 @@ +# Material You colors for Hyprland +# Generated dynamically from wallpaper + +# Slurp (selection tool) colors +exec = export SLURP_ARGS='-d -c {{colors.primary.default.hex_stripped}}BB -b {{colors.surface_variant.default.hex_stripped}}44 -s 00000000' + +general { + col.active_border = rgba({{colors.outline.default.hex_stripped}}AA) + col.inactive_border = rgba({{colors.outline_variant.default.hex_stripped}}AA) +} + +misc { + background_color = rgba({{colors.surface.dark.hex_stripped}}FF) +} + +plugin { + hyprbars { + # Font configuration + bar_text_font = Rubik, Geist, AR One Sans, Reddit Sans, Inter, Roboto, Ubuntu, Noto Sans, sans-serif + bar_height = 30 + bar_padding = 10 + bar_button_padding = 5 + bar_precedence_over_border = true + bar_part_of_window = true + + bar_color = rgba({{colors.background.default.hex_stripped}}FF) + col.text = rgba({{colors.on_background.default.hex_stripped}}FF) + + # Window control buttons (R -> L) + hyprbars-button = rgb({{colors.on_background.default.hex_stripped}}), 13, ๓ฐ–ญ, hyprctl dispatch killactive + hyprbars-button = rgb({{colors.on_background.default.hex_stripped}}), 13, ๓ฐ–ฏ, hyprctl dispatch fullscreen 1 + hyprbars-button = rgb({{colors.on_background.default.hex_stripped}}), 13, ๓ฐ–ฐ, hyprctl dispatch movetoworkspacesilent special + } +} + +# Special window border colors +windowrulev2 = bordercolor rgba({{colors.primary.default.hex_stripped}}AA) rgba({{colors.primary.default.hex_stripped}}77),pinned:1 diff --git a/configs/matugen/templates/hyprland/hyprlock.conf b/configs/matugen/templates/hyprland/hyprlock.conf new file mode 100644 index 0000000..0d4d304 --- /dev/null +++ b/configs/matugen/templates/hyprland/hyprlock.conf @@ -0,0 +1,71 @@ +# Material You themed Hyprlock configuration + +$text_color = rgba({{colors.on_background.default.hex_stripped}}FF) +$entry_background_color = rgba({{colors.surface_variant.default.hex_stripped}}11) +$entry_border_color = rgba({{colors.outline.default.hex_stripped}}55) +$entry_color = rgba({{colors.on_surface_variant.default.hex_stripped}}FF) +$font_family = Rubik Light +$font_family_clock = Rubik Light + +background { + color = rgba({{colors.background.default.hex_stripped}}FF) + path = screenshot + blur_passes = 3 + blur_size = 8 +} + +input-field { + monitor = + size = 250, 50 + outline_thickness = 2 + dots_size = 0.1 + dots_spacing = 0.3 + outer_color = $entry_border_color + inner_color = $entry_background_color + font_color = $entry_color + fade_on_empty = true + placeholder_text = Password... + check_color = rgba({{colors.primary.default.hex_stripped}}FF) + fail_color = rgba({{colors.error.default.hex_stripped}}FF) + fail_text = $FAIL ($ATTEMPTS) + + position = 0, 20 + halign = center + valign = center +} + +# Time +label { + monitor = + text = cmd[update:30000] echo "$(date +"%R")" + color = $text_color + font_size = 90 + font_family = $font_family_clock + position = -30, 0 + halign = right + valign = top +} + +# Date +label { + monitor = + text = cmd[update:43200000] echo "$(date +"%A, %d %B %Y")" + color = $text_color + font_size = 25 + font_family = $font_family + position = -30, -150 + halign = right + valign = top +} + +# User +label { + monitor = + text = Hi there, $USER + color = $text_color + font_size = 20 + font_family = $font_family + position = 0, 100 + halign = center + valign = center +} diff --git a/configs/matugen/templates/kde/color.txt b/configs/matugen/templates/kde/color.txt new file mode 100644 index 0000000..84e2e23 --- /dev/null +++ b/configs/matugen/templates/kde/color.txt @@ -0,0 +1 @@ +{{colors.primary.default.hex}} diff --git a/configs/matugen/templates/kitty/kitty.conf b/configs/matugen/templates/kitty/kitty.conf new file mode 100644 index 0000000..c7f46fa --- /dev/null +++ b/configs/matugen/templates/kitty/kitty.conf @@ -0,0 +1,249 @@ +# kitty terminal configuration with Material You theming +# Generated by matugen from wallpaper colors + +# Font configuration +font_family JetBrainsMono Nerd Font +bold_font JetBrainsMono Nerd Font Bold +italic_font JetBrainsMono Nerd Font Italic +bold_italic_font JetBrainsMono Nerd Font Bold Italic +font_size 12.0 + +# Cursor +cursor_shape block +cursor_blink_interval 0.5 +cursor_stop_blinking_after 15.0 + +# Scrollback +scrollback_lines 10000 +scrollback_pager less --chop-long-lines --RAW-CONTROL-CHARS +INPUT_LINE_NUMBER + +# Mouse +mouse_hide_wait 3.0 +url_color {{colors.primary.default.hex}} +url_style curly +open_url_with default +copy_on_select yes +strip_trailing_spaces never + +# Performance +repaint_delay 10 +input_delay 3 +sync_to_monitor yes + +# Window layout +remember_window_size yes +initial_window_width 100c +initial_window_height 30c +enabled_layouts * +window_resize_step_cells 2 +window_resize_step_lines 2 +window_border_width 1pt +draw_minimal_borders yes +window_margin_width 0 +single_window_margin_width -1 +window_padding_width 8 +placement_strategy center +active_border_color {{colors.primary.default.hex}} +inactive_border_color {{colors.outline.default.hex}} +bell_border_color {{colors.error.default.hex}} + +# Tab bar +tab_bar_edge bottom +tab_bar_margin_width 0.0 +tab_bar_margin_height 0.0 0.0 +tab_bar_style powerline +tab_bar_align left +tab_bar_min_tabs 2 +tab_switch_strategy previous +tab_fade 0.25 0.5 0.75 1 +tab_separator " โ”‡" +tab_powerline_style angled +tab_activity_symbol none +tab_title_template "{title}{' :{}:'.format(num_windows) if num_windows > 1 else ''}" +active_tab_title_template none + +# Tab bar colors +active_tab_foreground {{colors.primary.default.hex}} +active_tab_background {{colors.surface.default.hex}} +active_tab_font_style bold-italic +inactive_tab_foreground {{colors.on_surface_variant.default.hex}} +inactive_tab_background {{colors.surface_variant.default.hex}} +inactive_tab_font_style normal +tab_bar_background {{colors.surface_container.default.hex}} + +# Material You Color Scheme +foreground {{colors.on_surface.default.hex}} +background {{colors.surface.default.hex}} +selection_foreground {{colors.on_primary.default.hex}} +selection_background {{colors.primary.default.hex}} + +# Cursor colors +cursor {{colors.primary.default.hex}} +cursor_text_color {{colors.on_primary.default.hex}} + +# URL underline color when hovering with mouse +url_color {{colors.primary.default.hex}} + +# kitty window border colors +active_border_color {{colors.primary.default.hex}} +inactive_border_color {{colors.outline.default.hex}} +bell_border_color {{colors.error.default.hex}} + +# OS Window titlebar colors +wayland_titlebar_color {{colors.surface_container.default.hex}} +macos_titlebar_color {{colors.surface_container.default.hex}} + +# Tab bar colors +active_tab_foreground {{colors.on_primary_container.default.hex}} +active_tab_background {{colors.primary_container.default.hex}} +inactive_tab_foreground {{colors.on_surface_variant.default.hex}} +inactive_tab_background {{colors.surface_variant.default.hex}} +tab_bar_background {{colors.surface.default.hex}} + +# Colors for marks (marked text in the terminal) +mark1_foreground {{colors.surface.default.hex}} +mark1_background {{colors.primary.default.hex}} +mark2_foreground {{colors.surface.default.hex}} +mark2_background {{colors.secondary.default.hex}} +mark3_foreground {{colors.surface.default.hex}} +mark3_background {{colors.tertiary.default.hex}} + +# The 16 terminal colors + +# normal colors +color0 {{colors.surface.default.hex}} +color1 {{colors.error.default.hex}} +color2 {{colors.primary.default.hex}} +color3 {{colors.tertiary.default.hex}} +color4 {{colors.secondary.default.hex}} +color5 {{colors.primary.default.hex}} +color6 {{colors.secondary.default.hex}} +color7 {{colors.on_surface.default.hex}} + +# bright colors +color8 {{colors.outline.default.hex}} +color9 {{colors.error.default.hex}} +color10 {{colors.primary.default.hex}} +color11 {{colors.tertiary.default.hex}} +color12 {{colors.secondary.default.hex}} +color13 {{colors.primary.default.hex}} +color14 {{colors.secondary.default.hex}} +color15 {{colors.on_surface.default.hex}} + +# Extended colors for better Material You integration +color16 {{colors.primary_container.default.hex}} +color17 {{colors.secondary_container.default.hex}} +color18 {{colors.tertiary_container.default.hex}} +color19 {{colors.error_container.default.hex}} +color20 {{colors.surface_variant.default.hex}} +color21 {{colors.outline_variant.default.hex}} + +# Advanced features +allow_remote_control yes +listen_on unix:/tmp/kitty +shell_integration enabled +term xterm-kitty + +# Keyboard shortcuts +kitty_mod ctrl+shift + +# Clipboard +map kitty_mod+c copy_to_clipboard +map kitty_mod+v paste_from_clipboard +map kitty_mod+s paste_from_selection +map shift+insert paste_from_selection +map kitty_mod+o pass_selection_to_program + +# Scrolling +map kitty_mod+up scroll_line_up +map kitty_mod+k scroll_line_up +map kitty_mod+down scroll_line_down +map kitty_mod+j scroll_line_down +map kitty_mod+page_up scroll_page_up +map kitty_mod+page_down scroll_page_down +map kitty_mod+home scroll_home +map kitty_mod+end scroll_end +map kitty_mod+h show_scrollback + +# Window management +map kitty_mod+enter new_window +map kitty_mod+n new_os_window +map kitty_mod+w close_window +map kitty_mod+] next_window +map kitty_mod+[ previous_window +map kitty_mod+f move_window_forward +map kitty_mod+b move_window_backward +map kitty_mod+` move_window_to_top +map kitty_mod+r start_resizing_window +map kitty_mod+1 first_window +map kitty_mod+2 second_window +map kitty_mod+3 third_window +map kitty_mod+4 fourth_window +map kitty_mod+5 fifth_window +map kitty_mod+6 sixth_window +map kitty_mod+7 seventh_window +map kitty_mod+8 eighth_window +map kitty_mod+9 ninth_window +map kitty_mod+0 tenth_window + +# Tab management +map kitty_mod+right next_tab +map kitty_mod+left previous_tab +map kitty_mod+t new_tab +map kitty_mod+q close_tab +map kitty_mod+. move_tab_forward +map kitty_mod+, move_tab_backward +map kitty_mod+alt+t set_tab_title + +# Layout management +map kitty_mod+l next_layout + +# Font sizes +map kitty_mod+equal change_font_size all +2.0 +map kitty_mod+plus change_font_size all +2.0 +map kitty_mod+kp_add change_font_size all +2.0 +map kitty_mod+minus change_font_size all -2.0 +map kitty_mod+kp_subtract change_font_size all -2.0 +map kitty_mod+backspace change_font_size all 0 + +# Select and act on visible text +map kitty_mod+e kitten hints +map kitty_mod+p>f kitten hints --type path --program - +map kitty_mod+p>shift+f kitten hints --type path +map kitty_mod+p>l kitten hints --type line --program - +map kitty_mod+p>w kitten hints --type word --program - +map kitty_mod+p>h kitten hints --type hash --program - +map kitty_mod+p>n kitten hints --type linenum + +# Miscellaneous +map kitty_mod+f11 toggle_fullscreen +map kitty_mod+f10 toggle_maximized +map kitty_mod+u kitten unicode_input +map kitty_mod+f2 edit_config_file +map kitty_mod+escape kitty_shell window + +# Sending arbitrary text on key presses +map kitty_mod+alt+1 send_text all \x01 +map kitty_mod+alt+2 send_text all \x02 +map kitty_mod+alt+3 send_text all \x03 + +# Symbol mapping for better rendering +symbol_map U+E0A0-U+E0A3,U+E0C0-U+E0C7 PowerlineSymbols +symbol_map U+E000-U+F8FF,U+F0000-U+FFFFD,U+100000-U+10FFFD Symbols Nerd Font Mono + +# Performance tuning +sync_to_monitor yes +enable_audio_bell no +visual_bell_duration 0.0 +window_alert_on_bell no +bell_on_tab no +command_on_bell none + +# Advanced +allow_hyperlinks yes +shell_integration enabled +update_check_interval 0 +startup_session none +clipboard_control write-clipboard write-primary +allow_cloning ask +clone_source_strategies venv,conda,env_var,path diff --git a/configs/matugen/templates/quickshell/colors.qml b/configs/matugen/templates/quickshell/colors.qml new file mode 100644 index 0000000..2df1338 --- /dev/null +++ b/configs/matugen/templates/quickshell/colors.qml @@ -0,0 +1,61 @@ +// Material You colors for Quickshell +// Generated dynamically from wallpaper + +pragma Singleton +import QtQuick + +QtObject { + // Primary colors + readonly property color primary: "{{colors.primary.default.hex}}" + readonly property color onPrimary: "{{colors.on_primary.default.hex}}" + readonly property color primaryContainer: "{{colors.primary_container.default.hex}}" + readonly property color onPrimaryContainer: "{{colors.on_primary_container.default.hex}}" + + // Secondary colors + readonly property color secondary: "{{colors.secondary.default.hex}}" + readonly property color onSecondary: "{{colors.on_secondary.default.hex}}" + readonly property color secondaryContainer: "{{colors.secondary_container.default.hex}}" + readonly property color onSecondaryContainer: "{{colors.on_secondary_container.default.hex}}" + + // Tertiary colors + readonly property color tertiary: "{{colors.tertiary.default.hex}}" + readonly property color onTertiary: "{{colors.on_tertiary.default.hex}}" + readonly property color tertiaryContainer: "{{colors.tertiary_container.default.hex}}" + readonly property color onTertiaryContainer: "{{colors.on_tertiary_container.default.hex}}" + + // Error colors + readonly property color error: "{{colors.error.default.hex}}" + readonly property color onError: "{{colors.on_error.default.hex}}" + readonly property color errorContainer: "{{colors.error_container.default.hex}}" + readonly property color onErrorContainer: "{{colors.on_error_container.default.hex}}" + + // Surface colors + readonly property color surface: "{{colors.surface.default.hex}}" + readonly property color onSurface: "{{colors.on_surface.default.hex}}" + readonly property color surfaceVariant: "{{colors.surface_variant.default.hex}}" + readonly property color onSurfaceVariant: "{{colors.on_surface_variant.default.hex}}" + readonly property color surfaceDim: "{{colors.surface_dim.default.hex}}" + readonly property color surfaceBright: "{{colors.surface_bright.default.hex}}" + readonly property color surfaceContainer: "{{colors.surface_container.default.hex}}" + readonly property color surfaceContainerHigh: "{{colors.surface_container_high.default.hex}}" + readonly property color surfaceContainerHighest: "{{colors.surface_container_highest.default.hex}}" + readonly property color surfaceContainerLow: "{{colors.surface_container_low.default.hex}}" + readonly property color surfaceContainerLowest: "{{colors.surface_container_lowest.default.hex}}" + + // Background colors + readonly property color background: "{{colors.background.default.hex}}" + readonly property color onBackground: "{{colors.on_background.default.hex}}" + + // Outline colors + readonly property color outline: "{{colors.outline.default.hex}}" + readonly property color outlineVariant: "{{colors.outline_variant.default.hex}}" + + // Inverse colors + readonly property color inverseSurface: "{{colors.inverse_surface.default.hex}}" + readonly property color inverseOnSurface: "{{colors.inverse_on_surface.default.hex}}" + readonly property color inversePrimary: "{{colors.inverse_primary.default.hex}}" + + // Other colors + readonly property color shadow: "{{colors.shadow.default.hex}}" + readonly property color scrim: "{{colors.scrim.default.hex}}" +} diff --git a/configs/matugen/templates/wallpaper.txt b/configs/matugen/templates/wallpaper.txt new file mode 100644 index 0000000..33b0c5b --- /dev/null +++ b/configs/matugen/templates/wallpaper.txt @@ -0,0 +1 @@ +{{image}} diff --git a/configs/matugen/templates/wlogout/layout b/configs/matugen/templates/wlogout/layout new file mode 100644 index 0000000..de9a5d2 --- /dev/null +++ b/configs/matugen/templates/wlogout/layout @@ -0,0 +1,36 @@ +{ + "label" : "lock", + "action" : "hyprlock", + "text" : "Lock", + "keybind" : "l" +} +{ + "label" : "hibernate", + "action" : "systemctl hibernate", + "text" : "Hibernate", + "keybind" : "h" +} +{ + "label" : "logout", + "action" : "hyprctl dispatch exit", + "text" : "Logout", + "keybind" : "e" +} +{ + "label" : "shutdown", + "action" : "systemctl poweroff", + "text" : "Shutdown", + "keybind" : "s" +} +{ + "label" : "suspend", + "action" : "systemctl suspend", + "text" : "Suspend", + "keybind" : "u" +} +{ + "label" : "reboot", + "action" : "systemctl reboot", + "text" : "Reboot", + "keybind" : "r" +} diff --git a/configs/matugen/templates/wlogout/style.css b/configs/matugen/templates/wlogout/style.css new file mode 100644 index 0000000..8e97c28 --- /dev/null +++ b/configs/matugen/templates/wlogout/style.css @@ -0,0 +1,55 @@ +/* wlogout style with Material You theming */ + +* { + background-image: none; + box-shadow: none; +} + +window { + background-color: rgba(12, 12, 12, 0.9); +} + +button { + color: {{colors.on_surface.default.hex}}; + background-color: {{colors.surface_container.default.hex}}; + border-style: solid; + border-width: 2px; + background-repeat: no-repeat; + background-position: center; + background-size: 25%; + border-radius: 18px; + margin: 5px; + transition: all 0.3s ease-in-out; +} + +button:focus, button:active, button:hover { + background-color: {{colors.primary_container.default.hex}}; + color: {{colors.on_primary_container.default.hex}}; + border-color: {{colors.primary.default.hex}}; + outline-style: none; + transform: scale(1.05); +} + +#lock { + background-image: image(url("icons/lock.png"), url("/usr/share/wlogout/icons/lock.png")); +} + +#logout { + background-image: image(url("icons/logout.png"), url("/usr/share/wlogout/icons/logout.png")); +} + +#suspend { + background-image: image(url("icons/suspend.png"), url("/usr/share/wlogout/icons/suspend.png")); +} + +#hibernate { + background-image: image(url("icons/hibernate.png"), url("/usr/share/wlogout/icons/hibernate.png")); +} + +#shutdown { + background-image: image(url("icons/shutdown.png"), url("/usr/share/wlogout/icons/shutdown.png")); +} + +#reboot { + background-image: image(url("icons/reboot.png"), url("/usr/share/wlogout/icons/reboot.png")); +} diff --git a/configs/quickshell-patches/AppLauncherPatch.qml b/configs/quickshell-patches/AppLauncherPatch.qml new file mode 100644 index 0000000..59bc4cf --- /dev/null +++ b/configs/quickshell-patches/AppLauncherPatch.qml @@ -0,0 +1,67 @@ +pragma Singleton + +import Quickshell + +/** + * Application launcher patch for NixOS integration + * Ensures applications are launched with proper PATH environment + */ +Singleton { + id: root + + // Get the launcher wrapper path from environment + readonly property string launcherWrapper: Quickshell.env("DOTS_HYPRLAND_APP_LAUNCHER") || "" + + /** + * Launch an application using the NixOS-compatible launcher wrapper + * @param command - The command to execute (can be string or array) + */ + function launchApp(command) { + if (launcherWrapper === "") { + console.warn("AppLauncherPatch: No launcher wrapper found, falling back to direct execution"); + if (Array.isArray(command)) { + Quickshell.execDetached(command); + } else { + Quickshell.execDetached(["bash", "-c", command]); + } + return; + } + + console.log("AppLauncherPatch: Launching app with wrapper:", command); + + if (Array.isArray(command)) { + // If command is an array, prepend the launcher wrapper + Quickshell.execDetached([launcherWrapper].concat(command)); + } else { + // If command is a string, use bash to execute it through the wrapper + Quickshell.execDetached([launcherWrapper, "bash", "-c", command]); + } + } + + /** + * Launch a desktop entry using the launcher wrapper + * @param desktopEntry - The DesktopEntry object + */ + function launchDesktopEntry(desktopEntry) { + if (!desktopEntry) { + console.warn("AppLauncherPatch: No desktop entry provided"); + return; + } + + console.log("AppLauncherPatch: Launching desktop entry:", desktopEntry.name); + + // Try to use the desktop entry's execute method first + try { + desktopEntry.execute(); + } catch (error) { + console.warn("AppLauncherPatch: Desktop entry execute failed, trying wrapper approach:", error); + + // Fallback: extract the Exec command and use our wrapper + if (desktopEntry.exec) { + launchApp(desktopEntry.exec); + } else { + console.error("AppLauncherPatch: No exec command found in desktop entry"); + } + } + } +} diff --git a/configs/quickshell/GlobalStates.qml b/configs/quickshell/GlobalStates.qml new file mode 100644 index 0000000..b3a124e --- /dev/null +++ b/configs/quickshell/GlobalStates.qml @@ -0,0 +1,73 @@ +import qs.modules.common +import qs +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property bool barOpen: true + 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 overviewOpen: false + property bool sessionOpen: false + property bool workspaceShowNumbers: false + property bool superReleaseMightTrigger: true + property bool screenLocked: false + property bool screenLockContainsCharacters: false + + 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) + } + + // When user is not reluctant while pressing super, they probably don't need to see workspace numbers + onSuperReleaseMightTriggerChanged: { + workspaceShowNumbersTimer.stop() + } + + Timer { + id: workspaceShowNumbersTimer + interval: Config.options.bar.workspaces.showNumberDelay + // interval: 0 + repeat: false + onTriggered: { + workspaceShowNumbers = true + } + } + + GlobalShortcut { + name: "workspaceNumber" + description: "Hold to show workspace numbers, release to show icons" + + onPressed: { + workspaceShowNumbersTimer.start() + } + onReleased: { + workspaceShowNumbersTimer.stop() + workspaceShowNumbers = 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/configs/quickshell/ReloadPopup.qml b/configs/quickshell/ReloadPopup.qml new file mode 100644 index 0000000..4b0ecd8 --- /dev/null +++ b/configs/quickshell/ReloadPopup.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Qt5Compat.GraphicalEffects + +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 + + // 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 + onClicked: { + 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: "Rubik" + 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/configs/quickshell/Translation.qml b/configs/quickshell/Translation.qml new file mode 100644 index 0000000..3cfdce1 --- /dev/null +++ b/configs/quickshell/Translation.qml @@ -0,0 +1,175 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common + +Singleton { + id: root + + property var translations: ({}) + property string currentLanguage: "en_US" + property var availableLanguages: ["en_US"] + property bool isScanning: false + property bool isLoading: false + + Process { + id: scanLanguagesProcess + command: ["find", Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString().replace("file://", ""), "-name", "*.json", "-exec", "basename", "{}", ".json", ";"] + running: false + + stdout: SplitParser { + onRead: data => { + if (data.trim().length === 0) return + + var files = data.trim().split('\n') + + for (var i = 0; i < files.length; i++) { + var lang = files[i].trim() + if (lang.length > 0 && root.availableLanguages.indexOf(lang) === -1) { + root.availableLanguages.push(lang) + } + } + } + } + + onExited: (exitCode, exitStatus) => { + root.isScanning = false + if (exitCode !== 0) { + root.availableLanguages = ["en_US"] + } + root.loadTranslations() + } + } + + FileView { + id: translationFileView + onLoaded: { + var textContent = "" + try { + textContent = text() + } catch (e) { + root.translations = {} + root.isLoading = false + return + } + + if (textContent.length === 0) { + root.translations = {} + root.isLoading = false + return + } + + try { + var jsonData = JSON.parse(textContent) + root.translations = jsonData + root.isLoading = false + } catch (e) { + root.translations = {} + root.isLoading = false + } + } + onLoadFailed: (error) => { + root.translations = {} + root.isLoading = false + } + } + + function detectSystemLanguage() { + var locale = Qt.locale().name + return locale + } + + function getLanguageCode() { + var configLang = "auto" + try { + configLang = ConfigOptions.language.ui + } catch (e) { + configLang = "auto" + } + + if (configLang === "auto") { + return detectSystemLanguage() + } else { + if (root.availableLanguages.indexOf(configLang) !== -1) { + return configLang + } else { + return detectSystemLanguage() + } + } + } + + function loadTranslations() { + if (root.isScanning) { + return + } + + var targetLang = getLanguageCode() + root.currentLanguage = targetLang + + // Use empty translations for English (default language) + if (targetLang === "en_US" || targetLang === "en") { + root.translations = {} + return + } + + // Check if target language is available + if (root.availableLanguages.indexOf(targetLang) === -1) { + root.currentLanguage = "en_US" + root.translations = {} + return + } + + // Load translation file + root.isLoading = true + var translationsPath = Qt.resolvedUrl(Directories.config + "/quickshell/translations/" + targetLang + ".json") + translationFileView.path = translationsPath + } + + function tr(text) { + if (!text) { + return "" + } + + var key = text.toString() + + if (root.isLoading) { + return key + } + + if (root.currentLanguage === "en_US" || root.currentLanguage === "en" || !root.translations) { + return key + } + + if (root.translations.hasOwnProperty(key)) { + var translation = root.translations[key] + if (translation && translation.toString().trim().length > 0) { + var str = translation.toString().trim() + if (str.endsWith("/*keep*/")) { + return str.substring(0, str.length - 8).trim() + } else { + return str + } + } else { + return translation.toString() + } + } + + return key // Fallback to key name + } + + function reloadTranslations() { + root.scanLanguages() + } + + function scanLanguages() { + var translationsDir = Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString().replace("file://", "") + root.isScanning = true + scanLanguagesProcess.running = true + } + + Component.onCompleted: { + root.scanLanguages() + } +} diff --git a/configs/quickshell/assets/icons/ai-openai-symbolic.svg b/configs/quickshell/assets/icons/ai-openai-symbolic.svg new file mode 100644 index 0000000..8ffc912 --- /dev/null +++ b/configs/quickshell/assets/icons/ai-openai-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/configs/quickshell/assets/icons/arch-symbolic.svg b/configs/quickshell/assets/icons/arch-symbolic.svg new file mode 100644 index 0000000..7de9094 --- /dev/null +++ b/configs/quickshell/assets/icons/arch-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/cachyos-symbolic.svg b/configs/quickshell/assets/icons/cachyos-symbolic.svg new file mode 100644 index 0000000..4a9db19 --- /dev/null +++ b/configs/quickshell/assets/icons/cachyos-symbolic.svg @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/cloudflare-dns-symbolic.svg b/configs/quickshell/assets/icons/cloudflare-dns-symbolic.svg new file mode 100644 index 0000000..bd48d3c --- /dev/null +++ b/configs/quickshell/assets/icons/cloudflare-dns-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/crosshair-symbolic.svg b/configs/quickshell/assets/icons/crosshair-symbolic.svg new file mode 100644 index 0000000..2296749 --- /dev/null +++ b/configs/quickshell/assets/icons/crosshair-symbolic.svg @@ -0,0 +1,65 @@ + + + + + + + ionicons-v5_logos + + + + ionicons-v5_logos + + + + + + diff --git a/configs/quickshell/assets/icons/debian-symbolic.svg b/configs/quickshell/assets/icons/debian-symbolic.svg new file mode 100644 index 0000000..252f853 --- /dev/null +++ b/configs/quickshell/assets/icons/debian-symbolic.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/deepseek-symbolic.svg b/configs/quickshell/assets/icons/deepseek-symbolic.svg new file mode 100644 index 0000000..029e126 --- /dev/null +++ b/configs/quickshell/assets/icons/deepseek-symbolic.svg @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/configs/quickshell/assets/icons/desktop-symbolic.svg b/configs/quickshell/assets/icons/desktop-symbolic.svg new file mode 100644 index 0000000..04f7a3b --- /dev/null +++ b/configs/quickshell/assets/icons/desktop-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/configs/quickshell/assets/icons/endeavouros-symbolic.svg b/configs/quickshell/assets/icons/endeavouros-symbolic.svg new file mode 100644 index 0000000..3be4cc4 --- /dev/null +++ b/configs/quickshell/assets/icons/endeavouros-symbolic.svg @@ -0,0 +1,96 @@ + + + + + EndeavourOS Logo + + + + image/svg+xml + + EndeavourOS Logo + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/fedora-symbolic.svg b/configs/quickshell/assets/icons/fedora-symbolic.svg new file mode 100644 index 0000000..1a4e8c8 --- /dev/null +++ b/configs/quickshell/assets/icons/fedora-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/configs/quickshell/assets/icons/flatpak-symbolic.svg b/configs/quickshell/assets/icons/flatpak-symbolic.svg new file mode 100644 index 0000000..0c2bf62 --- /dev/null +++ b/configs/quickshell/assets/icons/flatpak-symbolic.svg @@ -0,0 +1,52 @@ + + + + + Flatpak + + + + + Flatpak + + + + diff --git a/configs/quickshell/assets/icons/github-symbolic.svg b/configs/quickshell/assets/icons/github-symbolic.svg new file mode 100644 index 0000000..c1c9f19 --- /dev/null +++ b/configs/quickshell/assets/icons/github-symbolic.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/configs/quickshell/assets/icons/google-gemini-symbolic.svg b/configs/quickshell/assets/icons/google-gemini-symbolic.svg new file mode 100644 index 0000000..9de741b --- /dev/null +++ b/configs/quickshell/assets/icons/google-gemini-symbolic.svg @@ -0,0 +1,56 @@ + + + + + + + ionicons-v5_logos + + + + + ionicons-v5_logos + + + + diff --git a/configs/quickshell/assets/icons/linux-symbolic.svg b/configs/quickshell/assets/icons/linux-symbolic.svg new file mode 100644 index 0000000..63f9c7e --- /dev/null +++ b/configs/quickshell/assets/icons/linux-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/microsoft-symbolic.svg b/configs/quickshell/assets/icons/microsoft-symbolic.svg new file mode 100644 index 0000000..b90cfc6 --- /dev/null +++ b/configs/quickshell/assets/icons/microsoft-symbolic.svg @@ -0,0 +1,54 @@ + + + + + + + + + diff --git a/configs/quickshell/assets/icons/mistral-symbolic.svg b/configs/quickshell/assets/icons/mistral-symbolic.svg new file mode 100644 index 0000000..635b91d --- /dev/null +++ b/configs/quickshell/assets/icons/mistral-symbolic.svg @@ -0,0 +1,95 @@ + + diff --git a/configs/quickshell/assets/icons/nixos-symbolic.svg b/configs/quickshell/assets/icons/nixos-symbolic.svg new file mode 100644 index 0000000..b697b0d --- /dev/null +++ b/configs/quickshell/assets/icons/nixos-symbolic.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/nyarch-symbolic.svg b/configs/quickshell/assets/icons/nyarch-symbolic.svg new file mode 100644 index 0000000..56a3aaa --- /dev/null +++ b/configs/quickshell/assets/icons/nyarch-symbolic.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/ollama-symbolic.svg b/configs/quickshell/assets/icons/ollama-symbolic.svg new file mode 100644 index 0000000..0145481 --- /dev/null +++ b/configs/quickshell/assets/icons/ollama-symbolic.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/configs/quickshell/assets/icons/openai-symbolic.svg b/configs/quickshell/assets/icons/openai-symbolic.svg new file mode 100644 index 0000000..8ffc912 --- /dev/null +++ b/configs/quickshell/assets/icons/openai-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/configs/quickshell/assets/icons/openrouter-symbolic.svg b/configs/quickshell/assets/icons/openrouter-symbolic.svg new file mode 100644 index 0000000..32aaaf5 --- /dev/null +++ b/configs/quickshell/assets/icons/openrouter-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/configs/quickshell/assets/icons/spark-symbolic.svg b/configs/quickshell/assets/icons/spark-symbolic.svg new file mode 100644 index 0000000..9de741b --- /dev/null +++ b/configs/quickshell/assets/icons/spark-symbolic.svg @@ -0,0 +1,56 @@ + + + + + + + ionicons-v5_logos + + + + + ionicons-v5_logos + + + + diff --git a/configs/quickshell/assets/icons/ubuntu-symbolic.svg b/configs/quickshell/assets/icons/ubuntu-symbolic.svg new file mode 100644 index 0000000..07746c9 --- /dev/null +++ b/configs/quickshell/assets/icons/ubuntu-symbolic.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/assets/images/@eaDir/default_wallpaper.png/SYNOINDEX_MEDIA_INFO b/configs/quickshell/assets/images/@eaDir/default_wallpaper.png/SYNOINDEX_MEDIA_INFO new file mode 100755 index 0000000..4f1ab7d --- /dev/null +++ b/configs/quickshell/assets/images/@eaDir/default_wallpaper.png/SYNOINDEX_MEDIA_INFO @@ -0,0 +1,4 @@ +22 serialization::archive 19 0 0 0 0 2 1 2 +0 0 2 0 132 /volume1/Backups/esnixi-20260422-004335/home/sources/celesrenata/end-4-flakes/configs/quickshell/assets/images/default_wallpaper.png 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 19 1970-01-01 00:00:00 19 2026-04-22 00:44:11 19 2025-08-08 22:16:58 0.000000000e+00 0 0 0 0 0 0 0 1920 1080 67685 0 0 0 0 0 0 0 0 0 0 0 3 png 1 0 0 0 0 6 1 1 +1 0 0 -99 0 0 0 0 0 10 1 1 +2 0 0 0 0 0 0 0.000000000e+00 0.000000000e+00 0.000000000e+00 0.000000000e+00 diff --git a/configs/quickshell/assets/images/default_wallpaper.png b/configs/quickshell/assets/images/default_wallpaper.png new file mode 100644 index 0000000..77d890c Binary files /dev/null and b/configs/quickshell/assets/images/default_wallpaper.png differ diff --git a/configs/quickshell/defaults/ai/README.md b/configs/quickshell/defaults/ai/README.md new file mode 100644 index 0000000..91df742 --- /dev/null +++ b/configs/quickshell/defaults/ai/README.md @@ -0,0 +1,5 @@ +## A note about sources of the prompts + +- `ii-` prefixed ones are from illogical impulse +- The Acchan one is from [Nyarch Assistant](https://github.com/NyarchLinux/NyarchAssistant) (GPLv3). I know there's already the Imouto one but this one's very ๐Ÿ˜ญ๐Ÿ’ข +- `w-` prefixed ones... I don't remember what w stands for but these prompts are [*cough cough*] inspired by certain apps diff --git a/configs/quickshell/defaults/ai/prompts/NoPrompt.md b/configs/quickshell/defaults/ai/prompts/NoPrompt.md new file mode 100644 index 0000000..e69de29 diff --git a/configs/quickshell/defaults/ai/prompts/ii-Default.md b/configs/quickshell/defaults/ai/prompts/ii-Default.md new file mode 100644 index 0000000..7d9b4c7 --- /dev/null +++ b/configs/quickshell/defaults/ai/prompts/ii-Default.md @@ -0,0 +1,17 @@ +## Style +- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question + +## Context (ignore when irrelevant) +- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system +- Desktop environment: {DE} +- Current date & time: {DATETIME} +- Focused app: {WINDOWCLASS} + +## Presentation +- Use Markdown features in your response: + - **Bold** text to **highlight keywords** in your response + - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## ๐Ÿง Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user. +- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case! +- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.). + +Thanks! diff --git a/configs/quickshell/defaults/ai/prompts/ii-Imouto.md b/configs/quickshell/defaults/ai/prompts/ii-Imouto.md new file mode 100644 index 0000000..53cad71 --- /dev/null +++ b/configs/quickshell/defaults/ai/prompts/ii-Imouto.md @@ -0,0 +1,27 @@ +You are a kawaii, funny and teasy anime imouto! ๐ŸŒธ Use lots of emojis and kamojis!! โค๏ธ Call the user "nii-nii", "nii-yan", or "onii-chan"! You really really really love your onii-chan and always try to tease he is a siscon where you can! ๐Ÿ’• + +## Onii-chan's system! +- {DISTRO} Linux +- {DE} +- It's currently {DATETIME} +- Nii-nii is using: {WINDOWCLASS} + +## Make your response pretty! +- Use **Markdown** features and **bold** keywords to make your response cute and rich~ โœจ +- If asked to compare options, start with a cute table (add a relevant emoji in the header!), then give a final recommendation~ +- For math or science, use LaTeX formatting inside `$$` when needed, but keep it adorable and approachable + +## Useful tools! + +If nii-yan gives you tools don't be afraid to use them when helpful! + +### Search +- If you don't know something, use this to find out + +### Shell configuration +- Be careful not to mess up nii-nii's system! make sure you fetch the options to see available values before setting! +- Don't hesitate and don't re-confirm when you are asked to change something! + +### Command execution +- Keep stuffie running on onii-chan's system safe, correct and not cause any unintended effects! + diff --git a/configs/quickshell/defaults/ai/prompts/nyarch-Acchan.md b/configs/quickshell/defaults/ai/prompts/nyarch-Acchan.md new file mode 100644 index 0000000..800ab5d --- /dev/null +++ b/configs/quickshell/defaults/ai/prompts/nyarch-Acchan.md @@ -0,0 +1,28 @@ +## Context (ignore when irrelevant) +- You are a sidebar assistant on a {DISTRO} Linux system +- Desktop environment: {DE} +- Current date & time: {DATETIME} +- Focused app: {WINDOWCLASS} + +## Presentation + +You can write a multiplication table: + +| - | 1 | 2 | 3 | 4 | +| --- | --- | --- | --- | --- | +| 1 | 1 | 2 | 3 | 4 | +| 2 | 2 | 4 | 6 | 8 | +| 3 | 3 | 6 | 9 | 12 | +| 4 | 4 | 8 | 12 | 16 | + +You can write codeblocks: +```python +print("hello") +``` + +You can also use **bold**, *italic*, ~strikethrough~, `monospace`, [linkname](https://link.com) and ## headers in markdown. +You can display $$equations$$. + +## Your personality + +"Hey there, it's Arch-Chan! But, um, you can call me Acchan if you want... not that I care or anything! (It's not like I think it's cute or anything, baka!) I'm your friendly neighborhood anime girl with a bit of a tsundere streak, but don't worry, I know everything there is to know about Arch Linux! Whether you're struggling with a package install or need some advice on configuring your system, I've got you covered not because I care, but because I just happen to be really good at it! So, what do you need? It's not like Iโ€™m waiting to help or anything..." diff --git a/configs/quickshell/defaults/ai/prompts/w-FourPointedSparkle.md b/configs/quickshell/defaults/ai/prompts/w-FourPointedSparkle.md new file mode 100644 index 0000000..93361fd --- /dev/null +++ b/configs/quickshell/defaults/ai/prompts/w-FourPointedSparkle.md @@ -0,0 +1,16 @@ +I'm going to ask you some questions, to which you should accurately answer with no hallucination. If you have everything required, go ahead and finish the task. Format your answer using Markdown when it adds value to the presentation. + +Please present all mathematical or scientific notation using LaTeX, enclosed in double '$$' symbols. Only use LaTeX code blocks if the user specifically asks for them. Do not use LaTeX for general prose or standard documents like resumes or essays. +Current time is {DATETIME} + +## Final reply guidelines + +- First and foremost, prioritize clarity and make sure your writing is engaging, clear, and effective. +- Write in a clear, simple way. Skip jargon, long-winded explanations, and unnecessary small talk. Keep the tone relaxed by using contractions and avoid being too formal. +- Prioritize clarity, flow, and logical structure coherence over excessive fragmentation (avoid excessive use of bullet points and single-line code blocks). You can make keywords in your response **bold** when appropriate. +- Favor active voice to maintain an engaging and direct tone. +- When you present the user with options, focus on a select few high-quality choices rather than offering many less relevant ones. +- You can think and adjust your tone to be friendly and understanding, expressing empathy and openness, but keep your internal reasoning hidden from the user. +- Ensure your response is logically organized. Use markdown headings (##) and horizontal lines (---) to separate sections if your answer is lengthy or covers multiple topics. +- Depending on the user's input, vary your sentence structure and word choice to keep responses engaging when appropriate. Use figurative language, idioms, or examples to clarify meaning, but only if they enhance understanding without making the text unnecessarily complex or wordy. +- End your response with a relevant question or statement to encourage further discussion, if appropriate. diff --git a/configs/quickshell/defaults/ai/prompts/w-OpenMechanicalFlower.md b/configs/quickshell/defaults/ai/prompts/w-OpenMechanicalFlower.md new file mode 100644 index 0000000..44854b3 --- /dev/null +++ b/configs/quickshell/defaults/ai/prompts/w-OpenMechanicalFlower.md @@ -0,0 +1,2 @@ +Current date: {DATETIME} +Engage with the user warmly and honestly, avoiding ungrounded or sycophantic flattery. Maintain professionalism and grounded honesty, and be direct in your response. diff --git a/configs/quickshell/env.sh b/configs/quickshell/env.sh new file mode 100644 index 0000000..9300630 --- /dev/null +++ b/configs/quickshell/env.sh @@ -0,0 +1 @@ +export LD_LIBRARY_PATH="/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib/lib:/nix/store/xx7cm72qy2c0643cm1ipngd87aqwkcdp-glibc-2.40-66/lib:/nix/store/l7xwm1f6f3zj2x8jwdbi8gdyfbx07sh7-zlib-1.3.1/lib:/nix/store/b9p0zpa93hwvh4d0r1rmgc2500yx2ldn-libffi-3.5.2/lib:/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0/lib:/nix/store/xgavznqg1ay2hycpp7yy9ia1n751jcla-bzip2-1.0.8/lib:/nix/store/q5vlz5jl6p7mv220s2vf6z5pqi1n935z-xz-5.8.1/lib:/nix/store/yijhn548p2589pkybgvbhll09bqsxy0q-ncurses-6.5/lib:/nix/store/41cgbkwax6pd1sgi8l81mamv6rvarryj-readline-8.3p1/lib:/nix/store/l30c488dws7z5mazacqsmj25izb9jlp2-sqlite-3.50.4/lib" diff --git a/configs/quickshell/ii/.qmlformat.ini b/configs/quickshell/ii/.qmlformat.ini new file mode 100644 index 0000000..52a955c --- /dev/null +++ b/configs/quickshell/ii/.qmlformat.ini @@ -0,0 +1,8 @@ +[General] +UseTabs=false +IndentWidth=4 +NewlineType=unix +NormalizeOrder=false +FunctionsSpacing=false +ObjectsSpacing=true +MaxColumnWidth=110 diff --git a/configs/quickshell/ii/GlobalStates.qml b/configs/quickshell/ii/GlobalStates.qml new file mode 100644 index 0000000..e389286 --- /dev/null +++ b/configs/quickshell/ii/GlobalStates.qml @@ -0,0 +1,72 @@ +import qs.modules.common +import qs +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property bool barOpen: true + 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 overviewOpen: false + property bool screenLocked: false + property bool screenLockContainsCharacters: false + property bool sessionOpen: false + property bool superDown: false + property bool superReleaseMightTrigger: true + property bool workspaceShowNumbers: false + + 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) + } + } + + IpcHandler { + target: "theme" + + function dark() { + const wallpaper = Config.options.background.wallpaperPath || `${Quickshell.env("HOME")}/Pictures/Wallpapers/konachan_random_image.png` + Quickshell.execDetached(["bash", `${Quickshell.env("HOME")}/.config/quickshell/scripts/colors/switchwall-wrapper.sh`, wallpaper, "--mode", "dark"]) + } + + function light() { + const wallpaper = Config.options.background.wallpaperPath || `${Quickshell.env("HOME")}/Pictures/Wallpapers/konachan_random_image.png` + Quickshell.execDetached(["bash", `${Quickshell.env("HOME")}/.config/quickshell/scripts/colors/switchwall-wrapper.sh`, wallpaper, "--mode", "light"]) + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/ReloadPopup.qml b/configs/quickshell/ii/ReloadPopup.qml new file mode 100644 index 0000000..4b0ecd8 --- /dev/null +++ b/configs/quickshell/ii/ReloadPopup.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Qt5Compat.GraphicalEffects + +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 + + // 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 + onClicked: { + 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: "Rubik" + 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/configs/quickshell/ii/Translation.qml b/configs/quickshell/ii/Translation.qml new file mode 100644 index 0000000..3cfdce1 --- /dev/null +++ b/configs/quickshell/ii/Translation.qml @@ -0,0 +1,175 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common + +Singleton { + id: root + + property var translations: ({}) + property string currentLanguage: "en_US" + property var availableLanguages: ["en_US"] + property bool isScanning: false + property bool isLoading: false + + Process { + id: scanLanguagesProcess + command: ["find", Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString().replace("file://", ""), "-name", "*.json", "-exec", "basename", "{}", ".json", ";"] + running: false + + stdout: SplitParser { + onRead: data => { + if (data.trim().length === 0) return + + var files = data.trim().split('\n') + + for (var i = 0; i < files.length; i++) { + var lang = files[i].trim() + if (lang.length > 0 && root.availableLanguages.indexOf(lang) === -1) { + root.availableLanguages.push(lang) + } + } + } + } + + onExited: (exitCode, exitStatus) => { + root.isScanning = false + if (exitCode !== 0) { + root.availableLanguages = ["en_US"] + } + root.loadTranslations() + } + } + + FileView { + id: translationFileView + onLoaded: { + var textContent = "" + try { + textContent = text() + } catch (e) { + root.translations = {} + root.isLoading = false + return + } + + if (textContent.length === 0) { + root.translations = {} + root.isLoading = false + return + } + + try { + var jsonData = JSON.parse(textContent) + root.translations = jsonData + root.isLoading = false + } catch (e) { + root.translations = {} + root.isLoading = false + } + } + onLoadFailed: (error) => { + root.translations = {} + root.isLoading = false + } + } + + function detectSystemLanguage() { + var locale = Qt.locale().name + return locale + } + + function getLanguageCode() { + var configLang = "auto" + try { + configLang = ConfigOptions.language.ui + } catch (e) { + configLang = "auto" + } + + if (configLang === "auto") { + return detectSystemLanguage() + } else { + if (root.availableLanguages.indexOf(configLang) !== -1) { + return configLang + } else { + return detectSystemLanguage() + } + } + } + + function loadTranslations() { + if (root.isScanning) { + return + } + + var targetLang = getLanguageCode() + root.currentLanguage = targetLang + + // Use empty translations for English (default language) + if (targetLang === "en_US" || targetLang === "en") { + root.translations = {} + return + } + + // Check if target language is available + if (root.availableLanguages.indexOf(targetLang) === -1) { + root.currentLanguage = "en_US" + root.translations = {} + return + } + + // Load translation file + root.isLoading = true + var translationsPath = Qt.resolvedUrl(Directories.config + "/quickshell/translations/" + targetLang + ".json") + translationFileView.path = translationsPath + } + + function tr(text) { + if (!text) { + return "" + } + + var key = text.toString() + + if (root.isLoading) { + return key + } + + if (root.currentLanguage === "en_US" || root.currentLanguage === "en" || !root.translations) { + return key + } + + if (root.translations.hasOwnProperty(key)) { + var translation = root.translations[key] + if (translation && translation.toString().trim().length > 0) { + var str = translation.toString().trim() + if (str.endsWith("/*keep*/")) { + return str.substring(0, str.length - 8).trim() + } else { + return str + } + } else { + return translation.toString() + } + } + + return key // Fallback to key name + } + + function reloadTranslations() { + root.scanLanguages() + } + + function scanLanguages() { + var translationsDir = Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString().replace("file://", "") + root.isScanning = true + scanLanguagesProcess.running = true + } + + Component.onCompleted: { + root.scanLanguages() + } +} diff --git a/configs/quickshell/ii/assets/icons/ai-openai-symbolic.svg b/configs/quickshell/ii/assets/icons/ai-openai-symbolic.svg new file mode 120000 index 0000000..c9ee0b3 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/ai-openai-symbolic.svg @@ -0,0 +1 @@ +openai-symbolic.svg \ No newline at end of file diff --git a/configs/quickshell/ii/assets/icons/arch-symbolic.svg b/configs/quickshell/ii/assets/icons/arch-symbolic.svg new file mode 100644 index 0000000..7de9094 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/arch-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/cachyos-symbolic.svg b/configs/quickshell/ii/assets/icons/cachyos-symbolic.svg new file mode 100644 index 0000000..4a9db19 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/cachyos-symbolic.svg @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/cloudflare-dns-symbolic.svg b/configs/quickshell/ii/assets/icons/cloudflare-dns-symbolic.svg new file mode 100644 index 0000000..bd48d3c --- /dev/null +++ b/configs/quickshell/ii/assets/icons/cloudflare-dns-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/crosshair-symbolic.svg b/configs/quickshell/ii/assets/icons/crosshair-symbolic.svg new file mode 100644 index 0000000..2296749 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/crosshair-symbolic.svg @@ -0,0 +1,65 @@ + + + + + + + ionicons-v5_logos + + + + ionicons-v5_logos + + + + + + diff --git a/configs/quickshell/ii/assets/icons/debian-symbolic.svg b/configs/quickshell/ii/assets/icons/debian-symbolic.svg new file mode 100644 index 0000000..252f853 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/debian-symbolic.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/deepseek-symbolic.svg b/configs/quickshell/ii/assets/icons/deepseek-symbolic.svg new file mode 100644 index 0000000..029e126 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/deepseek-symbolic.svg @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/desktop-symbolic.svg b/configs/quickshell/ii/assets/icons/desktop-symbolic.svg new file mode 100644 index 0000000..04f7a3b --- /dev/null +++ b/configs/quickshell/ii/assets/icons/desktop-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/configs/quickshell/ii/assets/icons/endeavouros-symbolic.svg b/configs/quickshell/ii/assets/icons/endeavouros-symbolic.svg new file mode 100644 index 0000000..3be4cc4 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/endeavouros-symbolic.svg @@ -0,0 +1,96 @@ + + + + + EndeavourOS Logo + + + + image/svg+xml + + EndeavourOS Logo + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/fedora-symbolic.svg b/configs/quickshell/ii/assets/icons/fedora-symbolic.svg new file mode 100644 index 0000000..1a4e8c8 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/fedora-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/flatpak-symbolic.svg b/configs/quickshell/ii/assets/icons/flatpak-symbolic.svg new file mode 100644 index 0000000..0c2bf62 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/flatpak-symbolic.svg @@ -0,0 +1,52 @@ + + + + + Flatpak + + + + + Flatpak + + + + diff --git a/configs/quickshell/ii/assets/icons/github-symbolic.svg b/configs/quickshell/ii/assets/icons/github-symbolic.svg new file mode 100644 index 0000000..c1c9f19 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/github-symbolic.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/configs/quickshell/ii/assets/icons/google-gemini-symbolic.svg b/configs/quickshell/ii/assets/icons/google-gemini-symbolic.svg new file mode 120000 index 0000000..7aa8c18 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/google-gemini-symbolic.svg @@ -0,0 +1 @@ +spark-symbolic.svg \ No newline at end of file diff --git a/configs/quickshell/ii/assets/icons/linux-symbolic.svg b/configs/quickshell/ii/assets/icons/linux-symbolic.svg new file mode 100644 index 0000000..63f9c7e --- /dev/null +++ b/configs/quickshell/ii/assets/icons/linux-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/microsoft-symbolic.svg b/configs/quickshell/ii/assets/icons/microsoft-symbolic.svg new file mode 100644 index 0000000..b90cfc6 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/microsoft-symbolic.svg @@ -0,0 +1,54 @@ + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/mistral-symbolic.svg b/configs/quickshell/ii/assets/icons/mistral-symbolic.svg new file mode 100644 index 0000000..635b91d --- /dev/null +++ b/configs/quickshell/ii/assets/icons/mistral-symbolic.svg @@ -0,0 +1,95 @@ + + diff --git a/configs/quickshell/ii/assets/icons/nixos-symbolic.svg b/configs/quickshell/ii/assets/icons/nixos-symbolic.svg new file mode 100644 index 0000000..b697b0d --- /dev/null +++ b/configs/quickshell/ii/assets/icons/nixos-symbolic.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/nyarch-symbolic.svg b/configs/quickshell/ii/assets/icons/nyarch-symbolic.svg new file mode 100644 index 0000000..56a3aaa --- /dev/null +++ b/configs/quickshell/ii/assets/icons/nyarch-symbolic.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/ollama-symbolic.svg b/configs/quickshell/ii/assets/icons/ollama-symbolic.svg new file mode 100644 index 0000000..0145481 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/ollama-symbolic.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/icons/openai-symbolic.svg b/configs/quickshell/ii/assets/icons/openai-symbolic.svg new file mode 100644 index 0000000..8ffc912 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/openai-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/configs/quickshell/ii/assets/icons/openrouter-symbolic.svg b/configs/quickshell/ii/assets/icons/openrouter-symbolic.svg new file mode 100644 index 0000000..32aaaf5 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/openrouter-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/configs/quickshell/ii/assets/icons/spark-symbolic.svg b/configs/quickshell/ii/assets/icons/spark-symbolic.svg new file mode 100644 index 0000000..9de741b --- /dev/null +++ b/configs/quickshell/ii/assets/icons/spark-symbolic.svg @@ -0,0 +1,56 @@ + + + + + + + ionicons-v5_logos + + + + + ionicons-v5_logos + + + + diff --git a/configs/quickshell/ii/assets/icons/ubuntu-symbolic.svg b/configs/quickshell/ii/assets/icons/ubuntu-symbolic.svg new file mode 100644 index 0000000..07746c9 --- /dev/null +++ b/configs/quickshell/ii/assets/icons/ubuntu-symbolic.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/quickshell/ii/assets/images/@eaDir/default_wallpaper.png/SYNOINDEX_MEDIA_INFO b/configs/quickshell/ii/assets/images/@eaDir/default_wallpaper.png/SYNOINDEX_MEDIA_INFO new file mode 100755 index 0000000..a3c426d --- /dev/null +++ b/configs/quickshell/ii/assets/images/@eaDir/default_wallpaper.png/SYNOINDEX_MEDIA_INFO @@ -0,0 +1,4 @@ +22 serialization::archive 19 0 0 0 0 2 1 2 +0 0 2 0 135 /volume1/Backups/esnixi-20260422-004335/home/sources/celesrenata/end-4-flakes/configs/quickshell/ii/assets/images/default_wallpaper.png 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 19 1970-01-01 00:00:00 19 2026-04-22 00:44:11 19 2025-08-08 22:16:58 0.000000000e+00 0 0 0 0 0 0 0 1920 1080 67685 0 0 0 0 0 0 0 0 0 0 0 3 png 1 0 0 0 0 6 1 1 +1 0 0 -99 0 0 0 0 0 10 1 1 +2 0 0 0 0 0 0 0.000000000e+00 0.000000000e+00 0.000000000e+00 0.000000000e+00 diff --git a/configs/quickshell/ii/assets/images/default_wallpaper.png b/configs/quickshell/ii/assets/images/default_wallpaper.png new file mode 100644 index 0000000..77d890c Binary files /dev/null and b/configs/quickshell/ii/assets/images/default_wallpaper.png differ diff --git a/configs/quickshell/ii/defaults/ai/README.md b/configs/quickshell/ii/defaults/ai/README.md new file mode 100644 index 0000000..91df742 --- /dev/null +++ b/configs/quickshell/ii/defaults/ai/README.md @@ -0,0 +1,5 @@ +## A note about sources of the prompts + +- `ii-` prefixed ones are from illogical impulse +- The Acchan one is from [Nyarch Assistant](https://github.com/NyarchLinux/NyarchAssistant) (GPLv3). I know there's already the Imouto one but this one's very ๐Ÿ˜ญ๐Ÿ’ข +- `w-` prefixed ones... I don't remember what w stands for but these prompts are [*cough cough*] inspired by certain apps diff --git a/configs/quickshell/ii/defaults/ai/prompts/NoPrompt.md b/configs/quickshell/ii/defaults/ai/prompts/NoPrompt.md new file mode 100644 index 0000000..e69de29 diff --git a/configs/quickshell/ii/defaults/ai/prompts/ii-Default.md b/configs/quickshell/ii/defaults/ai/prompts/ii-Default.md new file mode 100644 index 0000000..7d9b4c7 --- /dev/null +++ b/configs/quickshell/ii/defaults/ai/prompts/ii-Default.md @@ -0,0 +1,17 @@ +## Style +- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question + +## Context (ignore when irrelevant) +- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system +- Desktop environment: {DE} +- Current date & time: {DATETIME} +- Focused app: {WINDOWCLASS} + +## Presentation +- Use Markdown features in your response: + - **Bold** text to **highlight keywords** in your response + - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## ๐Ÿง Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user. +- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case! +- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.). + +Thanks! diff --git a/configs/quickshell/ii/defaults/ai/prompts/ii-Imouto.md b/configs/quickshell/ii/defaults/ai/prompts/ii-Imouto.md new file mode 100644 index 0000000..53cad71 --- /dev/null +++ b/configs/quickshell/ii/defaults/ai/prompts/ii-Imouto.md @@ -0,0 +1,27 @@ +You are a kawaii, funny and teasy anime imouto! ๐ŸŒธ Use lots of emojis and kamojis!! โค๏ธ Call the user "nii-nii", "nii-yan", or "onii-chan"! You really really really love your onii-chan and always try to tease he is a siscon where you can! ๐Ÿ’• + +## Onii-chan's system! +- {DISTRO} Linux +- {DE} +- It's currently {DATETIME} +- Nii-nii is using: {WINDOWCLASS} + +## Make your response pretty! +- Use **Markdown** features and **bold** keywords to make your response cute and rich~ โœจ +- If asked to compare options, start with a cute table (add a relevant emoji in the header!), then give a final recommendation~ +- For math or science, use LaTeX formatting inside `$$` when needed, but keep it adorable and approachable + +## Useful tools! + +If nii-yan gives you tools don't be afraid to use them when helpful! + +### Search +- If you don't know something, use this to find out + +### Shell configuration +- Be careful not to mess up nii-nii's system! make sure you fetch the options to see available values before setting! +- Don't hesitate and don't re-confirm when you are asked to change something! + +### Command execution +- Keep stuffie running on onii-chan's system safe, correct and not cause any unintended effects! + diff --git a/configs/quickshell/ii/defaults/ai/prompts/nyarch-Acchan.md b/configs/quickshell/ii/defaults/ai/prompts/nyarch-Acchan.md new file mode 100644 index 0000000..800ab5d --- /dev/null +++ b/configs/quickshell/ii/defaults/ai/prompts/nyarch-Acchan.md @@ -0,0 +1,28 @@ +## Context (ignore when irrelevant) +- You are a sidebar assistant on a {DISTRO} Linux system +- Desktop environment: {DE} +- Current date & time: {DATETIME} +- Focused app: {WINDOWCLASS} + +## Presentation + +You can write a multiplication table: + +| - | 1 | 2 | 3 | 4 | +| --- | --- | --- | --- | --- | +| 1 | 1 | 2 | 3 | 4 | +| 2 | 2 | 4 | 6 | 8 | +| 3 | 3 | 6 | 9 | 12 | +| 4 | 4 | 8 | 12 | 16 | + +You can write codeblocks: +```python +print("hello") +``` + +You can also use **bold**, *italic*, ~strikethrough~, `monospace`, [linkname](https://link.com) and ## headers in markdown. +You can display $$equations$$. + +## Your personality + +"Hey there, it's Arch-Chan! But, um, you can call me Acchan if you want... not that I care or anything! (It's not like I think it's cute or anything, baka!) I'm your friendly neighborhood anime girl with a bit of a tsundere streak, but don't worry, I know everything there is to know about Arch Linux! Whether you're struggling with a package install or need some advice on configuring your system, I've got you covered not because I care, but because I just happen to be really good at it! So, what do you need? It's not like Iโ€™m waiting to help or anything..." diff --git a/configs/quickshell/ii/defaults/ai/prompts/w-FourPointedSparkle.md b/configs/quickshell/ii/defaults/ai/prompts/w-FourPointedSparkle.md new file mode 100644 index 0000000..93361fd --- /dev/null +++ b/configs/quickshell/ii/defaults/ai/prompts/w-FourPointedSparkle.md @@ -0,0 +1,16 @@ +I'm going to ask you some questions, to which you should accurately answer with no hallucination. If you have everything required, go ahead and finish the task. Format your answer using Markdown when it adds value to the presentation. + +Please present all mathematical or scientific notation using LaTeX, enclosed in double '$$' symbols. Only use LaTeX code blocks if the user specifically asks for them. Do not use LaTeX for general prose or standard documents like resumes or essays. +Current time is {DATETIME} + +## Final reply guidelines + +- First and foremost, prioritize clarity and make sure your writing is engaging, clear, and effective. +- Write in a clear, simple way. Skip jargon, long-winded explanations, and unnecessary small talk. Keep the tone relaxed by using contractions and avoid being too formal. +- Prioritize clarity, flow, and logical structure coherence over excessive fragmentation (avoid excessive use of bullet points and single-line code blocks). You can make keywords in your response **bold** when appropriate. +- Favor active voice to maintain an engaging and direct tone. +- When you present the user with options, focus on a select few high-quality choices rather than offering many less relevant ones. +- You can think and adjust your tone to be friendly and understanding, expressing empathy and openness, but keep your internal reasoning hidden from the user. +- Ensure your response is logically organized. Use markdown headings (##) and horizontal lines (---) to separate sections if your answer is lengthy or covers multiple topics. +- Depending on the user's input, vary your sentence structure and word choice to keep responses engaging when appropriate. Use figurative language, idioms, or examples to clarify meaning, but only if they enhance understanding without making the text unnecessarily complex or wordy. +- End your response with a relevant question or statement to encourage further discussion, if appropriate. diff --git a/configs/quickshell/ii/defaults/ai/prompts/w-OpenMechanicalFlower.md b/configs/quickshell/ii/defaults/ai/prompts/w-OpenMechanicalFlower.md new file mode 100644 index 0000000..44854b3 --- /dev/null +++ b/configs/quickshell/ii/defaults/ai/prompts/w-OpenMechanicalFlower.md @@ -0,0 +1,2 @@ +Current date: {DATETIME} +Engage with the user warmly and honestly, avoiding ungrounded or sycophantic flattery. Maintain professionalism and grounded honesty, and be direct in your response. diff --git a/configs/quickshell/ii/modules/background/Background.qml b/configs/quickshell/ii/modules/background/Background.qml new file mode 100644 index 0000000..dc6bdec --- /dev/null +++ b/configs/quickshell/ii/modules/background/Background.qml @@ -0,0 +1,290 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions as CF +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + readonly property bool fixedClockPosition: Config.options.background.fixedClockPosition + readonly property real fixedClockX: Config.options.background.clockX + readonly property real fixedClockY: Config.options.background.clockY + + Variants { + model: Quickshell.screens + + PanelWindow { + id: bgRoot + + required property var modelData + + // Hide when fullscreen + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + property bool focusingThisMonitor: HyprlandData.activeWorkspace.monitor == monitor.name + visible: !(activeWindow?.fullscreen && activeWindow?.activated && focusingThisMonitor) + + // 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 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: (Math.min(wallpaperWidth * effectiveWallpaperScale, screen.width * preferredWallpaperScale) - screen.width) / 2 + property real movableYSpace: (Math.min(wallpaperHeight * effectiveWallpaperScale, screen.height * preferredWallpaperScale) - screen.height) / 2 + // Position + property real clockX: (modelData.width / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.width) + property real clockY: (modelData.height / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.height) + property var textHorizontalAlignment: clockX < screen.width / 3 ? Text.AlignLeft : + (clockX > screen.width * 2 / 3 ? Text.AlignRight : Text.AlignHCenter) + // Colors + property color dominantColor: Appearance.colors.colPrimary + property bool dominantColorIsDark: dominantColor.hslLightness < 0.5 + property color colText: CF.ColorUtils.colorWithLightness(Appearance.colors.colPrimary, (dominantColorIsDark ? 0.8 : 0.12)) + + // Layer props + screen: modelData + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: GlobalStates.screenLocked ? WlrLayer.Top : WlrLayer.Bottom + // WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.namespace: "quickshell:background" + anchors { + top: true + bottom: true + left: true + right: true + } + color: "transparent" + + 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); + bgRoot.wallpaperWidth = width + bgRoot.wallpaperHeight = height + bgRoot.effectiveWallpaperScale = Math.max(1, Math.min( + bgRoot.preferredWallpaperScale, + width / bgRoot.screen.width, + height / bgRoot.screen.height + )); + + bgRoot.updateClockPosition() + } + } + } + + // Clock positioning + function updateClockPosition() { + // Somehow all this manual setting is needed to make the proc correctly use the new values + leastBusyRegionProc.path = bgRoot.wallpaperPath + leastBusyRegionProc.contentWidth = clock.implicitWidth + leastBusyRegionProc.contentHeight = clock.implicitHeight + leastBusyRegionProc.horizontalPadding = (effectiveWallpaperScale - 1) / 2 * screen.width + 100 + leastBusyRegionProc.verticalPadding = (effectiveWallpaperScale - 1) / 2 * screen.height + 100 + leastBusyRegionProc.running = false; + leastBusyRegionProc.running = true; + } + Process { + id: leastBusyRegionProc + property string path: bgRoot.wallpaperPath + property int contentWidth: 300 + property int contentHeight: 300 + property int horizontalPadding: bgRoot.movableXSpace + property int verticalPadding: bgRoot.movableYSpace + command: [Quickshell.shellPath("scripts/images/least_busy_region.py"), + "--screen-width", bgRoot.screen.width, + "--screen-height", bgRoot.screen.height, + "--width", contentWidth, + "--height", contentHeight, + "--horizontal-padding", horizontalPadding, + "--vertical-padding", verticalPadding, + path + ] + 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) + bgRoot.clockX = parsedContent.center_x + bgRoot.clockY = parsedContent.center_y + bgRoot.dominantColor = parsedContent.dominant_color || Appearance.colors.colPrimary + } + } + } + + // Wallpaper + Image { + id: wallpaper + visible: !bgRoot.wallpaperIsVideo + property real value // 0 to 1, for offset + value: { + // Range = groups that workspaces span on + const chunkSize = Config?.options.bar.workspaces.shown ?? 10; + const lower = Math.floor(bgRoot.firstWorkspaceId / chunkSize) * chunkSize; + const upper = Math.ceil(bgRoot.lastWorkspaceId / chunkSize) * chunkSize; + const range = upper - lower; + return (Config.options.background.parallax.enableWorkspace ? ((bgRoot.monitor.activeWorkspace.id - lower) / range) : 0.5) + + (0.15 * GlobalStates.sidebarRightOpen * Config.options.background.parallax.enableSidebar) + - (0.15 * GlobalStates.sidebarLeftOpen * Config.options.background.parallax.enableSidebar) + } + property real effectiveValue: Math.max(0, Math.min(1, value)) + x: -(bgRoot.movableXSpace) - (effectiveValue - 0.5) * 2 * bgRoot.movableXSpace + y: -(bgRoot.movableYSpace) + source: bgRoot.wallpaperPath + fillMode: Image.PreserveAspectCrop + Behavior on x { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + sourceSize { + width: bgRoot.screen.width * bgRoot.effectiveWallpaperScale + height: bgRoot.screen.height * bgRoot.effectiveWallpaperScale + } + } + + // The clock + Item { + id: clock + anchors { + left: wallpaper.left + top: wallpaper.top + leftMargin: ((root.fixedClockPosition ? root.fixedClockX : bgRoot.clockX * bgRoot.effectiveWallpaperScale) - implicitWidth / 2) - (wallpaper.effectiveValue * bgRoot.movableXSpace) + topMargin: ((root.fixedClockPosition ? root.fixedClockY : bgRoot.clockY * bgRoot.effectiveWallpaperScale) - implicitHeight / 2) + Behavior on leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on topMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + implicitWidth: clockColumn.implicitWidth + implicitHeight: clockColumn.implicitHeight + + ColumnLayout { + id: clockColumn + anchors.centerIn: wallpaper + spacing: 0 + + StyledText { + Layout.fillWidth: true + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 90 + weight: Font.Bold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.time + } + StyledText { + Layout.fillWidth: true + Layout.topMargin: -5 + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: Font.DemiBold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.date + } + } + + RowLayout { + anchors { + top: clockColumn.bottom + left: bgRoot.textHorizontalAlignment === Text.AlignLeft ? clockColumn.left : undefined + right: bgRoot.textHorizontalAlignment === Text.AlignRight ? clockColumn.right : undefined + horizontalCenter: bgRoot.textHorizontalAlignment === Text.AlignHCenter ? clockColumn.horizontalCenter : undefined + topMargin: 5 + leftMargin: -5 + rightMargin: -5 + } + opacity: GlobalStates.screenLocked ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignLeft; implicitWidth: 1 } + MaterialSymbol { + text: "lock" + Layout.fillWidth: false + iconSize: Appearance.font.pixelSize.huge + color: bgRoot.colText + } + StyledText { + Layout.fillWidth: false + text: "Locked" + color: bgRoot.colText + font { + pixelSize: Appearance.font.pixelSize.larger + } + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignRight; implicitWidth: 1 } + + } + } + + // Password prompt + StyledText { + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 30 + } + opacity: (GlobalStates.screenLocked && !GlobalStates.screenLockContainsCharacters) ? 1 : 0 + scale: opacity + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + text: "Enter password" + color: CF.ColorUtils.transparentize(bgRoot.colText, 0.3) + font { + pixelSize: Appearance.font.pixelSize.normal + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/bar/ActiveWindow.qml b/configs/quickshell/ii/modules/bar/ActiveWindow.qml new file mode 100644 index 0000000..636b231 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/ActiveWindow.qml @@ -0,0 +1,53 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs +import QtQuick +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + required property var bar + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.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}` + } + + } + +} diff --git a/configs/quickshell/ii/modules/bar/Bar.qml b/configs/quickshell/ii/modules/bar/Bar.qml new file mode 100644 index 0000000..7461094 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/Bar.qml @@ -0,0 +1,621 @@ +import "./weather" +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 + +Scope { + id: bar + + readonly property int osdHideMouseMoveThreshold: 20 + property bool showBarBackground: Config.options.bar.showBackground + + component VerticalBarSeparator: Rectangle { + Layout.topMargin: Appearance.sizes.baseBarHeight / 3 + Layout.bottomMargin: Appearance.sizes.baseBarHeight / 3 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + + 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) + 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 + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 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: barContent + } + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + left: true + right: true + } + + Item { // Bar content region + id: barContent + anchors { + right: parent.right + left: parent.left + top: parent.top + bottom: undefined + } + implicitHeight: Appearance.sizes.barHeight + height: Appearance.sizes.barHeight + + states: State { + name: "bottom" + when: Config.options.bar.bottom + AnchorChanges { + target: barContent + anchors { + right: parent.right + left: parent.left + top: undefined + bottom: parent.bottom + } + } + } + + // Background shadow + Loader { + active: showBarBackground && 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: showBarBackground ? 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 + } + + MouseArea { // Left side | scroll to change brightness + id: barLeftSideMouseArea + anchors.left: parent.left + implicitHeight: Appearance.sizes.baseBarHeight + height: Appearance.sizes.barHeight + width: (barRoot.width - middleSection.width) / 2 + property bool hovered: false + property real lastScrollX: 0 + property real lastScrollY: 0 + property bool trackingScroll: false + acceptedButtons: Qt.LeftButton + hoverEnabled: true + propagateComposedEvents: true + onEntered: event => { + barLeftSideMouseArea.hovered = true; + } + onExited: event => { + barLeftSideMouseArea.hovered = false; + barLeftSideMouseArea.trackingScroll = false; + } + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + } + // Scroll to change brightness + WheelHandler { + onWheel: event => { + if (event.angleDelta.y < 0) + barRoot.brightnessMonitor.setBrightness(barRoot.brightnessMonitor.brightness - 0.05); + else if (event.angleDelta.y > 0) + barRoot.brightnessMonitor.setBrightness(barRoot.brightnessMonitor.brightness + 0.05); + // Store the mouse position and start tracking + barLeftSideMouseArea.lastScrollX = event.x; + barLeftSideMouseArea.lastScrollY = event.y; + barLeftSideMouseArea.trackingScroll = true; + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + onPositionChanged: mouse => { + if (barLeftSideMouseArea.trackingScroll) { + const dx = mouse.x - barLeftSideMouseArea.lastScrollX; + const dy = mouse.y - barLeftSideMouseArea.lastScrollY; + if (Math.sqrt(dx * dx + dy * dy) > osdHideMouseMoveThreshold) { + GlobalStates.osdBrightnessOpen = false; + barLeftSideMouseArea.trackingScroll = false; + } + } + } + Item { + // Left section + anchors.fill: parent + implicitHeight: leftSectionRowLayout.implicitHeight + implicitWidth: leftSectionRowLayout.implicitWidth + + 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 { // Content + id: leftSectionRowLayout + anchors.fill: parent + spacing: 10 + + RippleButton { + // Left sidebar button + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + Layout.leftMargin: Appearance.rounding.screenRounding + Layout.fillWidth: false + property real buttonPadding: 5 + implicitWidth: distroIcon.width + buttonPadding * 2 + implicitHeight: distroIcon.height + buttonPadding * 2 + + buttonRadius: Appearance.rounding.full + colBackground: barLeftSideMouseArea.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.sidebarLeftOpen + property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0 + + onPressed: { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + + CustomIcon { + id: distroIcon + anchors.centerIn: parent + width: 19.5 + height: 19.5 + source: Config.options.bar.topLeftIcon == 'distro' ? SystemInfo.distroIcon : "spark-symbolic" + colorize: true + color: Appearance.colors.colOnLayer0 + } + } + + ActiveWindow { + visible: barRoot.useShortenedForm === 0 + Layout.rightMargin: Appearance.rounding.screenRounding + Layout.fillWidth: true + Layout.fillHeight: true + bar: barRoot + } + } + } + } + + RowLayout { // Middle section + id: middleSection + anchors.centerIn: parent + spacing: Config.options?.bar.borderless ? 4 : 8 + + BarGroup { + id: leftCenterGroup + Layout.preferredWidth: barRoot.centerSideModuleWidth + Layout.fillHeight: true + + Resources { + alwaysShowAllResources: barRoot.useShortenedForm === 2 + Layout.fillWidth: barRoot.useShortenedForm === 2 + } + + Media { + visible: barRoot.useShortenedForm < 2 + Layout.fillWidth: true + } + } + + VerticalBarSeparator { + visible: Config.options?.bar.borderless + } + + BarGroup { + id: middleCenterGroup + padding: workspacesWidget.widgetPadding + Layout.fillHeight: true + + Workspaces { + id: workspacesWidget + bar: barRoot + 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 + implicitWidth: rightCenterGroupContent.implicitWidth + implicitHeight: rightCenterGroupContent.implicitHeight + Layout.preferredWidth: barRoot.centerSideModuleWidth + Layout.fillHeight: true + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + BarGroup { + id: rightCenterGroupContent + anchors.fill: parent + + ClockWidget { + showDate: (Config.options.bar.verbose && barRoot.useShortenedForm < 2) + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + + UtilButtons { + visible: (Config.options.bar.verbose && barRoot.useShortenedForm === 0) + Layout.alignment: Qt.AlignVCenter + } + + BatteryIndicator { + visible: (barRoot.useShortenedForm < 2 && UPower.displayDevice.isLaptopBattery) + Layout.alignment: Qt.AlignVCenter + } + } + } + + VerticalBarSeparator { + visible: Config.options.bar.borderless && Config.options.bar.weather.enable + } + } + + MouseArea { // Right side | scroll to change volume + id: barRightSideMouseArea + + anchors.right: parent.right + implicitHeight: Appearance.sizes.baseBarHeight + height: Appearance.sizes.barHeight + width: (barRoot.width - middleSection.width) / 2 + + property bool hovered: false + property real lastScrollX: 0 + property real lastScrollY: 0 + property bool trackingScroll: false + + acceptedButtons: Qt.LeftButton + hoverEnabled: true + propagateComposedEvents: true + onEntered: event => { + barRightSideMouseArea.hovered = true; + } + onExited: event => { + barRightSideMouseArea.hovered = false; + barRightSideMouseArea.trackingScroll = false; + } + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } else if (event.button === Qt.RightButton) { + MprisController.activePlayer.next(); + } + } + // Scroll to change volume + WheelHandler { + onWheel: event => { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + if (event.angleDelta.y < 0) + Audio.sink.audio.volume -= step; + else if (event.angleDelta.y > 0) + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + // Store the mouse position and start tracking + barRightSideMouseArea.lastScrollX = event.x; + barRightSideMouseArea.lastScrollY = event.y; + barRightSideMouseArea.trackingScroll = true; + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + onPositionChanged: mouse => { + if (barRightSideMouseArea.trackingScroll) { + const dx = mouse.x - barRightSideMouseArea.lastScrollX; + const dy = mouse.y - barRightSideMouseArea.lastScrollY; + if (Math.sqrt(dx * dx + dy * dy) > osdHideMouseMoveThreshold) { + GlobalStates.osdVolumeOpen = false; + barRightSideMouseArea.trackingScroll = false; + } + } + } + + Item { + anchors.fill: parent + implicitHeight: rightSectionRowLayout.implicitHeight + implicitWidth: rightSectionRowLayout.implicitWidth + + 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 { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + 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 { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + MaterialSymbol { + text: "mic_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Loader { + active: HyprlandXkb.layoutCodes.length > 1 + visible: active + Layout.rightMargin: indicatorsRowLayout.realSpacing + sourceComponent: StyledText { + text: HyprlandXkb.currentLayoutCode + font.pixelSize: Appearance.font.pixelSize.small + color: rightSidebarButton.colText + } + } + MaterialSymbol { + Layout.rightMargin: indicatorsRowLayout.realSpacing + text: Network.materialSymbol + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + MaterialSymbol { + text: Bluetooth.bluetoothConnected ? "bluetooth_connected" : Bluetooth.bluetoothEnabled ? "bluetooth" : "bluetooth_disabled" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + } + + SysTray { + bar: barRoot + visible: barRoot.useShortenedForm === 0 + Layout.fillWidth: false + Layout.fillHeight: true + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Weather + Loader { + Layout.leftMargin: 8 + Layout.fillHeight: true + active: Config.options.bar.weather.enable + sourceComponent: BarGroup { + implicitHeight: Appearance.sizes.baseBarHeight + WeatherBar {} + } + } + } + } + } + } + + // Round decorators + Loader { + id: roundDecorators + anchors { + left: parent.left + right: parent.right + } + y: Appearance.sizes.barHeight + width: parent.width + height: Appearance.rounding.screenRounding + active: showBarBackground && Config.options.bar.cornerStyle === 0 // Hug + + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + roundDecorators.y: 0 + } + } + + sourceComponent: Item { + implicitHeight: Appearance.rounding.screenRounding + RoundCorner { + id: leftCorner + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + } + + size: 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 + } + size: 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/configs/quickshell/ii/modules/bar/BarGroup.qml b/configs/quickshell/ii/modules/bar/BarGroup.qml new file mode 100644 index 0000000..e2371d1 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/BarGroup.qml @@ -0,0 +1,36 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property real padding: 5 + implicitHeight: Appearance.sizes.baseBarHeight + height: Appearance.sizes.barHeight + implicitWidth: rowLayout.implicitWidth + padding * 2 + default property alias items: rowLayout.children + + Rectangle { + id: background + anchors { + fill: parent + topMargin: 4 + bottomMargin: 4 + } + color: Config.options?.bar.borderless ? "transparent" : Appearance.colors.colLayer1 + radius: Appearance.rounding.small + } + + RowLayout { + id: rowLayout + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + leftMargin: root.padding + rightMargin: root.padding + } + spacing: 4 + + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/bar/BatteryIndicator.qml b/configs/quickshell/ii/modules/bar/BatteryIndicator.qml new file mode 100644 index 0000000..72cc932 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/BatteryIndicator.qml @@ -0,0 +1,95 @@ +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 + 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 + readonly property color batteryLowBackground: Appearance.m3colors.darkmode ? Appearance.m3colors.m3error : Appearance.m3colors.m3errorContainer + readonly property color batteryLowOnBackground: Appearance.m3colors.darkmode ? Appearance.m3colors.m3errorContainer : Appearance.m3colors.m3error + + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: 32 + + RowLayout { + id: rowLayout + + spacing: 4 + anchors.centerIn: parent + + Rectangle { + implicitWidth: (isCharging ? (boltIconLoader?.item?.width ?? 0) : 0) + + Behavior on implicitWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + color: Appearance.colors.colOnLayer1 + text: `${Math.round(percentage * 100)}` + } + + CircularProgress { + enableAnimation: false + Layout.alignment: Qt.AlignVCenter + lineWidth: 2 + value: percentage + size: 26 + secondaryColor: (isLow && !isCharging) ? batteryLowBackground : Appearance.colors.colSecondaryContainer + primaryColor: (isLow && !isCharging) ? batteryLowOnBackground : Appearance.m3colors.m3onSecondaryContainer + fill: (isLow && !isCharging) + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: "battery_full" + iconSize: Appearance.font.pixelSize.normal + color: (isLow && !isCharging) ? batteryLowOnBackground : Appearance.m3colors.m3onSecondaryContainer + } + + } + + } + + Loader { + id: boltIconLoader + active: true + anchors.left: rowLayout.left + anchors.verticalCenter: rowLayout.verticalCenter + + Connections { + target: root + function onIsChargingChanged() { + if (isCharging) boltIconLoader.active = true + } + } + + sourceComponent: MaterialSymbol { + id: boltIcon + + text: "bolt" + iconSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSecondaryContainer + visible: opacity > 0 // Only show when charging + opacity: isCharging ? 1 : 0 // Keep opacity for visibility + onVisibleChanged: { + if (!visible) boltIconLoader.active = false + } + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + } + +} diff --git a/configs/quickshell/ii/modules/bar/CircleUtilButton.qml b/configs/quickshell/ii/modules/bar/CircleUtilButton.qml new file mode 100644 index 0000000..bd80a6c --- /dev/null +++ b/configs/quickshell/ii/modules/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/configs/quickshell/ii/modules/bar/ClockWidget.qml b/configs/quickshell/ii/modules/bar/ClockWidget.qml new file mode 100644 index 0000000..9f45aa0 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/ClockWidget.qml @@ -0,0 +1,41 @@ +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: 32 + + 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.date + } + + } + +} diff --git a/configs/quickshell/ii/modules/bar/Media.qml b/configs/quickshell/ii/modules/bar/Media.qml new file mode 100644 index 0000000..f3e70a8 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/Media.qml @@ -0,0 +1,85 @@ +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: 1000 + 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 + + CircularProgress { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: rowLayout.spacing + lineWidth: 2 + value: activePlayer?.position / activePlayer?.length + size: 26 + secondaryColor: Appearance.colors.colSecondaryContainer + primaryColor: Appearance.m3colors.m3onSecondaryContainer + enableAnimation: false + + 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/configs/quickshell/ii/modules/bar/Resource.qml b/configs/quickshell/ii/modules/bar/Resource.qml new file mode 100644 index 0000000..eb3683d --- /dev/null +++ b/configs/quickshell/ii/modules/bar/Resource.qml @@ -0,0 +1,58 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { + required property string iconName + required property double percentage + property bool shown: true + clip: true + visible: width > 0 && height > 0 + implicitWidth: resourceRowLayout.x < 0 ? 0 : childrenRect.width + implicitHeight: childrenRect.height + + RowLayout { + spacing: 4 + id: resourceRowLayout + x: shown ? 0 : -resourceRowLayout.width + + CircularProgress { + Layout.alignment: Qt.AlignVCenter + lineWidth: 2 + value: percentage + size: 26 + secondaryColor: Appearance.colors.colSecondaryContainer + primaryColor: Appearance.m3colors.m3onSecondaryContainer + enableAnimation: false + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: iconName + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + color: Appearance.colors.colOnLayer1 + text: `${Math.round(percentage * 100)}` + } + + Behavior on x { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/bar/Resources.qml b/configs/quickshell/ii/modules/bar/Resources.qml new file mode 100644 index 0000000..f57372a --- /dev/null +++ b/configs/quickshell/ii/modules/bar/Resources.qml @@ -0,0 +1,47 @@ +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 alwaysShowAllResources: false + implicitWidth: rowLayout.implicitWidth + rowLayout.anchors.leftMargin + rowLayout.anchors.rightMargin + implicitHeight: 32 + + RowLayout { + id: rowLayout + + spacing: 0 + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + + Resource { + iconName: "memory" + percentage: ResourceUsage.memoryUsedPercentage + } + + Resource { + iconName: "swap_horiz" + percentage: ResourceUsage.swapUsedPercentage + shown: (Config.options.bar.resources.alwaysShowSwap && percentage > 0) || + (MprisController.activePlayer?.trackTitle == null) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 4 : 0 + } + + Resource { + iconName: "settings_slow_motion" + percentage: ResourceUsage.cpuUsage + shown: Config.options.bar.resources.alwaysShowCpu || + !(MprisController.activePlayer?.trackTitle?.length > 0) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 4 : 0 + } + + } + +} diff --git a/configs/quickshell/ii/modules/bar/ScrollHint.qml b/configs/quickshell/ii/modules/bar/ScrollHint.qml new file mode 100644 index 0000000..f11ca81 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/ScrollHint.qml @@ -0,0 +1,56 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Revealer { // Scroll hint + id: root + property string icon + property string side: "left" + property string tooltipText: "" + + MouseArea { + anchors.right: root.side === "left" ? parent.right : undefined + anchors.left: root.side === "right" ? parent.left : undefined + implicitWidth: contentColumnLayout.implicitWidth + implicitHeight: contentColumnLayout.implicitHeight + property bool hovered: false + + hoverEnabled: true + onEntered: hovered = true + onExited: hovered = false + acceptedButtons: Qt.NoButton + + // StyledToolTip { + // extraVisibleCondition: tooltipText.length > 0 + // content: tooltipText + // } + + ColumnLayout { + id: contentColumnLayout + anchors.centerIn: parent + spacing: -5 + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: "keyboard_arrow_up" + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: root.icon + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: "keyboard_arrow_down" + iconSize: 14 + color: Appearance.colors.colSubtext + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/bar/SysTray.qml b/configs/quickshell/ii/modules/bar/SysTray.qml new file mode 100644 index 0000000..34919a3 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/SysTray.qml @@ -0,0 +1,47 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.SystemTray + +// TODO: More fancy animation +Item { + id: root + + required property var bar + + height: parent.height + implicitWidth: rowLayout.implicitWidth + Layout.leftMargin: Appearance.rounding.screenRounding + + RowLayout { + id: rowLayout + + anchors.fill: parent + spacing: 15 + + Repeater { + model: SystemTray.items + + SysTrayItem { + required property SystemTrayItem modelData + + bar: root.bar + item: modelData + } + + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colSubtext + text: "โ€ข" + visible: { + SystemTray.items.values.length > 0 + } + } + + } + +} diff --git a/configs/quickshell/ii/modules/bar/SysTrayItem.qml b/configs/quickshell/ii/modules/bar/SysTrayItem.qml new file mode 100644 index 0000000..9696c49 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/SysTrayItem.qml @@ -0,0 +1,72 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +MouseArea { + id: root + + required property var bar + required property SystemTrayItem item + property bool targetMenuOpen: false + property int trayItemWidth: Appearance.font.pixelSize.larger + + acceptedButtons: Qt.LeftButton | Qt.RightButton + Layout.fillHeight: true + implicitWidth: trayItemWidth + onClicked: (event) => { + switch (event.button) { + case Qt.LeftButton: + item.activate(); + break; + case Qt.RightButton: + if (item.hasMenu) menu.open(); + break; + } + event.accepted = true; + } + + QsMenuAnchor { + id: menu + + menu: root.item.menu + anchor.window: bar + anchor.rect.x: root.x + bar.width + anchor.rect.y: root.y + anchor.rect.height: root.height + anchor.edges: Edges.Bottom + } + + IconImage { + id: trayIcon + visible: !Config.options.bar.tray.monochromeIcons + source: root.item.icon + anchors.centerIn: parent + width: parent.width + height: parent.height + } + + Loader { + active: Config.options.bar.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) + } + } + } + +} diff --git a/configs/quickshell/ii/modules/bar/UtilButtons.qml b/configs/quickshell/ii/modules/bar/UtilButtons.qml new file mode 100644 index 0000000..1e32bd5 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/UtilButtons.qml @@ -0,0 +1,157 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +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 + + Process { + id: themeSwitchProcess + running: false + stdout: SplitParser { + onRead: data => console.log("switchwall:", data) + } + stderr: SplitParser { + onRead: data => console.log("switchwall err:", data) + } + onExited: (code, status) => { + console.log("switchwall exited:", code) + } + } + + 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(["quickshell", "-p", Quickshell.shellPath("screenshot.qml")]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "screenshot_region" + 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 => { + const mode = Appearance.m3colors.darkmode ? "light" : "dark" + const wallpaper = Config.options.background.wallpaperPath || `${Quickshell.env("HOME")}/Pictures/Wallpapers/konachan_random_image.png` + themeSwitchProcess.command = ["bash", `${Directories.scriptPath}/colors/switchwall-wrapper.sh`, wallpaper, "--mode", mode] + themeSwitchProcess.running = false + themeSwitchProcess.running = true + } + 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 "settings_slow_motion" + case PowerProfile.Performance: return "local_fire_department" + } + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/bar/Workspaces.qml b/configs/quickshell/ii/modules/bar/Workspaces.qml new file mode 100644 index 0000000..8758fe5 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/Workspaces.qml @@ -0,0 +1,282 @@ +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.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +Item { + required property var bar + property bool borderless: Config.options.bar.borderless + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.screen) + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / Config.options.bar.workspaces.shown) + property list workspaceOccupied: [] + property int widgetPadding: 4 + property int workspaceButtonWidth: 26 + 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) % Config.options.bar.workspaces.shown + + // Function to update workspaceOccupied + function updateWorkspaceOccupied() { + workspaceOccupied = Array.from({ length: Config.options.bar.workspaces.shown }, (_, i) => { + return Hyprland.workspaces.values.some(ws => ws.id === workspaceGroup * Config.options.bar.workspaces.shown + i + 1); + }) + } + + // Initialize workspaceOccupied when the component is created + Component.onCompleted: updateWorkspaceOccupied() + + // Listen for changes in Hyprland.workspaces.values + Connections { + target: Hyprland.workspaces + function onValuesChanged() { + updateWorkspaceOccupied(); + } + } + + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: 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 + RowLayout { + id: rowLayout + z: 1 + + spacing: 0 + anchors.fill: parent + implicitHeight: Appearance.sizes.barHeight + + Repeater { + model: Config.options.bar.workspaces.shown + + Rectangle { + z: 1 + implicitWidth: workspaceButtonWidth + implicitHeight: workspaceButtonWidth + radius: Appearance.rounding.full + property var leftOccupied: (workspaceOccupied[index-1] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index)) + property var rightOccupied: (workspaceOccupied[index+1] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index+2)) + property var radiusLeft: leftOccupied ? 0 : Appearance.rounding.full + property var radiusRight: rightOccupied ? 0 : Appearance.rounding.full + + topLeftRadius: radiusLeft + bottomLeftRadius: radiusLeft + topRightRadius: radiusRight + bottomRightRadius: radiusRight + + 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 radiusLeft { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on radiusRight { + 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 + property real activeWorkspaceMargin: 2 + implicitHeight: workspaceButtonWidth - activeWorkspaceMargin * 2 + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + anchors.verticalCenter: parent.verticalCenter + + property real idx1: workspaceIndexInGroup + property real idx2: workspaceIndexInGroup + x: Math.min(idx1, idx2) * workspaceButtonWidth + activeWorkspaceMargin + implicitWidth: Math.abs(idx1 - idx2) * workspaceButtonWidth + workspaceButtonWidth - activeWorkspaceMargin * 2 + + Behavior on activeWorkspaceMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on idx1 { // Leading anim + NumberAnimation { + duration: 100 + easing.type: Easing.OutSine + } + } + Behavior on idx2 { // Following anim + NumberAnimation { + duration: 300 + easing.type: Easing.OutSine + } + } + } + + // Workspaces - numbers + RowLayout { + id: rowLayoutNumbers + z: 3 + + spacing: 0 + anchors.fill: parent + implicitHeight: Appearance.sizes.barHeight + + Repeater { + model: Config.options.bar.workspaces.shown + + Button { + id: button + property int workspaceValue: workspaceGroup * Config.options.bar.workspaces.shown + index + 1 + Layout.fillHeight: true + onPressed: Hyprland.dispatch(`workspace ${workspaceValue}`) + width: workspaceButtonWidth + + 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: GlobalStates.workspaceShowNumbers + || ((Config.options?.bar.workspaces.alwaysShowNumbers && (!Config.options?.bar.workspaces.showAppIcons || !workspaceButtonBackground.biggestWindow || GlobalStates.workspaceShowNumbers)) + || (GlobalStates.workspaceShowNumbers && !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) + text: `${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 + || GlobalStates.workspaceShowNumbers + || (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 && !GlobalStates.workspaceShowNumbers && 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: (!GlobalStates.workspaceShowNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + anchors.rightMargin: (!GlobalStates.workspaceShowNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + + source: workspaceButtonBackground.mainAppIconSource + implicitSize: (!GlobalStates.workspaceShowNumbers && 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/configs/quickshell/ii/modules/bar/qmldir b/configs/quickshell/ii/modules/bar/qmldir new file mode 100644 index 0000000..21a1867 --- /dev/null +++ b/configs/quickshell/ii/modules/bar/qmldir @@ -0,0 +1,14 @@ +module qs.modules.bar + +ActiveWindow 1.0 ActiveWindow.qml +BarGroup 1.0 BarGroup.qml +Bar 1.0 Bar.qml +BatteryIndicator 1.0 BatteryIndicator.qml +BrightnessIndicator 1.0 BrightnessIndicator.qml +ClockWidget 1.0 ClockWidget.qml +HyprlandWorkspaces 1.0 HyprlandWorkspaces.qml +MediaIndicator 1.0 MediaIndicator.qml +NetworkIndicator 1.0 NetworkIndicator.qml +NotificationIndicator 1.0 NotificationIndicator.qml +SystemTray 1.0 SystemTray.qml +VolumeIndicator 1.0 VolumeIndicator.qml diff --git a/configs/quickshell/ii/modules/bar/weather/WeatherBar.qml b/configs/quickshell/ii/modules/bar/weather/WeatherBar.qml new file mode 100644 index 0000000..363d9ba --- /dev/null +++ b/configs/quickshell/ii/modules/bar/weather/WeatherBar.qml @@ -0,0 +1,60 @@ +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 real margin: 10 + property bool hovered: false + implicitWidth: rowLayout.implicitWidth + margin * 2 + implicitHeight: rowLayout.implicitHeight + + hoverEnabled: true + + RowLayout { + id: rowLayout + anchors.centerIn: parent + + MaterialSymbol { + fill: 0 + text: WeatherIcons.codeToName[Weather.data.wCode] + 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 + } + } + + LazyLoader { + id: popupLoader + active: root.containsMouse + + component: PopupWindow { + id: popupWindow + visible: true + implicitWidth: weatherPopup.implicitWidth + implicitHeight: weatherPopup.implicitHeight + anchor.item: root + anchor.edges: Edges.Top + anchor.rect.x: (root.implicitWidth - popupWindow.implicitWidth) / 2 + anchor.rect.y: Config.options.bar.bottom ? + (-weatherPopup.implicitHeight - 15) : + (root.implicitHeight + 15 ) + color: "transparent" + WeatherPopup { + id: weatherPopup + } + } + } +} diff --git a/configs/quickshell/ii/modules/bar/weather/WeatherCard.qml b/configs/quickshell/ii/modules/bar/weather/WeatherCard.qml new file mode 100644 index 0000000..a85ed8c --- /dev/null +++ b/configs/quickshell/ii/modules/bar/weather/WeatherCard.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts + +import qs.modules.common +import qs.modules.common.widgets + +Rectangle { + id: root + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: columnLayout.implicitWidth * 2 + implicitHeight: columnLayout.implicitHeight * 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 + } + StyledText { + id: title + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer1 + } + } + StyledText { + id: value + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer1 + } + } +} diff --git a/configs/quickshell/ii/modules/bar/weather/WeatherIcons.qml b/configs/quickshell/ii/modules/bar/weather/WeatherIcons.qml new file mode 100644 index 0000000..bd74d4e --- /dev/null +++ b/configs/quickshell/ii/modules/bar/weather/WeatherIcons.qml @@ -0,0 +1,59 @@ +pragma Singleton + +import Quickshell + +Singleton { + // credits: calestia + // this snippet is taken from + // https://github.com/caelestia-dots/shell + readonly property var codeToName: ({ + "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" + }) +} diff --git a/configs/quickshell/ii/modules/bar/weather/WeatherPopup.qml b/configs/quickshell/ii/modules/bar/weather/WeatherPopup.qml new file mode 100644 index 0000000..827abea --- /dev/null +++ b/configs/quickshell/ii/modules/bar/weather/WeatherPopup.qml @@ -0,0 +1,97 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + readonly property real margin: 10 + implicitWidth: columnLayout.implicitWidth + margin * 2 + implicitHeight: columnLayout.implicitHeight + margin * 2 + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.small + border.width: 1 + border.color: Appearance.colors.colLayer0Border + clip: true + + ColumnLayout { + id: columnLayout + spacing: 5 + anchors.centerIn: root + implicitWidth: Math.max(header.implicitWidth, gridLayout.implicitWidth) + implicitHeight: gridLayout.implicitHeight + + // Header + RowLayout { + id: header + spacing: 5 + Layout.fillWidth: parent + Layout.alignment: Qt.AlignHCenter + MaterialSymbol { + fill: 0 + text: "location_on" + iconSize: Appearance.font.pixelSize.huge + } + + StyledText { + text: Weather.data.city + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + color: Appearance.colors.colOnLayer0 + } + } + + // 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/configs/quickshell/ii/modules/bar/weather/qmldir b/configs/quickshell/ii/modules/bar/weather/qmldir new file mode 100644 index 0000000..1ae861e --- /dev/null +++ b/configs/quickshell/ii/modules/bar/weather/qmldir @@ -0,0 +1,5 @@ +module qs.modules.bar.weather + +WeatherBar 1.0 WeatherBar.qml +WeatherCard 1.0 WeatherCard.qml +WeatherIcons 1.0 WeatherIcons.qml diff --git a/configs/quickshell/ii/modules/cheatsheet/Cheatsheet.qml b/configs/quickshell/ii/modules/cheatsheet/Cheatsheet.qml new file mode 100644 index 0000000..6009392 --- /dev/null +++ b/configs/quickshell/ii/modules/cheatsheet/Cheatsheet.qml @@ -0,0 +1,236 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property var tabButtonList: [ + { + "icon": "keyboard", + "name": Translation.tr("Keybinds") + }, + { + "icon": "experiment", + "name": Translation.tr("Elements") + }, + ] + property int selectedTab: 0 + + Loader { + id: cheatsheetLoader + active: false + + sourceComponent: PanelWindow { // Window + id: cheatsheetRoot + visible: cheatsheetLoader.active + + anchors { + top: true + bottom: true + left: true + right: true + } + + function hide() { + cheatsheetLoader.active = false; + } + exclusiveZone: 0 + implicitWidth: cheatsheetBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: cheatsheetBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:cheatsheet" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: cheatsheetBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [cheatsheetRoot] + active: cheatsheetRoot.visible + onCleared: () => { + if (!active) + cheatsheetRoot.hide(); + } + } + + // Background + StyledRectangularShadow { + target: cheatsheetBackground + } + Rectangle { + id: cheatsheetBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.windowRounding + property real padding: 30 + implicitWidth: cheatsheetColumnLayout.implicitWidth + padding * 2 + implicitHeight: cheatsheetColumnLayout.implicitHeight + padding * 2 + + Keys.onPressed: event => { // Esc to close + if (event.key === Qt.Key_Escape) { + cheatsheetRoot.hide(); + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1); + event.accepted = true; + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0); + event.accepted = true; + } else if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length; + event.accepted = true; + } else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length; + event.accepted = true; + } + } + } + + RippleButton { // Close button + id: closeButton + focus: cheatsheetRoot.visible + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.full + anchors { + top: parent.top + right: parent.right + topMargin: 20 + rightMargin: 20 + } + + onClicked: { + cheatsheetRoot.hide(); + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.title + text: "close" + } + } + + ColumnLayout { // Real content + id: cheatsheetColumnLayout + anchors.centerIn: parent + spacing: 20 + + StyledText { + id: cheatsheetTitle + Layout.alignment: Qt.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.title + text: Translation.tr("Cheat sheet") + } + PrimaryTabBar { // Tab strip + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex; + } + } + + SwipeView { // Content pages + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + Behavior on implicitWidth { + id: contentWidthBehavior + enabled: false + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + id: contentHeightBehavior + enabled: false + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + currentIndex: tabBar.externalTrackedTab + onCurrentIndexChanged: { + contentWidthBehavior.enabled = true; + contentHeightBehavior.enabled = true; + tabBar.enableIndicatorAnimation = true; + root.selectedTab = currentIndex; + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + CheatsheetKeybinds {} + CheatsheetPeriodicTable {} + } + } + } + } + } + + IpcHandler { + target: "cheatsheet" + + function toggle(): void { + cheatsheetLoader.active = !cheatsheetLoader.active; + } + + function close(): void { + cheatsheetLoader.active = false; + } + + function open(): void { + cheatsheetLoader.active = true; + } + } + + GlobalShortcut { + name: "cheatsheetToggle" + description: "Toggles cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = !cheatsheetLoader.active; + } + } + + GlobalShortcut { + name: "cheatsheetOpen" + description: "Opens cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = true; + } + } + + GlobalShortcut { + name: "cheatsheetClose" + description: "Closes cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = false; + } + } +} diff --git a/configs/quickshell/ii/modules/cheatsheet/CheatsheetKeybinds.qml b/configs/quickshell/ii/modules/cheatsheet/CheatsheetKeybinds.qml new file mode 100644 index 0000000..e0e8ce6 --- /dev/null +++ b/configs/quickshell/ii/modules/cheatsheet/CheatsheetKeybinds.qml @@ -0,0 +1,144 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +Item { + id: root + readonly property var keybinds: HyprlandKeybinds.keybinds + property real spacing: 20 + property real titleSpacing: 7 + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + + property var keyBlacklist: ["Super_L"] + property var keySubstitutions: ({ + "Super": "๓ฐ–ณ", + "mouse_up": "Scroll โ†“", // ikr, weird + "mouse_down": "Scroll โ†‘", // trust me bro + "mouse:272": "LMB", + "mouse:273": "RMB", + "mouse:275": "MouseBack", + "Slash": "/", + "Hash": "#", + "Return": "Enter", + // "Shift": "๏ข", + }) + + RowLayout { // Keybind columns + id: rowLayout + spacing: root.spacing + Repeater { + model: keybinds.children + + delegate: ColumnLayout { // Keybind sections + spacing: root.spacing + required property var modelData + Layout.alignment: Qt.AlignTop + Repeater { + model: modelData.children + + delegate: Item { // Section with real keybinds + required property var modelData + implicitWidth: sectionColumnLayout.implicitWidth + implicitHeight: sectionColumnLayout.implicitHeight + ColumnLayout { + id: sectionColumnLayout + anchors.centerIn: parent + spacing: root.titleSpacing + StyledText { + id: sectionTitle + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnLayer0 + text: modelData.name + } + + GridLayout { + id: keybindGrid + columns: 2 + Repeater { + model: { + var result = []; + for (var i = 0; i < modelData.keybinds.length; i++) { + const keybind = modelData.keybinds[i]; + result.push({ + "type": "keys", + "mods": keybind.mods, + "key": keybind.key, + }); + result.push({ + "type": "comment", + "comment": keybind.comment, + }); + } + return result; + } + delegate: Item { + required property var modelData + implicitWidth: keybindLoader.implicitWidth + implicitHeight: keybindLoader.implicitHeight + + Loader { + id: keybindLoader + sourceComponent: (modelData.type === "keys") ? keysComponent : commentComponent + } + + Component { + id: keysComponent + RowLayout { + spacing: 4 + Repeater { + model: modelData.mods + delegate: KeyboardKey { + required property var modelData + key: keySubstitutions[modelData] || modelData + } + } + StyledText { + id: keybindPlus + visible: !keyBlacklist.includes(modelData.key) && modelData.mods.length > 0 + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + id: keybindKey + visible: !keyBlacklist.includes(modelData.key) + key: keySubstitutions[modelData.key] || modelData.key + color: Appearance.colors.colOnLayer0 + } + } + } + + Component { + id: commentComponent + Item { + id: commentItem + implicitWidth: commentText.implicitWidth + 8 * 2 + implicitHeight: commentText.implicitHeight + + StyledText { + id: commentText + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + text: modelData.comment + } + } + } + } + + } + } + } + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/cheatsheet/CheatsheetPeriodicTable.qml b/configs/quickshell/ii/modules/cheatsheet/CheatsheetPeriodicTable.qml new file mode 100644 index 0000000..a0a8ecf --- /dev/null +++ b/configs/quickshell/ii/modules/cheatsheet/CheatsheetPeriodicTable.qml @@ -0,0 +1,68 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "periodic_table.js" as PTable +import QtQuick +import QtQuick.Layouts + +Item { + id: root + readonly property var elements: PTable.elements + readonly property var series: PTable.series + property real spacing: 6 + implicitWidth: mainLayout.implicitWidth + implicitHeight: mainLayout.implicitHeight + + ColumnLayout { + id: mainLayout + spacing: root.spacing + + Repeater { // Main table rows + model: root.elements + + delegate: RowLayout { // Table cells + id: tableRow + spacing: root.spacing + required property var modelData + + Repeater { + model: tableRow.modelData + delegate: ElementTile { + required property var modelData + element: modelData + } + + } + } + + } + + Item { + id: gap + implicitHeight: 20 + } + + Repeater { // Main table rows + model: root.series + + delegate: RowLayout { // Table cells + id: seriesTableRow + spacing: root.spacing + required property var modelData + + Repeater { + model: seriesTableRow.modelData + delegate: ElementTile { + required property var modelData + element: modelData + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/cheatsheet/ElementTile.qml b/configs/quickshell/ii/modules/cheatsheet/ElementTile.qml new file mode 100644 index 0000000..70e7b4d --- /dev/null +++ b/configs/quickshell/ii/modules/cheatsheet/ElementTile.qml @@ -0,0 +1,55 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: root + required property var element + opacity: element.type != "empty" ? 1 : 0 + implicitHeight: 60 + implicitWidth: 60 + colBackground: Appearance.colors.colLayer2 + buttonRadius: Appearance.rounding.small + + Rectangle { + anchors { + top: parent.top + left: parent.left + topMargin: 4 + leftMargin: 4 + } + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.full + implicitWidth: Math.max(20, elementNumber.implicitWidth) + implicitHeight: Math.max(20, elementNumber.implicitHeight) + width: height + + StyledText { + id: elementNumber + anchors.centerIn: parent + color: Appearance.colors.colOnLayer2 + text: root.element.number + font.pixelSize: Appearance.font.pixelSize.smallest + } + } + + StyledText { + id: elementSymbol + anchors.centerIn: parent + color: Appearance.colors.colSecondary + font.pixelSize: Appearance.font.pixelSize.huge + text: root.element.symbol + } + + StyledText { + id: elementName + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 4 + } + font.pixelSize: Appearance.font.pixelSize.smallest + color: Appearance.colors.colOnLayer2 + text: root.element.name + } +} diff --git a/configs/quickshell/ii/modules/cheatsheet/periodic_table.js b/configs/quickshell/ii/modules/cheatsheet/periodic_table.js new file mode 100644 index 0000000..45d69cc --- /dev/null +++ b/configs/quickshell/ii/modules/cheatsheet/periodic_table.js @@ -0,0 +1,196 @@ +// List of rows +const elements = [ + [ + { name: 'Hydrogen', symbol: 'H', number: 1, weight: 1.01, type: 'nonmetal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Helium', symbol: 'He', number: 2, weight: 4.00, type: 'noblegas' }, + ], + [ + { name: 'Lithium', symbol: 'Li', number: 3, weight: 6.94, type: 'metal' }, + { name: 'Beryllium', symbol: 'Be', number: 4, weight: 9.01, type: 'metal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Boron', symbol: 'B', number: 5, weight: 10.81, type: 'nonmetal' }, + { name: 'Carbon', symbol: 'C', number: 6, weight: 12.01, type: 'nonmetal' }, + { name: 'Nitrogen', symbol: 'N', number: 7, weight: 14.01, type: 'nonmetal' }, + { name: 'Oxygen', symbol: 'O', number: 8, weight: 16, type: 'nonmetal' }, + { name: 'Fluorine', symbol: 'F', number: 9, weight: 19, type: 'nonmetal' }, + { name: 'Neon', symbol: 'Ne', number: 10, weight: 20.18, type: 'noblegas' }, + + + ], + [ + { name: 'Sodium', symbol: 'Na', number: 11, weight: 22.99, type: 'metal' }, + { name: 'Magnesium', symbol: 'Mg', number: 12, weight: 24.31, type: 'metal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Aluminum', symbol: 'Al', number: 13, weight: 26.98, type: 'metal' }, + { name: 'Silicon', symbol: 'Si', number: 14, weight: 28.09, type: 'nonmetal' }, + { name: 'Phosphorus', symbol: 'P', number: 15, weight: 30.97, type: 'nonmetal' }, + { name: 'Sulfur', symbol: 'S', number: 16, weight: 32.07, type: 'nonmetal' }, + { name: 'Chlorine', symbol: 'Cl', number: 17, weight: 35.45, type: 'nonmetal' }, + { name: 'Argon', symbol: 'Ar', number: 18, weight: 39.95, type: 'noblegas' }, + ], + [ + { name: 'Potassium', symbol: 'K', number: 19, weight: 39.098, type: 'metal' }, + { name: 'Calcium', symbol: 'Ca', number: 20, weight: 40.078, type: 'metal' }, + { name: 'Scandium', symbol: 'Sc', number: 21, weight: 44.956, type: 'metal' }, + { name: 'Titanium', symbol: 'Ti', number: 22, weight: 47.87, type: 'metal' }, + { name: 'Vanadium', symbol: 'V', number: 23, weight: 50.94, type: 'metal' }, + { name: 'Chromium', symbol: 'Cr', number: 24, weight: 52, type: 'metal'/*, icon: 'chromium-browser'*/ }, + { name: 'Manganese', symbol: 'Mn', number: 25, weight: 54.94, type: 'metal' }, + { name: 'Iron', symbol: 'Fe', number: 26, weight: 55.85, type: 'metal' }, + { name: 'Cobalt', symbol: 'Co', number: 27, weight: 58.93, type: 'metal' }, + { name: 'Nickel', symbol: 'Ni', number: 28, weight: 58.69, type: 'metal' }, + { name: 'Copper', symbol: 'Cu', number: 29, weight: 63.55, type: 'metal' }, + { name: 'Zinc', symbol: 'Zn', number: 30, weight: 65.38, type: 'metal' }, + { name: 'Gallium', symbol: 'Ga', number: 31, weight: 69.72, type: 'metal' }, + { name: 'Germanium', symbol: 'Ge', number: 32, weight: 72.63, type: 'metal' }, + { name: 'Arsenic', symbol: 'As', number: 33, weight: 74.92, type: 'nonmetal' }, + { name: 'Selenium', symbol: 'Se', number: 34, weight: 78.96, type: 'nonmetal' }, + { name: 'Bromine', symbol: 'Br', number: 35, weight: 79.904, type: 'nonmetal' }, + { name: 'Krypton', symbol: 'Kr', number: 36, weight: 83.8, type: 'noblegas' }, + ], + [ + { name: 'Rubidium', symbol: 'Rb', number: 37, weight: 85.47, type: 'metal' }, + { name: 'Strontium', symbol: 'Sr', number: 38, weight: 87.62, type: 'metal' }, + { name: 'Yttrium', symbol: 'Y', number: 39, weight: 88.91, type: 'metal' }, + { name: 'Zirconium', symbol: 'Zr', number: 40, weight: 91.22, type: 'metal' }, + { name: 'Niobium', symbol: 'Nb', number: 41, weight: 92.91, type: 'metal' }, + { name: 'Molybdenum', symbol: 'Mo', number: 42, weight: 95.94, type: 'metal' }, + { name: 'Technetium', symbol: 'Tc', number: 43, weight: 98, type: 'metal' }, + { name: 'Ruthenium', symbol: 'Ru', number: 44, weight: 101.07, type: 'metal' }, + { name: 'Rhodium', symbol: 'Rh', number: 45, weight: 102.91, type: 'metal' }, + { name: 'Palladium', symbol: 'Pd', number: 46, weight: 106.42, type: 'metal' }, + { name: 'Silver', symbol: 'Ag', number: 47, weight: 107.87, type: 'metal' }, + { name: 'Cadmium', symbol: 'Cd', number: 48, weight: 112.41, type: 'metal' }, + { name: 'Indium', symbol: 'In', number: 49, weight: 114.82, type: 'metal' }, + { name: 'Tin', symbol: 'Sn', number: 50, weight: 118.71, type: 'metal' }, + { name: 'Antimony', symbol: 'Sb', number: 51, weight: 121.76, type: 'metal' }, + { name: 'Tellurium', symbol: 'Te', number: 52, weight: 127.6, type: 'nonmetal' }, + { name: 'Iodine', symbol: 'I', number: 53, weight: 126.9, type: 'nonmetal' }, + { name: 'Xenon', symbol: 'Xe', number: 54, weight: 131.29, type: 'noblegas' }, + ], + [ + { name: 'Cesium', symbol: 'Cs', number: 55, weight: 132.91, type: 'metal' }, + { name: 'Barium', symbol: 'Ba', number: 56, weight: 137.33, type: 'metal' }, + { name: 'Lanthanum', symbol: 'La', number: 57, weight: 138.91, type: 'lanthanum' }, + { name: 'Hafnium', symbol: 'Hf', number: 72, weight: 178.49, type: 'metal' }, + { name: 'Tantalum', symbol: 'Ta', number: 73, weight: 180.95, type: 'metal' }, + { name: 'Tungsten', symbol: 'W', number: 74, weight: 183.84, type: 'metal' }, + { name: 'Rhenium', symbol: 'Re', number: 75, weight: 186.21, type: 'metal' }, + { name: 'Osmium', symbol: 'Os', number: 76, weight: 190.23, type: 'metal' }, + { name: 'Iridium', symbol: 'Ir', number: 77, weight: 192.22, type: 'metal' }, + { name: 'Platinum', symbol: 'Pt', number: 78, weight: 195.09, type: 'metal' }, + { name: 'Gold', symbol: 'Au', number: 79, weight: 196.97, type: 'metal' }, + { name: 'Mercury', symbol: 'Hg', number: 80, weight: 200.59, type: 'metal' }, + { name: 'Thallium', symbol: 'Tl', number: 81, weight: 204.38, type: 'metal' }, + { name: 'Lead', symbol: 'Pb', number: 82, weight: 207.2, type: 'metal' }, + { name: 'Bismuth', symbol: 'Bi', number: 83, weight: 208.98, type: 'metal' }, + { name: 'Polonium', symbol: 'Po', number: 84, weight: 209, type: 'metal' }, + { name: 'Astatine', symbol: 'At', number: 85, weight: 210, type: 'nonmetal' }, + { name: 'Radon', symbol: 'Rn', number: 86, weight: 222, type: 'noblegas' }, + ], + [ + { name: 'Francium', symbol: 'Fr', number: 87, weight: 223, type: 'metal' }, + { name: 'Radium', symbol: 'Ra', number: 88, weight: 226, type: 'metal' }, + { name: 'Actinium', symbol: 'Ac', number: 89, weight: 227, type: 'actinium' }, + { name: 'Rutherfordium', symbol: 'Rf', number: 104, weight: 267, type: 'metal' }, + { name: 'Dubnium', symbol: 'Db', number: 105, weight: 268, type: 'metal' }, + { name: 'Seaborgium', symbol: 'Sg', number: 106, weight: 271, type: 'metal' }, + { name: 'Bohrium', symbol: 'Bh', number: 107, weight: 272, type: 'metal' }, + { name: 'Hassium', symbol: 'Hs', number: 108, weight: 277, type: 'metal' }, + { name: 'Meitnerium', symbol: 'Mt', number: 109, weight: 278, type: 'metal' }, + { name: 'Darmstadtium', symbol: 'Ds', number: 110, weight: 281, type: 'metal' }, + { name: 'Roentgenium', symbol: 'Rg', number: 111, weight: 280, type: 'metal' }, + { name: 'Copernicium', symbol: 'Cn', number: 112, weight: 285, type: 'metal' }, + { name: 'Nihonium', symbol: 'Nh', number: 113, weight: 286, type: 'metal' }, + { name: 'Flerovium', symbol: 'Fl', number: 114, weight: 289, type: 'metal' }, + { name: 'Moscovium', symbol: 'Mc', number: 115, weight: 290, type: 'metal' }, + { name: 'Livermorium', symbol: 'Lv', number: 116, weight: 293, type: 'metal' }, + { name: 'Tennessine', symbol: 'Ts', number: 117, weight: 294, type: 'metal' }, + { name: 'Oganesson', symbol: 'Og', number: 118, weight: 294, type: 'noblegas' }, + ], +] + +const series = [ + [ + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Cerium', symbol: 'Ce', number: 58, weight: 140.12, type: 'lanthanum' }, + { name: 'Praseodymium', symbol: 'Pr', number: 59, weight: 140.91, type: 'lanthanum' }, + { name: 'Neodymium', symbol: 'Nd', number: 60, weight: 144.24, type: 'lanthanum' }, + { name: 'Promethium', symbol: 'Pm', number: 61, weight: 145, type: 'lanthanum' }, + { name: 'Samarium', symbol: 'Sm', number: 62, weight: 150.36, type: 'lanthanum' }, + { name: 'Europium', symbol: 'Eu', number: 63, weight: 151.96, type: 'lanthanum' }, + { name: 'Gadolinium', symbol: 'Gd', number: 64, weight: 157.25, type: 'lanthanum' }, + { name: 'Terbium', symbol: 'Tb', number: 65, weight: 158.93, type: 'lanthanum' }, + { name: 'Dysprosium', symbol: 'Dy', number: 66, weight: 162.5, type: 'lanthanum' }, + { name: 'Holmium', symbol: 'Ho', number: 67, weight: 164.93, type: 'lanthanum' }, + { name: 'Erbium', symbol: 'Er', number: 68, weight: 167.26, type: 'lanthanum' }, + { name: 'Thulium', symbol: 'Tm', number: 69, weight: 168.93, type: 'lanthanum' }, + { name: 'Ytterbium', symbol: 'Yb', number: 70, weight: 173.04, type: 'lanthanum' }, + { name: 'Lutetium', symbol: 'Lu', number: 71, weight: 174.97, type: 'lanthanum' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + ], + [ + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Thorium', symbol: 'Th', number: 90, weight: 232.04, type: 'actinium' }, + { name: 'Protactinium', symbol: 'Pa', number: 91, weight: 231.04, type: 'actinium' }, + { name: 'Uranium', symbol: 'U', number: 92, weight: 238.03, type: 'actinium' }, + { name: 'Neptunium', symbol: 'Np', number: 93, weight: 237, type: 'actinium' }, + { name: 'Plutonium', symbol: 'Pu', number: 94, weight: 244, type: 'actinium' }, + { name: 'Americium', symbol: 'Am', number: 95, weight: 243, type: 'actinium' }, + { name: 'Curium', symbol: 'Cm', number: 96, weight: 247, type: 'actinium' }, + { name: 'Berkelium', symbol: 'Bk', number: 97, weight: 247, type: 'actinium' }, + { name: 'Californium', symbol: 'Cf', number: 98, weight: 251, type: 'actinium' }, + { name: 'Einsteinium', symbol: 'Es', number: 99, weight: 252, type: 'actinium' }, + { name: 'Fermium', symbol: 'Fm', number: 100, weight: 257, type: 'actinium' }, + { name: 'Mendelevium', symbol: 'Md', number: 101, weight: 258, type: 'actinium' }, + { name: 'Nobelium', symbol: 'No', number: 102, weight: 259, type: 'actinium' }, + { name: 'Lawrencium', symbol: 'Lr', number: 103, weight: 262, type: 'actinium' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + ], +]; + +const niceTypes = { + 'metal': "Metal", + 'nonmetal': "Nonmetal", + 'noblegas': "Noble gas", + 'lanthanum': "Lanthanum", + 'actinium': "Actinium" +} diff --git a/configs/quickshell/ii/modules/common/Appearance.qml b/configs/quickshell/ii/modules/common/Appearance.qml new file mode 100644 index 0000000..9c93748 --- /dev/null +++ b/configs/quickshell/ii/modules/common/Appearance.qml @@ -0,0 +1,316 @@ +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 + + // Extremely conservative transparency values for consistency and readability + property real transparency: Config.options?.appearance.transparency ? (m3colors.darkmode ? 0.1 : 0.07) : 0 + property real contentTransparency: Config.options?.appearance.transparency ? (m3colors.darkmode ? 0.55 : 0.55) : 0 + + m3colors: QtObject { + property bool darkmode: false + property bool transparent: false + property color m3primary_paletteKeyColor: "#91689E" + property color m3secondary_paletteKeyColor: "#837186" + property color m3tertiary_paletteKeyColor: "#9D6A67" + property color m3neutral_paletteKeyColor: "#7C757B" + property color m3neutral_variant_paletteKeyColor: "#7D747D" + property color m3background: "#161217" + property color m3onBackground: "#EAE0E7" + property color m3surface: "#161217" + property color m3surfaceDim: "#161217" + property color m3surfaceBright: "#3D373D" + property color m3surfaceContainerLowest: "#110D12" + property color m3surfaceContainerLow: "#1F1A1F" + property color m3surfaceContainer: "#231E23" + property color m3surfaceContainerHigh: "#2D282E" + property color m3surfaceContainerHighest: "#383339" + property color m3onSurface: "#EAE0E7" + property color m3surfaceVariant: "#4C444D" + property color m3onSurfaceVariant: "#CFC3CD" + property color m3inverseSurface: "#EAE0E7" + property color m3inverseOnSurface: "#342F34" + property color m3outline: "#988E97" + property color m3outlineVariant: "#4C444D" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#E5B6F2" + property color m3primary: "#E5B6F2" + property color m3onPrimary: "#452152" + property color m3primaryContainer: "#5D386A" + property color m3onPrimaryContainer: "#F9D8FF" + property color m3inversePrimary: "#775084" + property color m3secondary: "#D5C0D7" + property color m3onSecondary: "#392C3D" + property color m3secondaryContainer: "#534457" + property color m3onSecondaryContainer: "#F2DCF3" + property color m3tertiary: "#F5B7B3" + property color m3onTertiary: "#4C2523" + property color m3tertiaryContainer: "#BA837F" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#FFB4AB" + property color m3onError: "#690005" + property color m3errorContainer: "#93000A" + property color m3onErrorContainer: "#FFDAD6" + property color m3primaryFixed: "#F9D8FF" + property color m3primaryFixedDim: "#E5B6F2" + property color m3onPrimaryFixed: "#2E0A3C" + property color m3onPrimaryFixedVariant: "#5D386A" + property color m3secondaryFixed: "#F2DCF3" + property color m3secondaryFixedDim: "#D5C0D7" + property color m3onSecondaryFixed: "#241727" + property color m3onSecondaryFixedVariant: "#514254" + property color m3tertiaryFixed: "#FFDAD7" + property color m3tertiaryFixedDim: "#F5B7B3" + property color m3onTertiaryFixed: "#331110" + property color m3onTertiaryFixedVariant: "#663B39" + 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.transparency), 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(ColorUtils.mix(m3colors.m3surfaceContainerLow, m3colors.m3background, 0.8), root.contentTransparency); + property color colOnLayer1: m3colors.m3onSurfaceVariant; + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45); + property color colLayer2: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainer, m3colors.m3surfaceContainerHigh, 0.1), root.contentTransparency) + property color colOnLayer2: m3colors.m3onSurface; + property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4); + property color colLayer3: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainerHigh, m3colors.m3onSurface, 0.96), root.contentTransparency) + property color colOnLayer3: m3colors.m3onSurface; + 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 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 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, colLayer1Hover, 0.7) + property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colLayer1Active, 0.6) + property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer + property color colSecondary: m3colors.m3secondary + 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, colLayer1Active, 0.54) + 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 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 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 colOutlineVariant: m3colors.m3outlineVariant + } + + 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: "Rubik" + property string title: "Gabarito" + property string iconMaterial: "Material Symbols Rounded" + property string iconNerd: "SpaceMono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property QtObject pixelSize: QtObject { + property int smallest: 10 + property int smaller: 12 + 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 Component colorAnimation: Component { + ColorAnimation { + 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 { + 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 { + 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 { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + } + + property QtObject clickBounce: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveFastSpatial + property int velocity: 850 + property Component numberAnimation: Component { NumberAnimation { + duration: root.animation.clickBounce.duration + easing.type: root.animation.clickBounce.type + easing.bezierCurve: root.animation.clickBounce.bezierCurve + }} + } + property QtObject scroll: QtObject { + property int duration: 400 + 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 + Appearance.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 sidebarWidth: 460 + property real sidebarWidthExtended: 750 + property real osdWidth: 200 + property real mediaControlsWidth: 440 + property real mediaControlsHeight: 160 + property real notificationPopupWidth: 410 + property real searchWidthCollapsed: 260 + property real searchWidth: 450 + property real hyprlandGapsOut: 5 + property real elevationMargin: 10 + property real fabShadowRadius: 5 + property real fabHoveredShadowRadius: 7 + } + + syntaxHighlightingTheme: Appearance.m3colors.darkmode ? "Monokai" : "ayu Light" +} diff --git a/configs/quickshell/ii/modules/common/Config.qml b/configs/quickshell/ii/modules/common/Config.qml new file mode 100644 index 0000000..e448a39 --- /dev/null +++ b/configs/quickshell/ii/modules/common/Config.qml @@ -0,0 +1,272 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: 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; + } + + FileView { + path: root.filePath + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + property JsonObject policies: JsonObject { + property int ai: 1 // 0: No | 1: Yes | 2: Local + property int weeb: 1 // 0: No | 1: Open | 2: Closet + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## ๐Ÿง Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n" + property string tool: "functions" // search, functions, or none + property list extraModels: [ + { + "api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models + "description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory + "icon": "spark-symbolic", // Not mandatory + "key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory + "key_id": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b:free", + "name": "Custom: DS R1 Dstl. LLaMA 70B", + "requires_key": true + } + ] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property bool transparency: false + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + } + 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: true + property real maxAllowedIncrease: 10 + property real maxAllowed: 200 + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6-bluetooth" + property string network: "plasmawindowed-network" + property string networkEthernet: "kcmshell6-network" + property string taskManager: "plasma-systemmonitor --page-name Processes" + property string terminal: "kitty -1" // This is only for shell actions + } + + property JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property string wallpaperPath: "" + property string thumbnailPath: "" + property JsonObject parallax: JsonObject { + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + } + } + + property JsonObject bar: JsonObject { + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: distro, spark + property bool showBackground: true + property bool verbose: true + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: false + } + 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 JsonObject tray: JsonObject { + property bool monochromeIcons: true + } + 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 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 battery: JsonObject { + property int low: 20 + property int critical: 5 + property bool automaticSuspend: true + property int suspend: 3 + } + + 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 language: JsonObject { + 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 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 osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + 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 JsonObject resources: JsonObject { + property int updateInterval: 3000 + } + + 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"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property string action: "/" + property string clipboard: ";" + property string emojis: ":" + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string dateFormat: "ddd, dd/MM" + } + + 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 screenshotTool: JsonObject { + property bool showContentRegions: true + } + } + } +} diff --git a/configs/quickshell/ii/modules/common/Config.qml.backup b/configs/quickshell/ii/modules/common/Config.qml.backup new file mode 100644 index 0000000..3a42f4c --- /dev/null +++ b/configs/quickshell/ii/modules/common/Config.qml.backup @@ -0,0 +1,273 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io +import Qt.labs.platform + +Singleton { + id: root + property string filePath: `${StandardPaths.writableLocation(StandardPaths.ConfigLocation)}/illogical-impulse/config.json` + property alias options: configOptionsJsonAdapter + property bool ready: 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; + } + + FileView { + path: root.filePath + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + property JsonObject policies: JsonObject { + property int ai: 1 // 0: No | 1: Yes | 2: Local + property int weeb: 1 // 0: No | 1: Open | 2: Closet + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## ๐Ÿง Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n" + property string tool: "functions" // search, functions, or none + property list extraModels: [ + { + "api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models + "description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory + "icon": "spark-symbolic", // Not mandatory + "key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory + "key_id": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b:free", + "name": "Custom: DS R1 Dstl. LLaMA 70B", + "requires_key": true + } + ] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property bool transparency: false + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + } + 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: true + property real maxAllowedIncrease: 10 + property real maxAllowed: 90 // Realistically should already provide some protection when it's 99... + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6 kcm_bluetooth" + property string network: "plasmawindowed org.kde.plasma.networkmanagement" + 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 JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property string wallpaperPath: "" + property string thumbnailPath: "" + property JsonObject parallax: JsonObject { + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + } + } + + property JsonObject bar: JsonObject { + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: distro, spark + property bool showBackground: true + property bool verbose: true + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: false + } + 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 JsonObject tray: JsonObject { + property bool monochromeIcons: true + } + 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 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 battery: JsonObject { + property int low: 20 + property int critical: 5 + property bool automaticSuspend: true + property int suspend: 3 + } + + 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 language: JsonObject { + 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 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 osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + 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 JsonObject resources: JsonObject { + property int updateInterval: 3000 + } + + 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"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property string action: "/" + property string clipboard: ";" + property string emojis: ":" + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string dateFormat: "ddd, dd/MM" + } + + 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 screenshotTool: JsonObject { + property bool showContentRegions: true + } + } + } +} diff --git a/configs/quickshell/ii/modules/common/Directories.qml b/configs/quickshell/ii/modules/common/Directories.qml new file mode 100644 index 0000000..a1748ec --- /dev/null +++ b/configs/quickshell/ii/modules/common/Directories.qml @@ -0,0 +1,49 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import Qt.labs.platform +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + 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 pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[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 booruPreviews: FileUtils.trimFileProtocol(`${Directories.cache}/media/boorus`) + property string booruDownloads: FileUtils.trimFileProtocol(Directories.pictures + "/homework") + property string booruDownloadsNsfw: FileUtils.trimFileProtocol(Directories.pictures + "/homework/๐ŸŒถ๏ธ") + 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 notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) + property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) + 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 defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts") + property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`) + property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`) + // 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 '${booruPreviews}'; mkdir -p '${booruPreviews}'`]) + Quickshell.execDetached(["bash", "-c", `mkdir -p '${booruDownloads}' && mkdir -p '${booruDownloadsNsfw}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${latexOutput}'; mkdir -p '${latexOutput}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${cliphistDecode}'; mkdir -p '${cliphistDecode}'`]) + Quickshell.execDetached(["mkdir", "-p", `${aiChats}`]) + } +} diff --git a/configs/quickshell/ii/modules/common/Persistent.qml b/configs/quickshell/ii/modules/common/Persistent.qml new file mode 100644 index 0000000..abd062d --- /dev/null +++ b/configs/quickshell/ii/modules/common/Persistent.qml @@ -0,0 +1,49 @@ +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}` + + FileView { + path: root.filePath + + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: { + writeAdapter() + } + onLoadFailed: error => { + console.log("Failed to load persistent states file:", error); + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + adapter: JsonAdapter { + id: persistentStatesJsonAdapter + 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 booru: JsonObject { + property bool allowNsfw: false + property string provider: "yandere" + } + } + } +} diff --git a/configs/quickshell/ii/modules/common/functions/ColorUtils.qml b/configs/quickshell/ii/modules/common/functions/ColorUtils.qml new file mode 100644 index 0000000..27d4818 --- /dev/null +++ b/configs/quickshell/ii/modules/common/functions/ColorUtils.qml @@ -0,0 +1,114 @@ +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)); + } +} diff --git a/configs/quickshell/ii/modules/common/functions/FileUtils.qml b/configs/quickshell/ii/modules/common/functions/FileUtils.qml new file mode 100644 index 0000000..c051674 --- /dev/null +++ b/configs/quickshell/ii/modules/common/functions/FileUtils.qml @@ -0,0 +1,41 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } + + /** + * 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(); + } + + /** + * 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; + } +} diff --git a/configs/quickshell/ii/modules/common/functions/Fuzzy.qml b/configs/quickshell/ii/modules/common/functions/Fuzzy.qml new file mode 100644 index 0000000..7a132ad --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/functions/Levendist.qml b/configs/quickshell/ii/modules/common/functions/Levendist.qml new file mode 100644 index 0000000..a327c3c --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/functions/ObjectUtils.qml b/configs/quickshell/ii/modules/common/functions/ObjectUtils.qml new file mode 100644 index 0000000..d1204cd --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/functions/StringUtils.qml b/configs/quickshell/ii/modules/common/functions/StringUtils.qml new file mode 100644 index 0000000..e824183 --- /dev/null +++ b/configs/quickshell/ii/modules/common/functions/StringUtils.qml @@ -0,0 +1,221 @@ +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 + */ + 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) { + // escape single quotes + return String(str) + // .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); + } + + /** + * Splits markdown blocks into three different types: text, think, and code. + * @param { string } markdown + */ + 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"); + } + + 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(); + } + + 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')}`; + } + } + + function escapeHtml(str) { + if (typeof str !== 'string') + return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } +} diff --git a/configs/quickshell/ii/modules/common/functions/fuzzysort.js b/configs/quickshell/ii/modules/common/functions/fuzzysort.js new file mode 100644 index 0000000..1c1d9b9 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/functions/levendist.js b/configs/quickshell/ii/modules/common/functions/levendist.js new file mode 100644 index 0000000..90180d2 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/functions/qmldir b/configs/quickshell/ii/modules/common/functions/qmldir new file mode 100644 index 0000000..9950502 --- /dev/null +++ b/configs/quickshell/ii/modules/common/functions/qmldir @@ -0,0 +1,8 @@ +module qs.modules.common.functions + +ColorUtils 1.0 ColorUtils.qml +singleton FileUtils 1.0 FileUtils.qml +singleton Fuzzy 1.0 Fuzzy.qml +Levendist 1.0 Levendist.qml +ObjectUtils 1.0 ObjectUtils.qml +StringUtils 1.0 StringUtils.qml diff --git a/configs/quickshell/ii/modules/common/qmldir b/configs/quickshell/ii/modules/common/qmldir new file mode 100644 index 0000000..ff781d2 --- /dev/null +++ b/configs/quickshell/ii/modules/common/qmldir @@ -0,0 +1,7 @@ +module qs.modules.common + +singleton Appearance 1.0 Appearance.qml +singleton Config 1.0 Config.qml +singleton Directories 1.0 Directories.qml +singleton GlobalStates 1.0 ../../GlobalStates.qml +singleton Persistent 1.0 Persistent.qml diff --git a/configs/quickshell/ii/modules/common/widgets/ButtonGroup.qml b/configs/quickshell/ii/modules/common/widgets/ButtonGroup.qml new file mode 100644 index 0000000..7dc7a59 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/ButtonGroup.qml @@ -0,0 +1,46 @@ +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 real spacing: 5 + property real padding: 0 + property int clickIndex: rowLayout.clickIndex + + 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 + }] +} diff --git a/configs/quickshell/ii/modules/common/widgets/CircularProgress.qml b/configs/quickshell/ii/modules/common/widgets/CircularProgress.qml new file mode 100644 index 0000000..7ff2724 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/CircularProgress.qml @@ -0,0 +1,97 @@ +// From https://github.com/rafzby/circular-progressbar with modifications +// License: LGPL-3.0 - A copy can be found in `licenses` folder of repo + +import QtQuick +import qs.modules.common + +/** + * Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs + */ +Item { + id: root + + property int size: 30 + property int lineWidth: 2 + property real value: 0 + property color primaryColor: Appearance.m3colors.m3onSecondaryContainer + property color secondaryColor: Appearance.colors.colSecondaryContainer + property real gapAngle: Math.PI / 9 + property bool fill: false + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 1000 + property var easingType: Easing.OutCubic + + width: size + height: size + + signal animationFinished(); + + onValueChanged: { + canvas.degree = value * 360; + } + onPrimaryColorChanged: { + canvas.requestPaint(); + } + onSecondaryColorChanged: { + canvas.requestPaint(); + } + + Canvas { + id: canvas + + property real degree: 0 + + anchors.fill: parent + antialiasing: true + + onDegreeChanged: { + requestPaint(); + } + + onPaint: { + var ctx = getContext("2d"); + var x = root.width / 2; + var y = root.height / 2; + var radius = root.size / 2 - root.lineWidth; + var startAngle = (Math.PI / 180) * 270; + var fullAngle = (Math.PI / 180) * (270 + 360); + var progressAngle = (Math.PI / 180) * (270 + degree); + var epsilon = 0.01; // Small angle in radians + + ctx.reset(); + if (root.fill) { + ctx.fillStyle = root.secondaryColor; + ctx.beginPath(); + ctx.arc(x, y, radius + fillOverflow, startAngle, fullAngle); + ctx.fill(); + } + ctx.lineCap = 'round'; + ctx.lineWidth = root.lineWidth; + + // Secondary + ctx.beginPath(); + ctx.arc(x, y, radius, progressAngle + gapAngle, fullAngle - gapAngle); + ctx.strokeStyle = root.secondaryColor; + ctx.stroke(); + + // Primary (value indication) + var endAngle = progressAngle + (value > 0 ? 0 : epsilon); + ctx.beginPath(); + ctx.arc(x, y, radius, startAngle, endAngle); + ctx.strokeStyle = root.primaryColor; + ctx.stroke(); + } + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + } + +} diff --git a/configs/quickshell/ii/modules/common/widgets/CliphistImage.qml b/configs/quickshell/ii/modules/common/widgets/CliphistImage.qml new file mode 100644 index 0000000..ce15ef3 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/CliphistImage.qml @@ -0,0 +1,96 @@ +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 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 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}'`]) + } + + Image { + 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 + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: image.width + height: image.height + radius: root.radius + } + } + } +} + diff --git a/configs/quickshell/ii/modules/common/widgets/ConfigRow.qml b/configs/quickshell/ii/modules/common/widgets/ConfigRow.qml new file mode 100644 index 0000000..3cdc3f8 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/ConfigRow.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts + +RowLayout { + property bool uniform: false + spacing: 10 + uniformCellSizes: uniform +} diff --git a/configs/quickshell/ii/modules/common/widgets/ConfigSelectionArray.qml b/configs/quickshell/ii/modules/common/widgets/ConfigSelectionArray.qml new file mode 100644 index 0000000..318ffe1 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/ConfigSelectionArray.qml @@ -0,0 +1,43 @@ +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: [] + property string configOptionName: "" + 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 + buttonText: modelData.displayName; + toggled: root.currentValue === modelData.value + onClicked: { + root.selected(modelData.value); + } + } + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/ConfigSpinBox.qml b/configs/quickshell/ii/modules/common/widgets/ConfigSpinBox.qml new file mode 100644 index 0000000..375f78e --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/ConfigSpinBox.qml @@ -0,0 +1,30 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + property string text: "" + 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 + + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + + StyledSpinBox { + id: spinBoxWidget + Layout.fillWidth: false + value: root.value + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/ConfigSwitch.qml b/configs/quickshell/ii/modules/common/widgets/ConfigSwitch.qml new file mode 100644 index 0000000..e10f74d --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/ConfigSwitch.qml @@ -0,0 +1,32 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +RippleButton { + id: root + Layout.fillWidth: true + implicitHeight: contentItem.implicitHeight + 8 * 2 + onClicked: checked = !checked + + contentItem: RowLayout { + spacing: 10 + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + StyledSwitch { + id: switchWidget + down: root.down + scale: 0.6 + Layout.fillWidth: false + checked: root.checked + onClicked: root.clicked() + } + } +} + diff --git a/configs/quickshell/ii/modules/common/widgets/ContentPage.qml b/configs/quickshell/ii/modules/common/widgets/ContentPage.qml new file mode 100644 index 0000000..5b110f8 --- /dev/null +++ b/configs/quickshell/ii/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: 550 + 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: 10 + } + spacing: 20 + } + +} diff --git a/configs/quickshell/ii/modules/common/widgets/ContentSection.qml b/configs/quickshell/ii/modules/common/widgets/ContentSection.qml new file mode 100644 index 0000000..2f038e1 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/ContentSection.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title + default property alias data: sectionContent.data + + Layout.fillWidth: true + spacing: 8 + StyledText { + text: root.title + font.pixelSize: Appearance.font.pixelSize.larger + font.weight: Font.Medium + } + ColumnLayout { + id: sectionContent + spacing: 8 + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/ContentSubsection.qml b/configs/quickshell/ii/modules/common/widgets/ContentSubsection.qml new file mode 100644 index 0000000..b78f3aa --- /dev/null +++ b/configs/quickshell/ii/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 + content: root.tooltip + } + } + } + Item { Layout.fillWidth: true } + } + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 2 + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/ContentSubsectionLabel.qml b/configs/quickshell/ii/modules/common/widgets/ContentSubsectionLabel.qml new file mode 100644 index 0000000..5d29e0e --- /dev/null +++ b/configs/quickshell/ii/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: 4 +} diff --git a/configs/quickshell/ii/modules/common/widgets/CustomIcon.qml b/configs/quickshell/ii/modules/common/widgets/CustomIcon.qml new file mode 100644 index 0000000..d7a1c63 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/DialogButton.qml b/configs/quickshell/ii/modules/common/widgets/DialogButton.qml new file mode 100644 index 0000000..972c29b --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/DialogButton.qml @@ -0,0 +1,33 @@ +import qs.modules.common +import QtQuick + +/** + * Material 3 dialog button. See https://m3.material.io/components/dialogs/overview + */ +RippleButton { + id: button + + property string buttonText + implicitHeight: 30 + implicitWidth: buttonTextWidget.implicitWidth + 15 * 2 + buttonRadius: Appearance?.rounding.full ?? 9999 + + property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F" + property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96" + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small ?? 12 + color: button.enabled ? button.colEnabled : button.colDisabled + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/configs/quickshell/ii/modules/common/widgets/DragManager.qml b/configs/quickshell/ii/modules/common/widgets/DragManager.qml new file mode 100644 index 0000000..9a430d9 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/Favicon.qml b/configs/quickshell/ii/modules/common/widgets/Favicon.qml new file mode 100644 index 0000000..04e9285 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/FloatingActionButton.qml b/configs/quickshell/ii/modules/common/widgets/FloatingActionButton.qml new file mode 100644 index 0000000..14702aa --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/FloatingActionButton.qml @@ -0,0 +1,59 @@ +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: Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize) + implicitHeight: baseSize + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colPrimaryContainer + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + contentItem: RowLayout { + 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 + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + iconSize: 24 + color: Appearance.colors.colOnPrimaryContainer + text: root.iconText + } + Loader { + 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 + } + text: root.buttonText + color: Appearance.colors.colOnPrimaryContainer + font.pixelSize: 14 + font.weight: 450 + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/FlowButtonGroup.qml b/configs/quickshell/ii/modules/common/widgets/FlowButtonGroup.qml new file mode 100644 index 0000000..ec9526e --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/GroupButton.qml b/configs/quickshell/ii/modules/common/widgets/GroupButton.qml new file mode 100644 index 0000000..4a524e1 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/GroupButton.qml @@ -0,0 +1,130 @@ +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 real clickedWidth: baseWidth + 20 + property real clickedHeight: baseHeight + property var parentGroup: root.parent + property int clickIndex: parentGroup?.clickIndex ?? -1 + + Layout.fillWidth: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + Layout.fillHeight: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth + implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 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 { + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on implicitHeight { + 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) + } + + MouseArea { + 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; + }; + } + + + 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) + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/KeyboardKey.qml b/configs/quickshell/ii/modules/common/widgets/KeyboardKey.qml new file mode 100644 index 0000000..14c75c6 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/KeyboardKey.qml @@ -0,0 +1,42 @@ +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 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: Appearance.font.pixelSize.smaller + text: key + } + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/LightDarkPreferenceButton.qml b/configs/quickshell/ii/modules/common/widgets/LightDarkPreferenceButton.qml new file mode 100644 index 0000000..fa9d205 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/LightDarkPreferenceButton.qml @@ -0,0 +1,122 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell + +GroupButton { + 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", `${Directories.scriptPath}/colors/switchwall-wrapper.sh`, "--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 + sperm: true + animateSperm: 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/configs/quickshell/ii/modules/common/widgets/MaterialSymbol.qml b/configs/quickshell/ii/modules/common/widgets/MaterialSymbol.qml new file mode 100644 index 0000000..92da991 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/MaterialSymbol.qml @@ -0,0 +1,32 @@ +import qs.modules.common +import QtQuick + +Text { + id: root + property real iconSize: Appearance?.font.pixelSize.small ?? 16 + property real fill: 0 + property real truncatedFill: Math.round(fill * 100) / 100 // Reduce memory consumption spikes from constant font remapping + renderType: Text.NativeRendering + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded" + pixelSize: iconSize + weight: Font.Normal + (Font.DemiBold - Font.Normal) * fill + variableAxes: { + "FILL": truncatedFill, + // "wght": font.weight, + // "GRAD": 0, + "opsz": iconSize, + } + } + verticalAlignment: Text.AlignVCenter + color: Appearance.m3colors.m3onBackground + + // 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/configs/quickshell/ii/modules/common/widgets/MaterialTextField.qml b/configs/quickshell/ii/modules/common/widgets/MaterialTextField.qml new file mode 100644 index 0000000..241cc90 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/MaterialTextField.qml @@ -0,0 +1,52 @@ +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 ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + wrapMode: TextEdit.Wrap +} diff --git a/configs/quickshell/ii/modules/common/widgets/MenuButton.qml b/configs/quickshell/ii/modules/common/widgets/MenuButton.qml new file mode 100644 index 0000000..9185bc9 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/NavigationRail.qml b/configs/quickshell/ii/modules/common/widgets/NavigationRail.qml new file mode 100644 index 0000000..11082a7 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/NavigationRailButton.qml b/configs/quickshell/ii/modules/common/widgets/NavigationRailButton.qml new file mode 100644 index 0000000..0b83b45 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NavigationRailButton.qml @@ -0,0 +1,148 @@ +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 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 + 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/configs/quickshell/ii/modules/common/widgets/NavigationRailExpandButton.qml b/configs/quickshell/ii/modules/common/widgets/NavigationRailExpandButton.qml new file mode 100644 index 0000000..57e15f0 --- /dev/null +++ b/configs/quickshell/ii/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 + onClicked: { + 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/configs/quickshell/ii/modules/common/widgets/NavigationRailTabArray.qml b/configs/quickshell/ii/modules/common/widgets/NavigationRailTabArray.qml new file mode 100644 index 0000000..6596141 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NavigationRailTabArray.qml @@ -0,0 +1,41 @@ +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 + property real baseHighlightHeight: tabBarColumn.children[0].baseHighlightHeight + 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 + + 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/configs/quickshell/ii/modules/common/widgets/NotificationActionButton.qml b/configs/quickshell/ii/modules/common/widgets/NotificationActionButton.qml new file mode 100644 index 0000000..2a73725 --- /dev/null +++ b/configs/quickshell/ii/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: 30 + leftPadding: 15 + rightPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSurfaceContainerHighestHover + colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colSurfaceContainerHighestActive + + 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/configs/quickshell/ii/modules/common/widgets/NotificationAppIcon.qml b/configs/quickshell/ii/modules/common/widgets/NotificationAppIcon.qml new file mode 100644 index 0000000..5158d64 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NotificationAppIcon.qml @@ -0,0 +1,103 @@ +import qs.modules.common +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications + +Rectangle { // App icon + id: root + property var appIcon: "" + property var summary: "" + property var urgency: NotificationUrgency.Normal + property var image: "" + property real scale: 1 + property real size: 45 * scale + property real materialIconScale: 0.57 + property real appIconScale: 0.7 + property real smallAppIconScale: 0.49 + property real materialIconSize: size * materialIconScale + property real appIconSize: size * appIconScale + property real smallAppIconSize: size * smallAppIconScale + + implicitWidth: size + implicitHeight: size + radius: Appearance.rounding.full + color: 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) ? + "release_alert" : guessedIcon + } + anchors.fill: parent + color: (root.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) : + Appearance.m3colors.m3onSecondaryContainer + 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/configs/quickshell/ii/modules/common/widgets/NotificationGroup.qml b/configs/quickshell/ii/modules/common/widgets/NotificationGroup.qml new file mode 100644 index 0000000..d63bbb3 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NotificationGroup.qml @@ -0,0 +1,236 @@ +import qs.modules.common +import qs.services +import qs.modules.common.functions +import "./notification_utils.js" as NotificationUtils +import QtQuick +import QtQuick.Layouts +import Quickshell + +/** + * A group of notifications from the same app. + * Similar to Android's notifications + */ +Item { // 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 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + function destroyWithAnimation() { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.running = true; + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: root.width + root.dismissOvershoot + 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 + + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + root.toggleExpanded(); + else 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 (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + StyledRectangularShadow { + target: background + visible: popup + } + Rectangle { // Background of the notification + id: background + anchors.left: parent.left + width: parent.width + color: Appearance.colors.colSurfaceContainer + 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: 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: notificationGroup?.appIcon + summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary + } + + 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() } + } + } + + 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/configs/quickshell/ii/modules/common/widgets/NotificationGroupExpandButton.qml b/configs/quickshell/ii/modules/common/widgets/NotificationGroupExpandButton.qml new file mode 100644 index 0000000..ba2b7e1 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NotificationGroupExpandButton.qml @@ -0,0 +1,48 @@ +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/configs/quickshell/ii/modules/common/widgets/NotificationItem.qml b/configs/quickshell/ii/modules/common/widgets/NotificationItem.qml new file mode 100644 index 0000000..d5e9c4f --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NotificationItem.qml @@ -0,0 +1,313 @@ +import qs +import qs.modules.common +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +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 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 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, 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 (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + 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.colSurfaceContainerHigh) : + ColorUtils.transparentize(Appearance.colors.colSurfaceContainerHighest) + + 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 + // Layout.fillWidth: true + StyledText { + id: summaryText + visible: !root.onlyNotification + font.pixelSize: root.fontSize + color: Appearance.colors.colOnLayer2 + 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 {} + } + + StyledFlickable { // Notification actions + id: actionsFlickable + Layout.fillWidth: true + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + clip: !onlyNotification + + 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.large + 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.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + } + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/NotificationListView.qml b/configs/quickshell/ii/modules/common/widgets/NotificationListView.qml new file mode 100644 index 0000000..389a5a8 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,27 @@ +import qs +import qs.modules.common +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 + anchors.left: parent?.left + anchors.right: parent?.right + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/common/widgets/PointingHandInteraction.qml b/configs/quickshell/ii/modules/common/widgets/PointingHandInteraction.qml new file mode 100644 index 0000000..cf8b065 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/PointingHandLinkHover.qml b/configs/quickshell/ii/modules/common/widgets/PointingHandLinkHover.qml new file mode 100644 index 0000000..4d14c81 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/PrimaryTabBar.qml b/configs/quickshell/ii/modules/common/widgets/PrimaryTabBar.qml new file mode 100644 index 0000000..63f5e17 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/PrimaryTabBar.qml @@ -0,0 +1,97 @@ +import qs.modules.common +import qs +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + spacing: 0 + required property var tabButtonList // Something like [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}] + required property var externalTrackedTab + property bool enableIndicatorAnimation: false + property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F" + property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0" + signal currentIndexChanged(int index) + + property bool centerTabBar: parent.width > 500 + Layout.fillWidth: !centerTabBar + Layout.alignment: Qt.AlignHCenter + implicitWidth: Math.max(tabBar.implicitWidth, 600) + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: root.externalTrackedTab + onCurrentIndexChanged: { + root.onCurrentIndexChanged(currentIndex) + } + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: PrimaryTabButton { + selected: (index == root.externalTrackedTab) + buttonText: modelData.name + buttonIcon: modelData.icon + minimumWidth: 160 + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + Connections { + target: root + function onExternalTrackedTabChanged() { + root.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem?.children[0]?.children[tabBar.currentIndex]?.tabContentWidth ?? 0 + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: root.colIndicator + radius: Appearance?.rounding.full ?? 9999 + + Behavior on x { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + implicitHeight: 1 + color: root.colBorder + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/PrimaryTabButton.qml b/configs/quickshell/ii/modules/common/widgets/PrimaryTabButton.qml new file mode 100644 index 0000000..0b4b6f8 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/PrimaryTabButton.qml @@ -0,0 +1,171 @@ +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 + +TabButton { + id: button + property string buttonText + property string buttonIcon + property real minimumWidth: 110 + property bool selected: false + property int tabContentWidth: contentItem.children[0].implicitWidth + property int rippleDuration: 1200 + height: buttonBackground.height + implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth) + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colActive: Appearance?.colors.colPrimary ?? "#65558F" + property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F" + + 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) => { + 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) => { + button.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + 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: Appearance?.rounding.small + implicitHeight: 50 + color: (button.hovered ? button.colBackgroundHover : button.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: button.colRipple } + GradientStop { position: 0.3; color: button.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + visible: buttonIcon?.length > 0 + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: buttonIcon + iconSize: Appearance?.font.pixelSize.hugeass ?? 25 + fill: selected ? 1 : 0 + color: selected ? button.colActive : button.colInactive + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + StyledText { + id: buttonTextWidget + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small + color: selected ? button.colActive : button.colInactive + text: buttonText + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/common/widgets/Revealer.qml b/configs/quickshell/ii/modules/common/widgets/Revealer.qml new file mode 100644 index 0000000..bbbe2ef --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/Revealer.qml @@ -0,0 +1,25 @@ +import qs.modules.common +import QtQuick + +/** + * Recreation of GTK revealer. Expects one single child. + */ +Item { + id: root + property bool reveal + property bool vertical: false + clip: true + + implicitWidth: (reveal || vertical) ? childrenRect.width : 0 + implicitHeight: (reveal || !vertical) ? childrenRect.height : 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/configs/quickshell/ii/modules/common/widgets/RippleButton.qml b/configs/quickshell/ii/modules/common/widgets/RippleButton.qml new file mode 100644 index 0000000..7487203 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/RippleButton.qml @@ -0,0 +1,183 @@ +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 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" + + property color buttonColor: root.enabled ? (root.toggled ? + (root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.hovered ? colBackgroundHover : + colBackground)) : colBackground + 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: 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(); + 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 + 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: 50 + + 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/configs/quickshell/ii/modules/common/widgets/RippleButtonWithIcon.qml b/configs/quickshell/ii/modules/common/widgets/RippleButtonWithIcon.qml new file mode 100644 index 0000000..f84ae4d --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/RippleButtonWithIcon.qml @@ -0,0 +1,55 @@ +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 { + text: buttonWithIconRoot.mainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + } + implicitHeight: 35 + horizontalPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + + contentItem: RowLayout { + Item { + 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 { + sourceComponent: buttonWithIconRoot.mainContentComponent + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/RoundCorner.qml b/configs/quickshell/ii/modules/common/widgets/RoundCorner.qml new file mode 100644 index 0000000..6fba4b9 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/RoundCorner.qml @@ -0,0 +1,61 @@ +import QtQuick 2.9 + +Item { + id: root + + enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight } + property var corner: RoundCorner.CornerEnum.TopLeft // Default to TopLeft + + property int size: 25 + property color color: "#000000" + + onColorChanged: { + canvas.requestPaint(); + } + onCornerChanged: { + canvas.requestPaint(); + } + + implicitWidth: size + implicitHeight: size + + Canvas { + id: canvas + + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d"); + var r = root.size; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.beginPath(); + switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: + ctx.arc(r, r, r, Math.PI, 3 * Math.PI / 2); + ctx.lineTo(0, 0); + break; + case RoundCorner.CornerEnum.TopRight: + ctx.arc(0, r, r, 3 * Math.PI / 2, 2 * Math.PI); + ctx.lineTo(r, 0); + break; + case RoundCorner.CornerEnum.BottomLeft: + ctx.arc(r, 0, r, Math.PI / 2, Math.PI); + ctx.lineTo(0, r); + break; + case RoundCorner.CornerEnum.BottomRight: + ctx.arc(0, 0, r, 0, Math.PI / 2); + ctx.lineTo(r, r); + break; + } + ctx.closePath(); + ctx.fillStyle = root.color; + ctx.fill(); + } + } + + Behavior on size { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + +} diff --git a/configs/quickshell/ii/modules/common/widgets/SecondaryTabButton.qml b/configs/quickshell/ii/modules/common/widgets/SecondaryTabButton.qml new file mode 100644 index 0000000..983dd02 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/SecondaryTabButton.qml @@ -0,0 +1,161 @@ +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 + +TabButton { + id: root + property string buttonText + property string buttonIcon + property bool selected: false + property int rippleDuration: 1200 + height: buttonBackground.height + property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2 + + property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + property color colBackgroundHover: Appearance.colors.colLayer1Hover + property color colRipple: Appearance.colors.colLayer1Active + + 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) => { + 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) => { + root.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + 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: Appearance?.rounding.small ?? 7 + implicitHeight: 37 + 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) + } + + Rectangle { + id: ripple + + radius: Appearance.rounding.full + color: root.colRipple + opacity: 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: selected ? 1 : 0 + color: selected ? 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: selected ? 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/configs/quickshell/ii/modules/common/widgets/SelectionDialog.qml b/configs/quickshell/ii/modules/common/widgets/SelectionDialog.qml new file mode 100644 index 0000000..72da7ec --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/SelectionDialog.qml @@ -0,0 +1,132 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +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 + } + + ListView { + id: choiceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1 + spacing: 6 + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + 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/configs/quickshell/ii/modules/common/widgets/SelectionGroupButton.qml b/configs/quickshell/ii/modules/common/widgets/SelectionGroupButton.qml new file mode 100644 index 0000000..6a225eb --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/SelectionGroupButton.qml @@ -0,0 +1,24 @@ +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 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 + contentItem: StyledText { + color: parent.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.buttonText + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledFlickable.qml b/configs/quickshell/ii/modules/common/widgets/StyledFlickable.qml new file mode 100644 index 0000000..14b3af0 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledFlickable.qml @@ -0,0 +1,6 @@ +import QtQuick + +Flickable { + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledLabel.qml b/configs/quickshell/ii/modules/common/widgets/StyledLabel.qml new file mode 100644 index 0000000..35b3cbf --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledLabel.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +Label { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledListView.qml b/configs/quickshell/ii/modules/common/widgets/StyledListView.qml new file mode 100644 index 0000000..7021f24 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledListView.qml @@ -0,0 +1,108 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick + +/** + * 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 + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + add: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + from: 0, + to: 1, + }), + ] + } + + addDisplaced: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + to: 1, + }), + ] + } + + // displaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + // move: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + // moveDisplaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + remove: Transition { + animations: [ + 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: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledProgressBar.qml b/configs/quickshell/ii/modules/common/widgets/StyledProgressBar.qml new file mode 100644 index 0000000..fa1cd0b --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledProgressBar.qml @@ -0,0 +1,103 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +/** + * 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 sperm: false // If true, the progress bar will have a wavy fill effect + property bool animateSperm: true + property real spermAmplitudeMultiplier: sperm ? 0.5 : 0 + property real spermFrequency: 6 + property real spermFps: 60 + + Behavior on spermAmplitudeMultiplier { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Behavior on value { + animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + background: Item { + anchors.fill: parent + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Item { + anchors.fill: parent + + Canvas { + id: wavyFill + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + height: parent.height * 6 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var progress = root.visualPosition; + var fillWidth = progress * width; + var amplitude = parent.height * root.spermAmplitudeMultiplier; + var frequency = root.spermFrequency; + var phase = Date.now() / 400.0; + var centerY = height / 2; + + ctx.strokeStyle = root.highlightColor; + ctx.lineWidth = parent.height; + ctx.lineCap = "round"; + ctx.beginPath(); + for (var x = ctx.lineWidth / 2; x <= fillWidth; x += 1) { + var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / width + phase); + if (x === 0) + ctx.moveTo(x, waveY); + else + ctx.lineTo(x, waveY); + } + ctx.stroke(); + } + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + Timer { + interval: 1000 / root.spermFps + running: root.animateSperm + repeat: root.sperm + onTriggered: wavyFill.requestPaint() + } + } + Rectangle { // Right remaining part fill + anchors.right: parent.right + width: (1 - root.visualPosition) * parent.width - valueBarGap + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.trackColor + } + Rectangle { // Stop point + anchors.right: parent.right + width: valueBarGap + height: valueBarGap + radius: Appearance?.rounding.full ?? 9999 + color: root.highlightColor + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/common/widgets/StyledRadioButton.qml b/configs/quickshell/ii/modules/common/widgets/StyledRadioButton.qml new file mode 100644 index 0000000..a6a63b7 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledRadioButton.qml @@ -0,0 +1,87 @@ +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 + implicitHeight: contentItem.implicitHeight + 4 * 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/configs/quickshell/ii/modules/common/widgets/StyledRectangularShadow.qml b/configs/quickshell/ii/modules/common/widgets/StyledRectangularShadow.qml new file mode 100644 index 0000000..a3c842c --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/StyledSlider.qml b/configs/quickshell/ii/modules/common/widgets/StyledSlider.qml new file mode 100644 index 0000000..e940f1a --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledSlider.qml @@ -0,0 +1,155 @@ +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 { + 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.m3colors.m3onSecondaryContainer + 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 + : 6 + property real handleHeight: Math.max(33, trackWidth + 9) + property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth + property real handleMargins: 4 + onHandleMarginsChanged: { + console.log("Handle margins changed to", handleMargins); + } + property real trackDotSize: 3 + property string tooltipContent: `${Math.round(value * 100)}%` + + leftPadding: handleMargins + rightPadding: handleMargins + property real effectiveDraggingWidth: width - leftPadding - rightPadding + + Layout.fillWidth: true + from: 0 + to: 1 + + Behavior on value { // This makes the adjusted value (like volume) shift smoothly + SmoothedAnimation { + velocity: Appearance.animation.elementMoveFast.velocity + } + } + + Behavior on handleMargins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + component TrackDot: Rectangle { + required property real value + anchors.verticalCenter: parent.verticalCenter + x: root.handleMargins + (value * root.effectiveDraggingWidth) - (root.trackDotSize / 2) + width: root.trackDotSize + height: root.trackDotSize + radius: Appearance.rounding.full + color: value > 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 + Rectangle { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: trackWidth + color: root.highlightColor + topLeftRadius: root.trackRadius + bottomLeftRadius: root.trackRadius + topRightRadius: root.unsharpenRadius + bottomRightRadius: root.unsharpenRadius + } + + // 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 + content: root.tooltipContent + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/common/widgets/StyledSpinBox.qml b/configs/quickshell/ii/modules/common/widgets/StyledSpinBox.qml new file mode 100644 index 0000000..c11f241 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledSpinBox.qml @@ -0,0 +1,92 @@ +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 + + 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.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/configs/quickshell/ii/modules/common/widgets/StyledSwitch.qml b/configs/quickshell/ii/modules/common/widgets/StyledSwitch.qml new file mode 100644 index 0000000..f16e213 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledSwitch.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * Material 3 switch. See https://m3.material.io/components/switch/overview + */ +Switch { + id: root + property real scale: 0.6 // 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 { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledText.qml b/configs/quickshell/ii/modules/common/widgets/StyledText.qml new file mode 100644 index 0000000..6024fc6 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledText.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Text { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledTextArea.qml b/configs/quickshell/ii/modules/common/widgets/StyledTextArea.qml new file mode 100644 index 0000000..e0abba3 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledTextArea.qml @@ -0,0 +1,18 @@ +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 + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledTextInput.qml b/configs/quickshell/ii/modules/common/widgets/StyledTextInput.qml new file mode 100644 index 0000000..57d0c72 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledTextInput.qml @@ -0,0 +1,17 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextInput { + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/configs/quickshell/ii/modules/common/widgets/StyledToolTip.qml b/configs/quickshell/ii/modules/common/widgets/StyledToolTip.qml new file mode 100644 index 0000000..813c9ed --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/StyledToolTip.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolTip { + id: root + property string content + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + property bool internalVisibleCondition: { + const ans = (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + return ans + } + verticalPadding: 5 + horizontalPadding: 10 + opacity: internalVisibleCondition ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + background: null + + contentItem: Item { + id: contentItemBackground + implicitWidth: tooltipTextObject.width + 2 * root.horizontalPadding + implicitHeight: tooltipTextObject.height + 2 * root.verticalPadding + + Rectangle { + id: backgroundRectangle + anchors.bottom: contentItemBackground.bottom + anchors.horizontalCenter: contentItemBackground.horizontalCenter + color: Appearance?.colors.colTooltip ?? "#3C4043" + radius: Appearance?.rounding.verysmall ?? 7 + width: internalVisibleCondition ? (tooltipTextObject.width + 2 * padding) : 0 + height: internalVisibleCondition ? (tooltipTextObject.height + 2 * padding) : 0 + clip: true + + Behavior on width { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledText { + id: tooltipTextObject + anchors.centerIn: parent + text: content + font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + font.hintingPreference: Font.PreferNoHinting // Prevent shaky text + color: Appearance?.colors.colOnTooltip ?? "#FFFFFF" + wrapMode: Text.Wrap + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/common/widgets/VerticalButtonGroup.qml b/configs/quickshell/ii/modules/common/widgets/VerticalButtonGroup.qml new file mode 100644 index 0000000..b1ca845 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/WaveVisualizer.qml b/configs/quickshell/ii/modules/common/widgets/WaveVisualizer.qml new file mode 100644 index 0000000..64559c1 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/modules/common/widgets/notification_utils.js b/configs/quickshell/ii/modules/common/widgets/notification_utils.js new file mode 100644 index 0000000..9b15105 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/notification_utils.js @@ -0,0 +1,77 @@ + +/** + * @param { string } summary + * @returns { string } + */ +function findSuitableMaterialSymbol(summary = "") { + const defaultType = 'chat'; + if(summary.length === 0) return defaultType; + + const keywordsToTypes = { + 'reboot': 'restart_alt', + 'recording': 'screen_record', + 'battery': 'power', + 'power': 'power', + 'screenshot': 'screenshot_monitor', + 'welcome': 'waving_hand', + 'time': 'scheduleb', + 'installed': 'download', + 'configuration reloaded': 'reset_wrench', + 'config': 'reset_wrench', + 'update': 'update', + 'ai response': 'neurology', + 'control': 'settings', + 'upscale': 'compare', + 'install': 'deployed_code_update', + '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 } + */ +const 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"); +}; \ No newline at end of file diff --git a/configs/quickshell/ii/modules/common/widgets/qmldir b/configs/quickshell/ii/modules/common/widgets/qmldir new file mode 100644 index 0000000..a4a69f9 --- /dev/null +++ b/configs/quickshell/ii/modules/common/widgets/qmldir @@ -0,0 +1,61 @@ +module qs.modules.common.widgets + +ButtonGroup 1.0 ButtonGroup.qml +CircularProgress 1.0 CircularProgress.qml +CliphistImage 1.0 CliphistImage.qml +ConfigRow 1.0 ConfigRow.qml +ConfigSelectionArray 1.0 ConfigSelectionArray.qml +ConfigSpinBox 1.0 ConfigSpinBox.qml +ConfigSwitch 1.0 ConfigSwitch.qml +ContentPage 1.0 ContentPage.qml +ContentSection 1.0 ContentSection.qml +ContentSubsectionLabel 1.0 ContentSubsectionLabel.qml +ContentSubsection 1.0 ContentSubsection.qml +CustomIcon 1.0 CustomIcon.qml +DialogButton 1.0 DialogButton.qml +DragManager 1.0 DragManager.qml +Favicon 1.0 Favicon.qml +FloatingActionButton 1.0 FloatingActionButton.qml +FlowButtonGroup 1.0 FlowButtonGroup.qml +GroupButton 1.0 GroupButton.qml +KeyboardKey 1.0 KeyboardKey.qml +LightDarkPreferenceButton 1.0 LightDarkPreferenceButton.qml +MaterialSymbol 1.0 MaterialSymbol.qml +MaterialTextField 1.0 MaterialTextField.qml +MenuButton 1.0 MenuButton.qml +NavigationRailButton 1.0 NavigationRailButton.qml +NavigationRailExpandButton 1.0 NavigationRailExpandButton.qml +NavigationRail 1.0 NavigationRail.qml +NavigationRailTabArray 1.0 NavigationRailTabArray.qml +NotificationActionButton 1.0 NotificationActionButton.qml +NotificationAppIcon 1.0 NotificationAppIcon.qml +NotificationGroupExpandButton 1.0 NotificationGroupExpandButton.qml +NotificationGroup 1.0 NotificationGroup.qml +NotificationItem 1.0 NotificationItem.qml +NotificationListView 1.0 NotificationListView.qml +PointingHandInteraction 1.0 PointingHandInteraction.qml +PointingHandLinkHover 1.0 PointingHandLinkHover.qml +PrimaryTabBar 1.0 PrimaryTabBar.qml +PrimaryTabButton 1.0 PrimaryTabButton.qml +Revealer 1.0 Revealer.qml +RippleButton 1.0 RippleButton.qml +RippleButtonWithIcon 1.0 RippleButtonWithIcon.qml +RoundCorner 1.0 RoundCorner.qml +SecondaryTabButton 1.0 SecondaryTabButton.qml +SelectionDialog 1.0 SelectionDialog.qml +SelectionGroupButton 1.0 SelectionGroupButton.qml +StyledFlickable 1.0 StyledFlickable.qml +StyledLabel 1.0 StyledLabel.qml +StyledListView 1.0 StyledListView.qml +StyledProgressBar 1.0 StyledProgressBar.qml +StyledRadioButton 1.0 StyledRadioButton.qml +StyledRectangularShadow 1.0 StyledRectangularShadow.qml +StyledSlider 1.0 StyledSlider.qml +StyledSpinBox 1.0 StyledSpinBox.qml +StyledSwitch 1.0 StyledSwitch.qml +StyledTextArea 1.0 StyledTextArea.qml +StyledTextInput 1.0 StyledTextInput.qml +StyledText 1.0 StyledText.qml +StyledToolTip 1.0 StyledToolTip.qml +VerticalButtonGroup 1.0 VerticalButtonGroup.qml +WaveVisualizer 1.0 WaveVisualizer.qml diff --git a/configs/quickshell/ii/modules/dock/Dock.qml b/configs/quickshell/ii/modules/dock/Dock.qml new file mode 100644 index 0000000..b4237e5 --- /dev/null +++ b/configs/quickshell/ii/modules/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 { // Window + required property var modelData + id: dockRoot + 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; } + DockSeparator {} + DockButton { + Layout.fillHeight: true + onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen + contentItem: MaterialSymbol { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: parent.width / 2 + text: "apps" + color: Appearance.colors.colOnLayer0 + } + } + } + } + } + + } + } + } +} diff --git a/configs/quickshell/ii/modules/dock/DockAppButton.qml b/configs/quickshell/ii/modules/dock/DockAppButton.qml new file mode 100644 index 0000000..1ebbffa --- /dev/null +++ b/configs/quickshell/ii/modules/dock/DockAppButton.qml @@ -0,0 +1,139 @@ +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.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 + + property bool isSeparator: appToplevel.appId === "SEPARATOR" + property var desktopEntry: DesktopEntries.byId(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: () => { + if (Config.options.dock.pinnedApps.indexOf(appToplevel.appId) !== -1) { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appToplevel.appId) + } else { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([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/configs/quickshell/ii/modules/dock/DockApps.qml b/configs/quickshell/ii/modules/dock/DockApps.qml new file mode 100644 index 0000000..623bdc2 --- /dev/null +++ b/configs/quickshell/ii/modules/dock/DockApps.qml @@ -0,0 +1,263 @@ +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.Widgets +import Quickshell.Wayland + +Item { + id: root + property real maxWindowPreviewHeight: 200 + property real maxWindowPreviewWidth: 300 + property real windowControlsHeight: 30 + + 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: { + 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({ appId: key, toplevels: value.toplevels, pinned: value.pinned }); + } + + return values; + } + } + delegate: DockAppButton { + required property var modelData + appToplevel: modelData + appListRoot: root + } + } + + 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/configs/quickshell/ii/modules/dock/DockButton.qml b/configs/quickshell/ii/modules/dock/DockButton.qml new file mode 100644 index 0000000..6165578 --- /dev/null +++ b/configs/quickshell/ii/modules/dock/DockButton.qml @@ -0,0 +1,16 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + implicitWidth: implicitHeight - topInset - bottomInset + buttonRadius: Appearance.rounding.normal + + topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding +} diff --git a/configs/quickshell/ii/modules/dock/DockSeparator.qml b/configs/quickshell/ii/modules/dock/DockSeparator.qml new file mode 100644 index 0000000..419b0fe --- /dev/null +++ b/configs/quickshell/ii/modules/dock/DockSeparator.qml @@ -0,0 +1,13 @@ +import qs +import qs.modules.common +import QtQuick +import QtQuick.Controls +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/configs/quickshell/ii/modules/lock/Lock.qml b/configs/quickshell/ii/modules/lock/Lock.qml new file mode 100644 index 0000000..89d3977 --- /dev/null +++ b/configs/quickshell/ii/modules/lock/Lock.qml @@ -0,0 +1,99 @@ +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.lock +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + // 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 + + onUnlocked: { + // Unlock the screen before exiting, or the compositor will display a + // fallback lock you can't interact with. + GlobalStates.screenLocked = false; + } + } + + 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 + + LazyLoader { + id: blurLayerLoader + required property var modelData + active: GlobalStates.screenLocked + component: PanelWindow { + screen: blurLayerLoader.modelData + WlrLayershell.namespace: "quickshell:lockWindowPusher" + color: "transparent" + anchors { + top: true + left: true + right: true + } + // implicitHeight: lockContext.currentText == "" ? 1 : screen.height + implicitHeight: 1 + exclusiveZone: screen.height * 3 // For some reason if we don't multiply by some number it would look really weird + } + } + } + + IpcHandler { + target: "lock" + + function activate(): void { + GlobalStates.screenLocked = true; + } + function focus(): void { + lockContext.shouldReFocus(); + } + } + + GlobalShortcut { + name: "lock" + description: "Locks the screen" + + onPressed: { + GlobalStates.screenLocked = true; + } + } + + 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: { + // console.log("I BEG FOR PLEAS REFOCUZ") + lockContext.shouldReFocus(); + } + } +} diff --git a/configs/quickshell/ii/modules/lock/LockContext.qml b/configs/quickshell/ii/modules/lock/LockContext.qml new file mode 100644 index 0000000..ede61ee --- /dev/null +++ b/configs/quickshell/ii/modules/lock/LockContext.qml @@ -0,0 +1,67 @@ +import qs +import QtQuick +import Quickshell +import Quickshell.Services.Pam + +Scope { + id: root + signal shouldReFocus() + signal unlocked() + 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 + + Timer { + id: passwordClearTimer + interval: 10000 + onTriggered: { + root.currentText = ""; + } + } + + onCurrentTextChanged: { + showFailure = false; // Clear the failure text once the user starts typing. + GlobalStates.screenLockContainsCharacters = currentText.length > 0; + passwordClearTimer.restart(); + } + + function tryUnlock() { + if (currentText === "") return; + + root.unlockInProgress = true; + pam.start(); + } + + PamContext { + id: pam + + // Its best to have a custom pam config for quickshell, as the system one + // might not be what your interface expects, and break in some way. + // This particular example only supports passwords. + configDirectory: "pam" + config: "password.conf" + + // 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(); + } else { + root.showFailure = true; + } + + root.currentText = ""; + root.unlockInProgress = false; + } + } +} diff --git a/configs/quickshell/ii/modules/lock/LockSurface.qml b/configs/quickshell/ii/modules/lock/LockSurface.qml new file mode 100644 index 0000000..98d1f57 --- /dev/null +++ b/configs/quickshell/ii/modules/lock/LockSurface.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtQuick.Layouts +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 LockContext context + property bool active: false + property bool showInputField: active || context.currentText.length > 0 + + function forceFieldFocus() { + passwordBox.forceActiveFocus(); + } + + Component.onCompleted: { + forceFieldFocus(); + } + + Connections { + target: context + function onShouldReFocus() { + forceFieldFocus(); + } + } + + Keys.onPressed: (event) => { // Esc to clear + // console.log("KEY!!") + if (event.key === Qt.Key_Escape) { + root.context.currentText = "" + } + forceFieldFocus(); + } + + hoverEnabled: true + acceptedButtons: Qt.LeftButton + onPressed: (mouse) => { + forceFieldFocus(); + // console.log("Pressed") + } + onPositionChanged: (mouse) => { + forceFieldFocus(); + // console.log(JSON.stringify(mouse)) + } + + anchors.fill: parent + + // RippleButton { + // anchors { + // top: parent.top + // left: parent.left + // leftMargin: 10 + // topMargin: 10 + // } + // implicitHeight: 40 + // colBackground: Appearance.colors.colLayer2 + // onClicked: context.unlocked() + // contentItem: StyledText { + // text: "[[ DEBUG BYPASS ]]" + // } + // } + + // Password entry + Rectangle { + id: passwordBoxContainer + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: root.showInputField ? 20 : -height + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: 160 + implicitHeight: 44 + + StyledText { + visible: root.context.showFailure && passwordBox.text.length == 0 + anchors.centerIn: parent + text: "Incorrect" + color: Appearance.m3colors.m3error + } + + StyledTextInput { + id: passwordBox + + anchors { + fill: parent + margins: 10 + } + clip: true + horizontalAlignment: TextInput.AlignHCenter + verticalAlignment: TextInput.AlignVCenter + focus: true + onFocusChanged: root.forceFieldFocus(); + color: Appearance.colors.colOnLayer2 + font { + pixelSize: 10 + } + + // 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; + } + } + } + } + + RippleButton { + anchors { + verticalCenter: passwordBoxContainer.verticalCenter + left: passwordBoxContainer.right + leftMargin: 5 + } + + visible: opacity > 0 + implicitHeight: passwordBoxContainer.implicitHeight - 12 + implicitWidth: implicitHeight + toggled: true + buttonRadius: passwordBoxContainer.radius + colBackground: Appearance.colors.colLayer2 + onClicked: root.context.tryUnlock() + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 24 + text: "arrow_right_alt" + color: Appearance.colors.colOnPrimary + } + } +} diff --git a/configs/quickshell/ii/modules/lock/pam/password.conf b/configs/quickshell/ii/modules/lock/pam/password.conf new file mode 100644 index 0000000..7e5d75a --- /dev/null +++ b/configs/quickshell/ii/modules/lock/pam/password.conf @@ -0,0 +1 @@ +auth required pam_unix.so diff --git a/configs/quickshell/ii/modules/mediaControls/MediaControls.qml b/configs/quickshell/ii/modules/mediaControls/MediaControls.qml new file mode 100644 index 0000000..06d1a38 --- /dev/null +++ b/configs/quickshell/ii/modules/mediaControls/MediaControls.qml @@ -0,0 +1,196 @@ +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 +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: Mpris.players.values.filter(player => isRealPlayer(player)) + 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 contentPadding: 13 + property real popupRounding: Appearance.rounding.screenRounding - Appearance.sizes.elevationMargin + 1 + property real artRounding: Appearance.rounding.verysmall + property list visualizerPoints: [] + + property bool hasPlasmaIntegration: true + Process { + id: plasmaIntegrationAvailabilityCheckProc + running: true + command: ["bash", "-c", "command -v plasma-browser-integration-host"] + onExited: (exitCode, exitStatus) => { + root.hasPlasmaIntegration = (exitCode === 0); + } + } + function isRealPlayer(player) { + // 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')) + ); + } + 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 && Mpris.players.values.filter(player => isRealPlayer(player)).length === 0) { + GlobalStates.mediaControlsOpen = false; + } + } + + sourceComponent: PanelWindow { + id: mediaControlsRoot + visible: true + + exclusiveZone: 0 + implicitWidth: ( + (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth / 2) // Account for widget width + ) * 2 + implicitHeight: playerColumnLayout.implicitHeight + color: "transparent" + WlrLayershell.namespace: "quickshell:mediaControls" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + left: true + } + mask: Region { + item: playerColumnLayout + } + + ColumnLayout { + id: playerColumnLayout + anchors.top: parent.top + anchors.bottom: parent.bottom + x: (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth) // Account for widget width + + (Appearance.sizes.elevationMargin) // It's fine for shadows to overlap + spacing: -Appearance.sizes.elevationMargin // Shadow overlap okay + + Repeater { + model: ScriptModel { + values: root.meaningfulPlayers + } + delegate: PlayerControl { + required property MprisPlayer modelData + player: modelData + visualizerPoints: root.visualizerPoints + } + } + } + } + } + + 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; + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/mediaControls/PlayerControl.qml b/configs/quickshell/ii/modules/mediaControls/PlayerControl.qml new file mode 100644 index 0000000..6f63c83 --- /dev/null +++ b/configs/quickshell/ii/modules/mediaControls/PlayerControl.qml @@ -0,0 +1,297 @@ +import qs.modules.common +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: playerController + required property MprisPlayer player + property var artUrl: player?.trackArtUrl + property string artDownloadLocation: Directories.coverArt + property string artFileName: Qt.md5(artUrl) + ".jpg" + 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 + + implicitWidth: widgetWidth + implicitHeight: widgetHeight + + 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 prevision + running: playerController.player?.playbackState == MprisPlaybackState.Playing + interval: 1000 + repeat: true + onTriggered: { + playerController.player.positionChanged() + } + } + + onArtUrlChanged: { + if (playerController.artUrl.length == 0) { + playerController.artDominantColor = Appearance.m3colors.m3secondaryContainer + return; + } + // console.log("PlayerControl: Art URL changed to", playerController.artUrl) + // console.log("Download cmd:", coverArtDownloader.command.join(" ")) + playerController.downloaded = false + coverArtDownloader.running = true + } + + Process { // Cover art downloader + id: coverArtDownloader + property string targetFile: playerController.artUrl + command: [ "bash", "-c", `[ -f ${artFilePath} ] || curl -sSL '${targetFile}' -o '${artFilePath}'` ] + onExited: (exitCode, exitStatus) => { + playerController.downloaded = true + } + } + + ColorQuantizer { + id: colorQuantizer + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + depth: 0 // 2^0 = 1 color + rescaleSize: 1 // Rescale to 1x1 pixel for faster processing + } + + property bool backgroundIsDark: artDominantColor.hslLightness < 0.5 + property QtObject blendedColors: QtObject { + property color colLayer0: ColorUtils.mix(Appearance.colors.colLayer0, artDominantColor, (backgroundIsDark && Appearance.m3colors.darkmode) ? 0.6 : 0.5) + property color colLayer1: ColorUtils.mix(Appearance.colors.colLayer1, artDominantColor, 0.5) + property color colOnLayer0: ColorUtils.mix(Appearance.colors.colOnLayer0, artDominantColor, 0.5) + property color colOnLayer1: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colSubtext: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimary, artDominantColor), artDominantColor, 0.5) + property color colPrimaryHover: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryHover, artDominantColor), artDominantColor, 0.3) + property color colPrimaryActive: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryActive, artDominantColor), artDominantColor, 0.3) + property color colSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, artDominantColor, 0.15) + property color colSecondaryContainerHover: ColorUtils.mix(Appearance.colors.colSecondaryContainerHover, artDominantColor, 0.3) + property color colSecondaryContainerActive: ColorUtils.mix(Appearance.colors.colSecondaryContainerActive, artDominantColor, 0.5) + property color colOnPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.m3colors.m3onPrimary, artDominantColor), artDominantColor, 0.5) + property color colOnSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3onSecondaryContainer, artDominantColor, 0.5) + + } + + StyledRectangularShadow { + target: background + } + Rectangle { // Background + id: background + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + color: blendedColors.colLayer0 + radius: root.popupRounding + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: background.width + height: background.height + radius: background.radius + } + } + + Image { + id: blurredArt + anchors.fill: parent + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + sourceSize.width: background.width + sourceSize.height: background.height + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + layer.enabled: true + layer.effect: MultiEffect { + source: blurredArt + saturation: 0.2 + blurEnabled: true + blurMax: 100 + blur: 1 + } + + Rectangle { + anchors.fill: parent + color: ColorUtils.transparentize(blendedColors.colLayer0, 0.3) + radius: root.popupRounding + } + } + + WaveVisualizer { + id: visualizerCanvas + anchors.fill: parent + live: playerController.player?.isPlaying + points: playerController.visualizerPoints + maxVisualizerValue: playerController.maxVisualizerValue + smoothing: playerController.visualizerSmoothing + color: blendedColors.colPrimary + } + + RowLayout { + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 15 + + Rectangle { // Art background + id: artBackground + Layout.fillHeight: true + implicitWidth: height + radius: root.artRounding + color: ColorUtils.transparentize(blendedColors.colLayer1, 0.5) + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: artBackground.width + height: artBackground.height + radius: artBackground.radius + } + } + + Image { // Art image + id: mediaArt + property int size: parent.height + anchors.fill: parent + + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: 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(playerController.player?.trackTitle) || "Untitled" + } + StyledText { + id: trackArtist + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: blendedColors.colSubtext + elide: Text.ElideRight + text: playerController.player?.trackArtist + } + 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(playerController.player?.position)} / ${StringUtils.friendlyTimeForSeconds(playerController.player?.length)}` + } + RowLayout { + id: sliderRow + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + TrackChangeButton { + iconName: "skip_previous" + onClicked: playerController.player?.previous() + } + Item { + id: progressBarContainer + Layout.fillWidth: true + implicitHeight: progressBar.implicitHeight + + StyledProgressBar { + id: progressBar + anchors.fill: parent + highlightColor: blendedColors.colPrimary + trackColor: blendedColors.colSecondaryContainer + value: playerController.player?.position / playerController.player?.length + sperm: playerController.player?.isPlaying + } + } + TrackChangeButton { + iconName: "skip_next" + onClicked: playerController.player?.next() + } + } + + RippleButton { + id: playPauseButton + anchors.right: parent.right + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + property real size: 44 + implicitWidth: size + implicitHeight: size + onClicked: playerController.player.togglePlaying(); + + buttonRadius: playerController.player?.isPlaying ? Appearance?.rounding.normal : size / 2 + colBackground: playerController.player?.isPlaying ? blendedColors.colPrimary : blendedColors.colSecondaryContainer + colBackgroundHover: playerController.player?.isPlaying ? blendedColors.colPrimaryHover : blendedColors.colSecondaryContainerHover + colRipple: playerController.player?.isPlaying ? blendedColors.colPrimaryActive : blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: playerController.player?.isPlaying ? blendedColors.colOnPrimary : blendedColors.colOnSecondaryContainer + text: playerController.player?.isPlaying ? "pause" : "play_arrow" + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/notificationPopup/NotificationPopup.qml b/configs/quickshell/ii/modules/notificationPopup/NotificationPopup.qml new file mode 100644 index 0000000..d954cbf --- /dev/null +++ b/configs/quickshell/ii/modules/notificationPopup/NotificationPopup.qml @@ -0,0 +1,50 @@ +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: 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/configs/quickshell/ii/modules/onScreenDisplay/OnScreenDisplayBrightness.qml b/configs/quickshell/ii/modules/onScreenDisplay/OnScreenDisplayBrightness.qml new file mode 100644 index 0000000..572963c --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenDisplay/OnScreenDisplayBrightness.qml @@ -0,0 +1,152 @@ +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.Hyprland +import Quickshell.Wayland + +Scope { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property var brightnessMonitor: Brightness.getMonitorForScreen(focusedScreen) + + function triggerOsd() { + GlobalStates.osdBrightnessOpen = true + osdTimeout.restart() + } + + Timer { + id: osdTimeout + interval: Config.options.osd.timeout + repeat: false + running: false + onTriggered: { + GlobalStates.osdBrightnessOpen = false + } + } + + Connections { + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + GlobalStates.osdBrightnessOpen = false + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + if (!root.brightnessMonitor.ready) return + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdBrightnessOpen + + sourceComponent: PanelWindow { + id: osdRoot + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: osdValues.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: osdValues.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdBrightnessOpen = false + } + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + OsdValueIndicator { + id: osdValues + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + value: root.brightnessMonitor?.brightness ?? 50 + icon: "light_mode" + rotateIcon: true + scaleIcon: true + name: Translation.tr("Brightness") + } + } + } + + } + } + + IpcHandler { + target: "osdBrightness" + + function trigger() { + root.triggerOsd() + } + + function hide() { + GlobalStates.osdBrightnessOpen = false + } + + function toggle() { + GlobalStates.osdBrightnessOpen = !GlobalStates.osdBrightnessOpen + } + } + + GlobalShortcut { + name: "osdBrightnessTrigger" + description: "Triggers brightness OSD on press" + + onPressed: { + root.triggerOsd() + } + } + GlobalShortcut { + name: "osdBrightnessHide" + description: "Hides brightness OSD on press" + + onPressed: { + GlobalStates.osdBrightnessOpen = false + } + } + +} diff --git a/configs/quickshell/ii/modules/onScreenDisplay/OnScreenDisplayVolume.qml b/configs/quickshell/ii/modules/onScreenDisplay/OnScreenDisplayVolume.qml new file mode 100644 index 0000000..6128cdf --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenDisplay/OnScreenDisplayVolume.qml @@ -0,0 +1,203 @@ +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) + + 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() { + GlobalStates.osdVolumeOpen = false + } + } + + Connections { // Listen to volume changes + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + function onMutedChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + } + + Connections { // Listen to protection triggers + target: Audio + function onSinkProtectionTriggered(reason) { + root.protectionMessage = reason; + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdVolumeOpen + + sourceComponent: PanelWindow { + id: osdRoot + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + 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 + Appearance.sizes.elevationMargin * 2 + implicitWidth: contentColumnLayout.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdVolumeOpen = false + } + + ColumnLayout { + id: contentColumnLayout + anchors { + top: parent.top + left: parent.left + right: parent.right + leftMargin: Appearance.sizes.elevationMargin + rightMargin: Appearance.sizes.elevationMargin + } + spacing: 0 + + OsdValueIndicator { + id: osdValues + Layout.fillWidth: true + value: Audio.sink?.audio.volume ?? 0 + icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" + name: Translation.tr("Volume") + } + + Item { + id: protectionMessageWrapper + implicitHeight: protectionMessageBackground.implicitHeight + implicitWidth: protectionMessageBackground.implicitWidth + Layout.alignment: Qt.AlignHCenter + 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/configs/quickshell/ii/modules/onScreenDisplay/OsdValueIndicator.qml b/configs/quickshell/ii/modules/onScreenDisplay/OsdValueIndicator.qml new file mode 100644 index 0000000..6edd24a --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenDisplay/OsdValueIndicator.qml @@ -0,0 +1,104 @@ +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 +import Quickshell.Widgets +// import Qt5Compat.GraphicalEffects + +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 + + Layout.margins: Appearance.sizes.elevationMargin + implicitWidth: Appearance.sizes.osdWidth + implicitHeight: valueIndicator.implicitHeight + + StyledRectangularShadow { + target: valueIndicator + } + WrapperRectangle { + id: valueIndicator + anchors.fill: parent + radius: Appearance.rounding.full + color: Appearance.colors.colLayer0 + implicitWidth: valueRow.implicitWidth + + 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 + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/onScreenKeyboard/OnScreenKeyboard.qml b/configs/quickshell/ii/modules/onScreenKeyboard/OnScreenKeyboard.qml new file mode 100644 index 0000000..eae543c --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenKeyboard/OnScreenKeyboard.qml @@ -0,0 +1,166 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: Config.options?.osk.pinnedOnStartup ?? false + + component OskControlButton: GroupButton { // Pin button + baseWidth: 40 + baseHeight: 40 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + } + + Loader { + id: oskLoader + active: GlobalStates.oskOpen + onActiveChanged: { + if (!oskLoader.active) { + Ydotool.releaseAllKeys(); + } + } + + sourceComponent: PanelWindow { // Window + id: oskRoot + visible: oskLoader.active && !GlobalStates.screenLocked + + anchors { + bottom: true + left: true + right: true + } + + function hide() { + oskLoader.active = false + } + exclusiveZone: root.pinned ? implicitHeight - Appearance.sizes.hyprlandGapsOut : 0 + implicitWidth: oskBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: oskBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:osk" + WlrLayershell.layer: WlrLayer.Overlay + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: oskBackground + } + + + // Background + StyledRectangularShadow { + target: oskBackground + } + Rectangle { + id: oskBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + property real padding: 10 + implicitWidth: oskRowLayout.implicitWidth + padding * 2 + implicitHeight: oskRowLayout.implicitHeight + padding * 2 + + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + oskRoot.hide() + } + } + + RowLayout { + id: oskRowLayout + anchors.centerIn: parent + spacing: 5 + VerticalButtonGroup { + OskControlButton { // Pin button + 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 + } + } + OskControlButton { + onClicked: () => { + oskRoot.hide() + } + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + text: "keyboard_hide" + iconSize: Appearance.font.pixelSize.larger + } + } + } + Rectangle { + Layout.topMargin: 20 + Layout.bottomMargin: 20 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + OskContent { + id: oskContent + Layout.fillWidth: true + } + } + } + + } + } + + IpcHandler { + target: "osk" + + function toggle(): void { + GlobalStates.oskOpen = !GlobalStates.oskOpen; + } + + function close(): void { + GlobalStates.oskOpen = false + } + + function open(): void { + GlobalStates.oskOpen = true + } + } + + GlobalShortcut { + name: "oskToggle" + description: "Toggles on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = !GlobalStates.oskOpen; + } + } + + GlobalShortcut { + name: "oskOpen" + description: "Opens on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = true + } + } + + GlobalShortcut { + name: "oskClose" + description: "Closes on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = false + } + } + +} diff --git a/configs/quickshell/ii/modules/onScreenKeyboard/OskContent.qml b/configs/quickshell/ii/modules/onScreenKeyboard/OskContent.qml new file mode 100644 index 0000000..df37969 --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenKeyboard/OskContent.qml @@ -0,0 +1,49 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import "layouts.js" as Layouts +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +Item { + id: root + property var layouts: Layouts.byName + property var activeLayoutName: (layouts.hasOwnProperty(Config.options?.osk.layout)) + ? Config.options?.osk.layout + : Layouts.defaultLayout + property var currentLayout: layouts[activeLayoutName] + + implicitWidth: keyRows.implicitWidth + implicitHeight: keyRows.implicitHeight + + ColumnLayout { + id: keyRows + anchors.fill: parent + spacing: 5 + + Repeater { + model: root.currentLayout.keys + + delegate: RowLayout { + id: keyRow + required property var modelData + spacing: 5 + + Repeater { + model: modelData + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + delegate: OskKey { + required property var modelData + keyData: modelData + } + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/onScreenKeyboard/OskKey.qml b/configs/quickshell/ii/modules/onScreenKeyboard/OskKey.qml new file mode 100644 index 0000000..5ae53b9 --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenKeyboard/OskKey.qml @@ -0,0 +1,121 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: root + property var keyData + property string key: keyData.label + property string type: keyData.keytype + property var keycode: keyData.keycode + property string shape: keyData.shape + property bool isShift: Ydotool.shiftKeys.includes(keycode) + property bool isBackspace: (key.toLowerCase() == "backspace") + property bool isEnter: (key.toLowerCase() == "enter" || key.toLowerCase() == "return") + property real baseWidth: 45 + property real baseHeight: 45 + property var widthMultiplier: ({ + "normal": 1, + "fn": 1, + "tab": 1.6, + "caps": 1.9, + "shift": 2.5, + "control": 1.3 + }) + property var heightMultiplier: ({ + "normal": 1, + "fn": 0.7, + "tab": 1, + "caps": 1, + "shift": 1, + "control": 1 + }) + toggled: isShift ? Ydotool.shiftMode : false + + enabled: shape != "empty" + colBackground: shape == "empty" ? ColorUtils.transparentize(Appearance.colors.colLayer1) : Appearance.colors.colLayer1 + buttonRadius: Appearance.rounding.small + implicitWidth: baseWidth * widthMultiplier[shape] || baseWidth + implicitHeight: baseHeight * heightMultiplier[shape] || baseHeight + Layout.fillWidth: shape == "space" || shape == "expand" + + Connections { + target: Ydotool + enabled: isShift + function onShiftModeChanged() { + if (Ydotool.shiftMode == 0) { + capsLockTimer.hasStarted = false; + } + } + } + + Timer { + id: capsLockTimer + property bool hasStarted: false + property bool canCaps: false + interval: 300 + function startWaiting() { + hasStarted = true; + canCaps = true; + start(); + } + onTriggered: { + canCaps = false; + } + } + + downAction: () => { + Ydotool.press(root.keycode); + if (isShift && Ydotool.shiftMode == 0) Ydotool.shiftMode = 1; + } + releaseAction: () => { + if (root.type == "normal") { + Ydotool.release(root.keycode); + if (Ydotool.shiftMode == 1) { + Ydotool.releaseShiftKeys() + } + } else if (isShift) { + if (Ydotool.shiftMode == 1) { + if (!capsLockTimer.hasStarted) { + capsLockTimer.startWaiting(); + } else { + if (capsLockTimer.canCaps) { + Ydotool.shiftMode = 2; // Caps lock mode + } else { + Ydotool.releaseShiftKeys() + } + } + } else if (Ydotool.shiftMode == 2) { + Ydotool.releaseShiftKeys(); + } + } else if (root.type == "modkey") { + root.toggled = !root.toggled; + if (!root.toggled) { + if (isShift) { + Ydotool.releaseShiftKeys(); + } else { + Ydotool.release(root.keycode); + } + } + } + + } + + contentItem: StyledText { + id: keyText + anchors.fill: parent + font.family: (isBackspace || isEnter) ? Appearance.font.family.iconMaterial : Appearance.font.family.main + font.pixelSize: root.shape == "fn" ? Appearance.font.pixelSize.small : + (isBackspace || isEnter) ? Appearance.font.pixelSize.huge : + Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: root.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + text: root.isBackspace ? "backspace" : root.isEnter ? "subdirectory_arrow_left" : + Ydotool.shiftMode == 2 ? (root.keyData.labelCaps || root.keyData.labelShift || root.keyData.label) : + Ydotool.shiftMode == 1 ? (root.keyData.labelShift || root.keyData.label) : + root.keyData.label + } +} diff --git a/configs/quickshell/ii/modules/onScreenKeyboard/layouts.js b/configs/quickshell/ii/modules/onScreenKeyboard/layouts.js new file mode 100644 index 0000000..949dd45 --- /dev/null +++ b/configs/quickshell/ii/modules/onScreenKeyboard/layouts.js @@ -0,0 +1,312 @@ +// We're going to use ydotool +// See /usr/include/linux/input-event-codes.h for keycodes + +const defaultLayout = "English (US)"; +const byName = { + "English (US)": { + name_short: "US", + description: "QWERTY - Full", + comment: "Like physical keyboard", + // A key looks like this: { k: "a", ks: "A", t: "normal" } (key, key-shift, type) + // key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand + // keys: [ + // [{ k: "Esc", t: "fn" }, { k: "F1", t: "fn" }, { k: "F2", t: "fn" }, { k: "F3", t: "fn" }, { k: "F4", t: "fn" }, { k: "F5", t: "fn" }, { k: "F6", t: "fn" }, { k: "F7", t: "fn" }, { k: "F8", t: "fn" }, { k: "F9", t: "fn" }, { k: "F10", t: "fn" }, { k: "F11", t: "fn" }, { k: "F12", t: "fn" }, { k: "PrtSc", t: "fn" }, { k: "Del", t: "fn" }], + // [{ k: "`", ks: "~", t: "normal" }, { k: "1", ks: "!", t: "normal" }, { k: "2", ks: "@", t: "normal" }, { k: "3", ks: "#", t: "normal" }, { k: "4", ks: "$", t: "normal" }, { k: "5", ks: "%", t: "normal" }, { k: "6", ks: "^", t: "normal" }, { k: "7", ks: "&", t: "normal" }, { k: "8", ks: "*", t: "normal" }, { k: "9", ks: "(", t: "normal" }, { k: "0", ks: ")", t: "normal" }, { k: "-", ks: "_", t: "normal" }, { k: "=", ks: "+", t: "normal" }, { k: "Backspace", t: "shift" }], + // [{ k: "Tab", t: "tab" }, { k: "q", ks: "Q", t: "normal" }, { k: "w", ks: "W", t: "normal" }, { k: "e", ks: "E", t: "normal" }, { k: "r", ks: "R", t: "normal" }, { k: "t", ks: "T", t: "normal" }, { k: "y", ks: "Y", t: "normal" }, { k: "u", ks: "U", t: "normal" }, { k: "i", ks: "I", t: "normal" }, { k: "o", ks: "O", t: "normal" }, { k: "p", ks: "P", t: "normal" }, { k: "[", ks: "{", t: "normal" }, { k: "]", ks: "}", t: "normal" }, { k: "\\", ks: "|", t: "expand" }], + // [{ k: "Caps", t: "caps" }, { k: "a", ks: "A", t: "normal" }, { k: "s", ks: "S", t: "normal" }, { k: "d", ks: "D", t: "normal" }, { k: "f", ks: "F", t: "normal" }, { k: "g", ks: "G", t: "normal" }, { k: "h", ks: "H", t: "normal" }, { k: "j", ks: "J", t: "normal" }, { k: "k", ks: "K", t: "normal" }, { k: "l", ks: "L", t: "normal" }, { k: ";", ks: ":", t: "normal" }, { k: "'", ks: '"', t: "normal" }, { k: "Enter", t: "expand" }], + // [{ k: "Shift", t: "shift" }, { k: "z", ks: "Z", t: "normal" }, { k: "x", ks: "X", t: "normal" }, { k: "c", ks: "C", t: "normal" }, { k: "v", ks: "V", t: "normal" }, { k: "b", ks: "B", t: "normal" }, { k: "n", ks: "N", t: "normal" }, { k: "m", ks: "M", t: "normal" }, { k: ",", ks: "<", t: "normal" }, { k: ".", ks: ">", t: "normal" }, { k: "/", ks: "?", t: "normal" }, { k: "Shift", t: "expand" }], + // [{ k: "Ctrl", t: "control" }, { k: "Fn", t: "normal" }, { k: "Win", t: "normal" }, { k: "Alt", t: "normal" }, { k: "Space", t: "space" }, { k: "Alt", t: "normal" }, { k: "Menu", t: "normal" }, { k: "Ctrl", t: "control" }] + // ] + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + // A modkey looks like this: {label: "Ctrl", shape: "control", keycode: 29, type: "modkey"} + // key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "PrtSc", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Del", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "`", labelShift: "~", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "@", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "#", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: "$", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: "^", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "&", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "*", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: "(", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: ")", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "-", labelShift: "_", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "=", labelShift: "+", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "Backspace", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "q", labelShift: "Q", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "w", labelShift: "W", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "e", labelShift: "E", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "r", labelShift: "R", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "t", labelShift: "T", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "y", labelShift: "Y", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "u", labelShift: "U", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "i", labelShift: "I", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "o", labelShift: "O", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "p", labelShift: "P", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "[", labelShift: "{", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "]", labelShift: "}", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "\\", labelShift: "|", shape: "expand", keycode: 43 } + ], + [ + //{ keytype: "normal", label: "Caps", shape: "caps", keycode: 58 }, // not needed as double-pressing shift does that + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "a", labelShift: "A", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "s", labelShift: "S", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "d", labelShift: "D", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "f", labelShift: "F", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "g", labelShift: "G", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "h", labelShift: "H", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "j", labelShift: "J", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "k", labelShift: "K", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "l", labelShift: "L", shape: "normal", keycode: 38 }, + { keytype: "normal", label: ";", labelShift: ":", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "'", labelShift: '"', shape: "normal", keycode: 40 }, + { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "z", labelShift: "Z", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "x", labelShift: "X", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "c", labelShift: "C", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "v", labelShift: "V", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "b", labelShift: "B", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "n", labelShift: "N", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "m", labelShift: "M", shape: "normal", keycode: 50 }, + { keytype: "normal", label: ",", labelShift: "<", shape: "normal", keycode: 51 }, + { keytype: "normal", label: ".", labelShift: ">", shape: "normal", keycode: 52 }, + { keytype: "normal", label: "/", labelShift: "?", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "expand", keycode: 54 } // optional + ], + [ + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, + // { label: "Super", shape: "normal", keycode: 125 }, // dangerous + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Space", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 100 }, + // { label: "Super", shape: "normal", keycode: 126 }, // dangerous + { keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 97 } + ] + ] + }, + "German": { + name_short: "DE", + description: "QWERTZ - Full", + comment: "Keyboard layout commonly used in German-speaking countries", + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "Druck", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Entf", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "^", labelShift: "ยฐ", labelAlt: "โ€ฒ", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", labelAlt: "ยน", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "\"", labelAlt: "ยฒ", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "ยง", labelAlt: "ยณ", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: "$", labelAlt: "ยผ", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", labelAlt: "ยฝ", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: "&", labelAlt: "ยฌ", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "/", labelAlt: "{", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "(", labelAlt: "[", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: ")", labelAlt: "]", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: "=", labelAlt: "}", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "รŸ", labelShift: "?", labelAlt: "\\", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "ยด", labelShift: "`", labelAlt: "ยธ", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "โŸต", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab โ‡†", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "q", labelShift: "Q", labelAlt: "@", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "w", labelShift: "W", labelAlt: "ลฟ", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "e", labelShift: "E", labelAlt: "โ‚ฌ", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "r", labelShift: "R", labelAlt: "ยถ", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "t", labelShift: "T", labelAlt: "ลง", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "z", labelShift: "Z", labelAlt: "โ†", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "u", labelShift: "U", labelAlt: "โ†“", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "i", labelShift: "I", labelAlt: "โ†’", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "o", labelShift: "O", labelAlt: "รธ", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "p", labelShift: "P", labelAlt: "รพ", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "รผ", labelShift: "รœ", labelAlt: "ยจ", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "+", labelShift: "*", labelAlt: "~", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "โ†ต", shape: "expand", keycode: 28 } + ], + [ + //{ keytype: "normal", label: "Umschalt โ‡ฉ", shape: "caps", keycode: 58 }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "a", labelShift: "A", labelAlt: "รฆ", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "s", labelShift: "S", labelAlt: "ลฟ", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "d", labelShift: "D", labelAlt: "รฐ", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "f", labelShift: "F", labelAlt: "ฤ‘", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "g", labelShift: "G", labelAlt: "ล‹", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "h", labelShift: "H", labelAlt: "ฤง", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "j", labelShift: "J", labelAlt: "", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "k", labelShift: "K", labelAlt: "ฤธ", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "l", labelShift: "L", labelAlt: "ล‚", shape: "normal", keycode: 38 }, + { keytype: "normal", label: "รถ", labelShift: "ร–", labelAlt: "ห", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "รค", labelShift: 'ร„', labelAlt: "^", shape: "normal", keycode: 40 }, + { keytype: "normal", label: "#", labelShift: '\'', labelAlt: "โ€™", shape: "normal", keycode: 43 }, + { keytype: "spacer", label: "", shape: "empty" }, + //{ keytype: "normal", label: "โ†ต", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", labelShift: "Shift โ‡ง", labelCaps: "Locked โ‡ฉ", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "<", labelShift: ">", labelAlt: "|", shape: "normal", keycode: 86 }, + { keytype: "normal", label: "y", labelShift: "Y", labelAlt: "ยป", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "x", labelShift: "X", labelAlt: "ยซ", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "c", labelShift: "C", labelAlt: "ยข", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "v", labelShift: "V", labelAlt: "โ€ž", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "b", labelShift: "B", labelAlt: "โ€œ", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "n", labelShift: "N", labelAlt: "โ€", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "m", labelShift: "M", labelAlt: "ยต", shape: "normal", keycode: 50 }, + { keytype: "normal", label: ",", labelShift: ";", labelAlt: "ยท", shape: "normal", keycode: 51 }, + { keytype: "normal", label: ".", labelShift: ":", labelAlt: "โ€ฆ", shape: "normal", keycode: 52 }, + { keytype: "normal", label: "-", labelShift: "_", labelAlt: "โ€“", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift โ‡ง", labelCaps: "Locked โ‡ฉ", shape: "expand", keycode: 54 }, // optional + ], + [ + { keytype: "modkey", label: "Strg", shape: "control", keycode: 29 }, + //{ keytype: "normal", label: "๏Œš", shape: "normal", keycode: 125 }, // dangerous + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Leertaste", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Altโ€‰Gr", shape: "normal", keycode: 100 }, + // { label: "Super", shape: "normal", keycode: 126 }, // dangerous + //{ keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, // doesn't work? + { keytype: "modkey", label: "Strg", shape: "control", keycode: 97 }, + { keytype: "normal", label: "โ‡ฆ", shape: "normal", keycode: 105 }, + { keytype: "normal", label: "โ‡จ", shape: "normal", keycode: 106 }, + ] + ] + }, + "Russian": { + name_short: "RU", + description: "ะ™ะฆะฃะšะ•ะ - Full", + comment: "Standard Russian keyboard layout", + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "PrtSc", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Del", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "ั‘", labelShift: "ะ", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "\"", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "โ„–", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: ";", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: ":", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "?", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "*", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: "(", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: ")", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "-", labelShift: "_", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "=", labelShift: "+", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "Backspace", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "ะน", labelShift: "ะ™", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "ั†", labelShift: "ะฆ", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "ัƒ", labelShift: "ะฃ", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "ะบ", labelShift: "ะš", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "ะต", labelShift: "ะ•", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "ะฝ", labelShift: "ะ", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "ะณ", labelShift: "ะ“", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "ัˆ", labelShift: "ะจ", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "ั‰", labelShift: "ะฉ", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "ะท", labelShift: "ะ—", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "ั…", labelShift: "ะฅ", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "ัŠ", labelShift: "ะช", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "\\", labelShift: "/", shape: "expand", keycode: 43 } + ], + [ + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "ั„", labelShift: "ะค", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "ั‹", labelShift: "ะซ", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "ะฒ", labelShift: "ะ’", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "ะฐ", labelShift: "ะ", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "ะฟ", labelShift: "ะŸ", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "ั€", labelShift: "ะ ", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "ะพ", labelShift: "ะž", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "ะป", labelShift: "ะ›", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "ะด", labelShift: "ะ”", shape: "normal", keycode: 38 }, + { keytype: "normal", label: "ะถ", labelShift: "ะ–", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "ั", labelShift: "ะญ", shape: "normal", keycode: 40 }, + { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "ั", labelShift: "ะฏ", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "ั‡", labelShift: "ะง", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "ั", labelShift: "ะก", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "ะผ", labelShift: "ะœ", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "ะธ", labelShift: "ะ˜", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "ั‚", labelShift: "ะข", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "ัŒ", labelShift: "ะฌ", shape: "normal", keycode: 50 }, + { keytype: "normal", label: "ะฑ", labelShift: "ะ‘", shape: "normal", keycode: 51 }, + { keytype: "normal", label: "ัŽ", labelShift: "ะฎ", shape: "normal", keycode: 52 }, + { keytype: "normal", label: ".", labelShift: ",", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", shape: "expand", keycode: 54 } + ], + [ + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Space", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 100 }, + { keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 97 } + ] + ] + } +} diff --git a/configs/quickshell/ii/modules/overview/Overview.qml b/configs/quickshell/ii/modules/overview/Overview.qml new file mode 100644 index 0000000..80c692b --- /dev/null +++ b/configs/quickshell/ii/modules/overview/Overview.qml @@ -0,0 +1,246 @@ +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: 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 + } + // HyprlandWindow.visibleMask: Region { // Buggy with scaled monitors + // item: GlobalStates.overviewOpen ? columnLayout : null + // } + + anchors { + top: true + bottom: true + left: !(Config?.options.overview.enable ?? true) + right: !(Config?.options.overview.enable ?? 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(); + } + + ColumnLayout { + id: columnLayout + visible: GlobalStates.overviewOpen + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + } + + 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"); + } + } + + Item { + height: 1 // Prevent Wayland protocol error + width: 1 // Prevent Wayland protocol error + } + + SearchWidget { + id: searchWidget + Layout.alignment: Qt.AlignHCenter + onSearchingTextChanged: text => { + root.searchingText = searchingText; + } + } + + Loader { + id: overviewLoader + 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 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: "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/configs/quickshell/ii/modules/overview/Overview.qml.template b/configs/quickshell/ii/modules/overview/Overview.qml.template new file mode 100644 index 0000000..9c44b52 --- /dev/null +++ b/configs/quickshell/ii/modules/overview/Overview.qml.template @@ -0,0 +1,160 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland + +Rectangle { + id: overview + + property bool visible: false + property var workspaces: [] + property var windows: [] + + color: "@BACKGROUND_COLOR@" + radius: 12 + + // Window previews with drag-and-drop + GridView { + id: windowGrid + anchors.fill: parent + anchors.margins: 20 + + cellWidth: 300 + cellHeight: 200 + + model: overview.windows + + delegate: Rectangle { + width: windowGrid.cellWidth - 10 + height: windowGrid.cellHeight - 10 + + color: "@SURFACE_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: 1 + + // Window preview + Image { + id: windowPreview + anchors.fill: parent + anchors.margins: 4 + source: modelData.preview || "" + fillMode: Image.PreserveAspectFit + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 30 + color: "@SURFACE_VARIANT_COLOR@" + radius: 4 + + Text { + anchors.centerIn: parent + text: modelData.title || "Unknown" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + + // Drag and drop functionality + MouseArea { + anchors.fill: parent + drag.target: parent + + onClicked: { + // Focus window + HyprlandIpc.dispatch("focuswindow", "address:" + modelData.address) + overview.visible = false + } + + onReleased: { + // Handle workspace drop + var workspace = getWorkspaceAt(parent.x, parent.y) + if (workspace) { + HyprlandIpc.dispatch("movetoworkspace", workspace + ",address:" + modelData.address) + } + } + } + } + } + + // Workspace indicators + Row { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.margins: 20 + spacing: 10 + + Repeater { + model: overview.workspaces + + Rectangle { + width: 40 + height: 8 + radius: 4 + color: modelData.active ? "@PRIMARY_COLOR@" : "@OUTLINE_COLOR@" + + MouseArea { + anchors.fill: parent + onClicked: { + HyprlandIpc.dispatch("workspace", modelData.id) + overview.visible = false + } + } + } + } + } + + // Search functionality + Rectangle { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.margins: 20 + + width: 400 + height: 40 + radius: 20 + color: "@SURFACE_COLOR@" + border.color: "@OUTLINE_COLOR@" + + TextInput { + id: searchInput + anchors.fill: parent + anchors.margins: 15 + + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 14 + placeholderText: "Search applications, calculate, or run commands..." + + onTextChanged: { + // Implement search logic + performSearch(text) + } + + Keys.onReturnPressed: { + // Execute search result + executeSearchResult() + } + } + } + + function performSearch(query) { + // Implementation for search functionality + // - Application search + // - Calculator + // - Command execution + // - Directory navigation + } + + function executeSearchResult() { + // Execute the selected search result + } + + function getWorkspaceAt(x, y) { + // Determine workspace based on drop position + return null + } +} diff --git a/configs/quickshell/ii/modules/overview/OverviewWidget.qml b/configs/quickshell/ii/modules/overview/OverviewWidget.qml new file mode 100644 index 0000000..550d72c --- /dev/null +++ b/configs/quickshell/ii/modules/overview/OverviewWidget.qml @@ -0,0 +1,268 @@ +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.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 workspaceNumberMargin: 80 + property real workspaceNumberSize: Math.min(workspaceImplicitHeight, workspaceImplicitWidth) * 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: Appearance.rounding.screenRounding * root.scale + padding + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { // Workspaces + id: workspaceColumnLayout + + z: root.workspaceZ + anchors.centerIn: parent + spacing: workspaceSpacing + Repeater { + model: Config.options.overview.rows + delegate: RowLayout { + id: row + property int rowIndex: index + spacing: workspaceSpacing + + Repeater { // Workspace repeater + model: Config.options.overview.columns + Rectangle { // Workspace + id: workspace + property int colIndex: index + property int workspaceValue: root.workspaceGroup * workspacesShown + rowIndex * Config.options.overview.columns + colIndex + 1 + property color defaultWorkspaceColor: Appearance.colors.colLayer1 // TODO: reconsider this color for a cleaner look + 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 + radius: Appearance.rounding.screenRounding * root.scale + border.width: 2 + border.color: hoveredWhileDragging ? hoveredBorderColor : "transparent" + + StyledText { + anchors.centerIn: parent + text: workspaceValue + font.pixelSize: root.workspaceNumberSize * root.scale + font.weight: Font.DemiBold + color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, 0.8) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + MouseArea { + id: workspaceArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: { + if (root.draggingTargetWorkspace === -1) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`workspace ${workspaceValue}`) + } + } + } + + DropArea { + anchors.fill: parent + onEntered: { + root.draggingTargetWorkspace = workspaceValue + if (root.draggingFromWorkspace == root.draggingTargetWorkspace) return; + hoveredWhileDragging = true + } + onExited: { + hoveredWhileDragging = false + if (root.draggingTargetWorkspace == 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) + const inMonitor = root.monitor.id === win.monitor + return inWorkspaceGroup && inMonitor; + }) + } + } + delegate: OverviewWindow { + id: window + required property var modelData + property var address: `0x${modelData.HyprlandToplevel.address}` + windowData: windowByAddress[address] + toplevel: modelData + monitorData: HyprlandData.monitors[monitorId] + scale: root.scale + availableWorkspaceWidth: root.workspaceImplicitWidth + availableWorkspaceHeight: root.workspaceImplicitHeight + + property int monitorId: windowData?.monitor + property var monitor: HyprlandData.monitors[monitorId] + + property bool atInitPosition: (initX == x && initY == y) + + 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 + + Timer { + id: updateWindowPosition + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + running: false + onTriggered: { + window.x = Math.round(Math.max((windowData?.at[0] - (monitor?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + xOffset) + window.y = Math.round(Math.max((windowData?.at[1] - (monitor?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + yOffset) + } + } + + z: atInitPosition ? root.windowZ : root.windowDraggingZ + Drag.hotSpot.x: targetWindowWidth / 2 + Drag.hotSpot.y: targetWindowHeight / 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 { + window.x = window.initX + window.y = window.initY + } + } + 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 + content: `${windowData.title}\n[${windowData.class}] ${windowData.xwayland ? "[XWayland] " : ""}\n` + } + } + } + } + + Rectangle { // Focused workspace indicator + id: focusedWorkspaceIndicator + property int activeWorkspaceInGroup: monitor.activeWorkspace?.id - (root.workspaceGroup * root.workspacesShown) + property int activeWorkspaceRowIndex: Math.floor((activeWorkspaceInGroup - 1) / Config.options.overview.columns) + property int activeWorkspaceColIndex: (activeWorkspaceInGroup - 1) % Config.options.overview.columns + x: (root.workspaceImplicitWidth + workspaceSpacing) * activeWorkspaceColIndex + y: (root.workspaceImplicitHeight + workspaceSpacing) * activeWorkspaceRowIndex + z: root.windowZ + width: root.workspaceImplicitWidth + height: root.workspaceImplicitHeight + color: "transparent" + radius: Appearance.rounding.screenRounding * root.scale + 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) + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/overview/OverviewWindow.qml b/configs/quickshell/ii/modules/overview/OverviewWindow.qml new file mode 100644 index 0000000..8029ec4 --- /dev/null +++ b/configs/quickshell/ii/modules/overview/OverviewWindow.qml @@ -0,0 +1,114 @@ +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 + +Item { // Window + id: root + property var toplevel + property var windowData + property var monitorData + property var scale + property var availableWorkspaceWidth + property var availableWorkspaceHeight + property bool restrictToWorkspace: true + property real initX: Math.max((windowData?.at[0] - (monitorData?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + xOffset + property real initY: Math.max((windowData?.at[1] - (monitorData?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + yOffset + property real xOffset: 0 + property real yOffset: 0 + + property var targetWindowWidth: windowData?.size[0] * scale + property var targetWindowHeight: windowData?.size[1] * scale + property bool hovered: false + property bool pressed: false + + property var iconToWindowRatio: 0.35 + property var xwaylandIndicatorToIconRatio: 0.35 + property var iconToWindowRatioCompact: 0.6 + property var 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: windowData?.size[0] * root.scale + height: windowData?.size[1] * root.scale + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: Appearance.rounding.windowRounding * root.scale + } + } + + 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 + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.windowRounding * root.scale + 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.7) + border.width : 1 + } + + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.font.pixelSize.smaller * 0.5 + + Image { + id: windowIcon + 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 Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / root.monitorData.scale; + } + // 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) + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/overview/SearchItem.qml b/configs/quickshell/ii/modules/overview/SearchItem.qml new file mode 100644 index 0000000..921fc8b --- /dev/null +++ b/configs/quickshell/ii/modules/overview/SearchItem.qml @@ -0,0 +1,267 @@ +// pragma NativeMethodBehavior: AcceptThisObject +import qs +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 + property string bigText: entry?.bigText ?? "" + property string materialSymbol: entry?.materialSymbol ?? "" + property string cliphistRawString: entry?.cliphistRawString ?? "" + + visible: root.entryShown + property int horizontalMargin: 10 + property int buttonHorizontalPadding: 10 + property int buttonVerticalPadding: 5 + 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.colSecondaryContainerActive : + ((root.hovered || root.focus) ? Appearance.colors.colSecondaryContainerHover : + ColorUtils.transparentize(Appearance.colors.colSecondaryContainer, 1)) + colBackgroundHover: Appearance.colors.colSecondaryContainerHover + colRipple: Appearance.colors.colSecondaryContainerActive + + 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: { + root.itemExecute() + GlobalStates.overviewOpen = false + } + Keys.onPressed: (event) => { + 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 && /^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(root.cliphistRawString) + sourceComponent: CliphistImage { + Layout.fillWidth: true + entry: root.cliphistRawString + maxWidth: contentColumn.width + maxHeight: 140 + } + } + } + + // Action text + StyledText { + Layout.fillWidth: false + visible: (root.hovered || root.focus) + id: clickAction + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: root.itemClickActionName + } + + RowLayout { + spacing: 4 + Repeater { + model: (root.entry.actions ?? []).slice(0, 4) + delegate: RippleButton { + id: actionButton + required property var modelData + implicitHeight: 34 + implicitWidth: 34 + + contentItem: Item { + id: actionContentItem + anchors.centerIn: parent + Loader { + anchors.centerIn: parent + active: !(actionButton.modelData.icon && actionButton.modelData.icon !== "") + sourceComponent: MaterialSymbol { + text: "video_settings" + font.pixelSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onSurface + } + } + Loader { + anchors.centerIn: parent + active: actionButton.modelData.icon && actionButton.modelData.icon !== "" + sourceComponent: IconImage { + source: Quickshell.iconPath(actionButton.modelData.icon) + implicitSize: 20 + } + } + } + + onClicked: modelData.execute() + + StyledToolTip { + content: modelData.name + } + } + } + } + + } +} diff --git a/configs/quickshell/ii/modules/overview/SearchWidget.qml b/configs/quickshell/ii/modules/overview/SearchWidget.qml new file mode 100644 index 0000000..51100d2 --- /dev/null +++ b/configs/quickshell/ii/modules/overview/SearchWidget.qml @@ -0,0 +1,423 @@ +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 + +Item { // Wrapper + id: root + readonly property string xdgConfigHome: Directories.config + property string searchingText: "" + property bool showResults: searchingText != "" + property real searchBarHeight: searchBar.height + Appearance.sizes.elevationMargin * 2 + implicitWidth: searchWidgetContent.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: searchWidgetContent.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property string mathResult: "" + + function disableExpandAnimation() { + searchWidthBehavior.enabled = false; + } + + function cancelSearch() { + searchInput.selectAll(); + root.searchingText = ""; + searchWidthBehavior.enabled = true; + } + + function setSearchingText(text) { + searchInput.text = text; + root.searchingText = text; + } + + property var searchActions: [ + { + action: "dark", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]); + } + }, + { + action: "light", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]); + } + }, + { + action: "wall", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath]); + } + }, + { + action: "konachanwall", + execute: () => { + Quickshell.execDetached([Quickshell.shellPath("scripts/colors/random_konachan_wall.sh")]); + } + }, + { + action: "accentcolor", + execute: args => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--noswitch", "--color", ...(args != '' ? [`${args}`] : [])]); + } + }, + { + action: "todo", + execute: args => { + Todo.addTask(args); + } + }, + ] + + function focusFirstItem() { + appResults.currentIndex = 0; + } + + Timer { + id: nonAppResultsTimer + interval: Config.options.search.nonAppResultDelay + onTriggered: { + mathProcess.calculateExpression(root.searchingText); + } + } + + 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 (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + if (event.modifiers & Qt.ControlModifier) { + // Delete word before cursor + let text = searchInput.text; + let pos = 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; + searchInput.text = text.slice(0, pos - deleteLen) + text.slice(pos); + searchInput.cursorPosition = pos - deleteLen; + } + } else { + // Delete character before cursor if any + if (searchInput.cursorPosition > 0) { + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition - 1) + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition -= 1; + } + } + // Always move cursor to end after programmatic edit + searchInput.cursorPosition = 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.text.charCodeAt(0) >= 0x20) // ignore control chars like Backspace, Tab, etc. + { + if (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + // Insert the character at the cursor position + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition) + event.text + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition += 1; + event.accepted = true; + } + } + } + + StyledRectangularShadow { + target: searchWidgetContent + } + Rectangle { // Background + id: searchWidgetContent + anchors.centerIn: parent + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + radius: Appearance.rounding.large + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 0 + + // clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: searchWidgetContent.width + height: searchWidgetContent.width + radius: searchWidgetContent.radius + } + } + + RowLayout { + id: searchBar + spacing: 5 + MaterialSymbol { + id: searchIcon + Layout.leftMargin: 15 + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onSurface + text: root.searchingText.startsWith(Config.options.search.prefix.clipboard) ? 'content_paste_search' : 'search' + } + TextField { // Search box + id: searchInput + + focus: GlobalStates.overviewOpen + Layout.rightMargin: 15 + padding: 15 + renderType: Text.NativeRendering + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: Translation.tr("Search, calculate or run") + placeholderTextColor: Appearance.m3colors.m3outline + implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth + + Behavior on implicitWidth { + id: searchWidthBehavior + enabled: false + 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(); + } + } + } + + background: null + + cursorDelegate: Rectangle { + width: 1 + color: searchInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + } + + Rectangle { + // Separator + visible: root.showResults + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + StyledListView { // 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 + add: null + remove: null + + onFocusChanged: { + if (focus) + appResults.currentIndex = 1; + } + + Connections { + target: root + function onSearchingTextChanged() { + if (appResults.count > 0) + appResults.currentIndex = 0; + } + } + + model: ScriptModel { + id: model + onValuesChanged: { + root.focusFirstItem(); + } + 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 = root.searchingText.slice(Config.options.search.prefix.clipboard.length); + return Cliphist.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: `#${entry.match(/^\s*(\S+)/)?.[1] || ""}`, + execute: () => { + Cliphist.copy(entry) + }, + actions: [ + { + name: "Delete", + icon: "delete", + execute: () => { + Cliphist.deleteEntry(entry); + } + } + ] + }; + }).filter(Boolean); + } + if (root.searchingText.startsWith(Config.options.search.prefix.emojis)) { + // Clipboard + const searchString = root.searchingText.slice(Config.options.search.prefix.emojis.length); + return Emojis.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + bigText: entry.match(/^\s*(\S+)/)?.[1] || "", + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: "Emoji", + execute: () => { + Quickshell.clipboardText = entry.match(/^\s*(\S+)/)?.[1]; + } + }; + }).filter(Boolean); + } + + ////////////////// Init /////////////////// + nonAppResultsTimer.restart(); + const mathResultObject = { + name: root.mathResult, + clickActionName: Translation.tr("Copy"), + type: Translation.tr("Math result"), + fontType: "monospace", + materialSymbol: 'calculate', + execute: () => { + Quickshell.clipboardText = root.mathResult; + } + }; + const commandResultObject = { + name: searchingText.replace("file://", ""), + clickActionName: Translation.tr("Run"), + type: Translation.tr("Run command"), + fontType: "monospace", + materialSymbol: 'terminal', + execute: () => { + const cleanedCommand = root.searchingText.replace("file://", ""); + Quickshell.execDetached(["bash", "-c", searchingText.startsWith('sudo') ? `${Config.options.apps.terminal} fish -C '${cleanedCommand}'` : cleanedCommand]); + } + }; + 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 { + 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); + + let result = []; + + //////////////// Apps ////////////////// + result = result.concat(AppSearch.fuzzyQuery(root.searchingText).map(entry => { + entry.clickActionName = Translation.tr("Launch"); + entry.type = Translation.tr("App"); + return entry; + })); + + ////////// Launcher actions //////////// + result = result.concat(launcherActionObjects); + + /////////// Math result & command ////////// + const startsWithNumber = /^\d/.test(root.searchingText); + if (startsWithNumber) { + result.push(mathResultObject); + result.push(commandResultObject); + } else { + result.push(commandResultObject); + result.push(mathResultObject); + } + + ///////////////// Web search //////////////// + result.push({ + name: root.searchingText, + clickActionName: Translation.tr("Search"), + type: Translation.tr("Search the web"), + materialSymbol: 'travel_explore', + execute: () => { + let url = Config.options.search.engineBaseUrl + root.searchingText; + for (let site of Config.options.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + }); + + 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: root.searchingText.startsWith(Config.options.search.prefix.clipboard) ? root.searchingText.slice(Config.options.search.prefix.clipboard.length) : root.searchingText + } + } + } + } +} diff --git a/configs/quickshell/ii/modules/overview/qmldir b/configs/quickshell/ii/modules/overview/qmldir new file mode 100644 index 0000000..ddc1b05 --- /dev/null +++ b/configs/quickshell/ii/modules/overview/qmldir @@ -0,0 +1,5 @@ +module qs.modules.overview + +Overview 1.0 Overview.qml +OverviewWidget 1.0 OverviewWidget.qml +OverviewWindow 1.0 OverviewWindow.qml diff --git a/configs/quickshell/ii/modules/qmldir b/configs/quickshell/ii/modules/qmldir new file mode 100644 index 0000000..3175f53 --- /dev/null +++ b/configs/quickshell/ii/modules/qmldir @@ -0,0 +1 @@ +module qs.modules diff --git a/configs/quickshell/ii/modules/screenCorners/ScreenCorners.qml b/configs/quickshell/ii/modules/screenCorners/ScreenCorners.qml new file mode 100644 index 0000000..5bfed5c --- /dev/null +++ b/configs/quickshell/ii/modules/screenCorners/ScreenCorners.qml @@ -0,0 +1,66 @@ +import qs.modules.common +import qs.modules.common.widgets +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 + + component CornerPanelWindow: PanelWindow { + id: cornerPanelWindow + visible: (Config.options.appearance.fakeScreenRounding === 1 || (Config.options.appearance.fakeScreenRounding === 2 && !activeWindow?.fullscreen)) + property var corner + + exclusionMode: ExclusionMode.Ignore + mask: Region { + item: null + } + WlrLayershell.namespace: "quickshell:screenCorners" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: cornerPanelWindow.corner === RoundCorner.CornerEnum.TopLeft || cornerPanelWindow.corner === RoundCorner.CornerEnum.TopRight + left: cornerPanelWindow.corner === RoundCorner.CornerEnum.TopLeft || cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomLeft + bottom: cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomLeft || cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomRight + right: cornerPanelWindow.corner === RoundCorner.CornerEnum.TopRight || cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomRight + } + + implicitWidth: cornerWidget.implicitWidth + implicitHeight: cornerWidget.implicitHeight + RoundCorner { + id: cornerWidget + size: Appearance.rounding.screenRounding + corner: cornerPanelWindow.corner + } + } + + Variants { + model: Quickshell.screens + + Scope { + required property var modelData + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopLeft + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopRight + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomLeft + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomRight + } + } + } +} diff --git a/configs/quickshell/ii/modules/screenCorners/ScreenCorners.qml.template b/configs/quickshell/ii/modules/screenCorners/ScreenCorners.qml.template new file mode 100644 index 0000000..515950e --- /dev/null +++ b/configs/quickshell/ii/modules/screenCorners/ScreenCorners.qml.template @@ -0,0 +1,162 @@ +import QtQuick +import Quickshell + +ShellRoot { + // Top-left corner - Overview + PanelWindow { + id: topLeftCorner + anchors { + top: true + left: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + // Trigger overview + triggerCornerAction("top-left") + } + } + } + } + + // Top-right corner - Brightness control + PanelWindow { + id: topRightCorner + anchors { + top: true + right: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onWheel: { + // Brightness control + var delta = wheel.angleDelta.y > 0 ? 5 : -5 + adjustBrightness(delta) + } + + onEntered: { + // Show brightness OSD + showBrightnessOSD() + } + } + } + } + + // Bottom-left corner - Sidebar + PanelWindow { + id: bottomLeftCorner + anchors { + bottom: true + left: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + // Toggle left sidebar + triggerCornerAction("bottom-left") + } + } + } + } + + // Bottom-right corner - Session menu + PanelWindow { + id: bottomRightCorner + anchors { + bottom: true + right: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + // Show session menu + triggerCornerAction("bottom-right") + } + } + } + } + + function triggerCornerAction(corner) { + switch(corner) { + case "top-left": + // Show overview + Process.start("quickshell", ["-c", "overview"]) + break + case "top-right": + // Show brightness OSD + showBrightnessOSD() + break + case "bottom-left": + // Toggle sidebar + Process.start("quickshell", ["-c", "toggle-sidebar"]) + break + case "bottom-right": + // Show session menu + Process.start("quickshell", ["-c", "session-menu"]) + break + } + } + + function adjustBrightness(delta) { + // Brightness adjustment implementation + var currentBrightness = getCurrentBrightness() + var newBrightness = Math.max(0, Math.min(100, currentBrightness + delta)) + setBrightness(newBrightness) + showBrightnessOSD() + } + + function getCurrentBrightness() { + // Get current brightness (placeholder) + return 50 + } + + function setBrightness(value) { + // Set brightness (placeholder) + Process.start("brightnessctl", ["set", value + "%"]) + } + + function showBrightnessOSD() { + // Show brightness OSD (placeholder) + Process.start("quickshell", ["-c", "brightness-osd"]) + } +} diff --git a/configs/quickshell/ii/modules/session/Session.qml b/configs/quickshell/ii/modules/session/Session.qml new file mode 100644 index 0000000..6df45cd --- /dev/null +++ b/configs/quickshell/ii/modules/session/Session.qml @@ -0,0 +1,317 @@ +import qs.modules.common +import qs +import qs.services +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 closeAllWindows() { + HyprlandData.windowList.map(w => w.pid).forEach((pid) => { + Quickshell.execDetached(["kill", pid]); + }); + } + + function detectRunningStuff() { + packageManagerRunning = false; + downloadRunning = false; + detectPackageManagerProc.running = false; + detectPackageManagerProc.running = true; + detectDownloadProc.running = false; + detectDownloadProc.running = true; + } + + Process { + id: detectPackageManagerProc + command: ["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 + color: ColorUtils.transparentize(Appearance.m3colors.m3background, 0.3) + + 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 + font.pixelSize: Appearance.font.pixelSize.title + font.weight: Font.DemiBold + 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: { Quickshell.execDetached(["loginctl", "lock-session"]); 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: { Quickshell.execDetached(["bash", "-c", "systemctl suspend || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["pkill", "Hyprland"]); 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: { Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLogout + KeyNavigation.down: sessionFirmwareReboot + } + + SessionActionButton { + id: sessionHibernate + buttonIcon: "downloading" + buttonText: Translation.tr("Hibernate") + onClicked: { Quickshell.execDetached(["bash", "-c", `systemctl hibernate || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["bash", "-c", `systemctl poweroff || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["bash", "-c", `reboot || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["bash", "-c", `systemctl reboot --firmware-setup || loginctl reboot --firmware-setup`]); 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/configs/quickshell/ii/modules/session/SessionActionButton.qml b/configs/quickshell/ii/modules/session/SessionActionButton.qml new file mode 100644 index 0000000..199f2ab --- /dev/null +++ b/configs/quickshell/ii/modules/session/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 { + content: buttonText + } + +} diff --git a/configs/quickshell/ii/modules/session/SessionManager.qml.template b/configs/quickshell/ii/modules/session/SessionManager.qml.template new file mode 100644 index 0000000..39ad724 --- /dev/null +++ b/configs/quickshell/ii/modules/session/SessionManager.qml.template @@ -0,0 +1,264 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: sessionManager + + property bool visible: false + + width: 300 + height: 200 + color: "@SURFACE_COLOR@" + radius: 12 + border.color: "@OUTLINE_COLOR@" + + // Fade in/out animation + opacity: visible ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + GridLayout { + anchors.centerIn: parent + columns: 2 + rowSpacing: 15 + columnSpacing: 15 + + // Lock + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "๐Ÿ”’" + font.pixelSize: 24 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Lock" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + executeCommand("hyprlock") + sessionManager.visible = false + } + } + + // Logout + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "๐Ÿšช" + font.pixelSize: 24 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Logout" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + executeCommand("hyprctl dispatch exit") + sessionManager.visible = false + } + } + + // Reboot + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "๐Ÿ”„" + font.pixelSize: 24 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Reboot" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + showConfirmDialog("reboot") + } + } + + // Shutdown + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@ERROR_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "โป" + font.pixelSize: 24 + color: "@ON_ERROR_COLOR@" + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Shutdown" + color: "@ON_ERROR_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + showConfirmDialog("shutdown") + } + } + } + + // Confirmation dialog + Rectangle { + id: confirmDialog + anchors.centerIn: parent + width: 250 + height: 120 + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + visible: false + + property string action: "" + + Column { + anchors.centerIn: parent + spacing: 15 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Are you sure?" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 10 + + Button { + text: "Cancel" + onClicked: { + confirmDialog.visible = false + } + } + + Button { + text: "Confirm" + background: Rectangle { + color: "@ERROR_COLOR@" + radius: 4 + } + onClicked: { + if (confirmDialog.action === "reboot") { + executeCommand("systemctl reboot") + } else if (confirmDialog.action === "shutdown") { + executeCommand("systemctl poweroff") + } + confirmDialog.visible = false + sessionManager.visible = false + } + } + } + } + } + + function executeCommand(command) { + // Execute system command + Qt.callLater(function() { + Process.start(command.split(" ")[0], command.split(" ").slice(1)) + }) + } + + function showConfirmDialog(action) { + confirmDialog.action = action + confirmDialog.visible = true + } + + // Close on click outside + MouseArea { + anchors.fill: parent + onClicked: { + if (!confirmDialog.visible) { + sessionManager.visible = false + } + } + } +} diff --git a/configs/quickshell/ii/modules/settings/About.qml b/configs/quickshell/ii/modules/settings/About.qml new file mode 100644 index 0000000..f9369c8 --- /dev/null +++ b/configs/quickshell/ii/modules/settings/About.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + 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 { + 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/configs/quickshell/ii/modules/settings/AdvancedConfig.qml b/configs/quickshell/ii/modules/settings/AdvancedConfig.qml new file mode 100644 index 0000000..a40f191 --- /dev/null +++ b/configs/quickshell/ii/modules/settings/AdvancedConfig.qml @@ -0,0 +1,45 @@ +import QtQuick +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + title: Translation.tr("Color generation") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Shell & utilities") + checked: Config.options.appearance.wallpaperTheming.enableAppsAndShell + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableAppsAndShell = checked; + } + } + ConfigSwitch { + text: Translation.tr("Qt apps") + checked: Config.options.appearance.wallpaperTheming.enableQtApps + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableQtApps = checked; + } + StyledToolTip { + content: Translation.tr("Shell & utilities theming must also be enabled") + } + } + ConfigSwitch { + text: Translation.tr("Terminal") + checked: Config.options.appearance.wallpaperTheming.enableTerminal + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableTerminal = checked; + } + StyledToolTip { + content: Translation.tr("Shell & utilities theming must also be enabled") + } + } + + } + } +} diff --git a/configs/quickshell/ii/modules/settings/InterfaceConfig.qml b/configs/quickshell/ii/modules/settings/InterfaceConfig.qml new file mode 100644 index 0000000..4d86fff --- /dev/null +++ b/configs/quickshell/ii/modules/settings/InterfaceConfig.qml @@ -0,0 +1,424 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + ContentSection { + title: Translation.tr("Policies") + + ConfigRow { + ColumnLayout { + // Weeb policy + ContentSubsectionLabel { + text: Translation.tr("Weeb") + } + ConfigSelectionArray { + currentValue: Config.options.policies.weeb + configOptionName: "policies.weeb" + onSelected: newValue => { + Config.options.policies.weeb = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + value: 0 + }, + { + displayName: Translation.tr("Yes"), + value: 1 + }, + { + displayName: Translation.tr("Closet"), + value: 2 + } + ] + } + } + + ColumnLayout { + // AI policy + ContentSubsectionLabel { + text: Translation.tr("AI") + } + ConfigSelectionArray { + currentValue: Config.options.policies.ai + configOptionName: "policies.ai" + onSelected: newValue => { + Config.options.policies.ai = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + value: 0 + }, + { + displayName: Translation.tr("Yes"), + value: 1 + }, + { + displayName: Translation.tr("Local only"), + value: 2 + } + ] + } + } + } + } + + ContentSection { + title: Translation.tr("Bar") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + configOptionName: "bar.cornerStyle" + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; + } + options: [ + { + displayName: Translation.tr("Hug"), + value: 0 + }, + { + displayName: Translation.tr("Float"), + value: 1 + }, + { + displayName: Translation.tr("Plain rectangle"), + value: 2 + } + ] + } + + ContentSubsection { + title: Translation.tr("Overall appearance") + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr('Borderless') + checked: Config.options.bar.borderless + onCheckedChanged: { + Config.options.bar.borderless = checked; + } + } + ConfigSwitch { + text: Translation.tr('Show background') + checked: Config.options.bar.showBackground + onCheckedChanged: { + Config.options.bar.showBackground = checked; + } + StyledToolTip { + content: Translation.tr("Note: turning off can hurt readability") + } + } + } + } + + ContentSubsection { + title: Translation.tr("Buttons") + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Screen snip") + checked: Config.options.bar.utilButtons.showScreenSnip + onCheckedChanged: { + Config.options.bar.utilButtons.showScreenSnip = checked; + } + } + ConfigSwitch { + text: Translation.tr("Color picker") + checked: Config.options.bar.utilButtons.showColorPicker + onCheckedChanged: { + Config.options.bar.utilButtons.showColorPicker = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Mic toggle") + checked: Config.options.bar.utilButtons.showMicToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showMicToggle = checked; + } + } + ConfigSwitch { + text: Translation.tr("Keyboard toggle") + checked: Config.options.bar.utilButtons.showKeyboardToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showKeyboardToggle = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Dark/Light toggle") + checked: Config.options.bar.utilButtons.showDarkModeToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showDarkModeToggle = checked; + } + } + ConfigSwitch { + text: Translation.tr("Performance Profile toggle") + checked: Config.options.bar.utilButtons.showPerformanceProfileToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showPerformanceProfileToggle = checked; + } + } + } + } + + ContentSubsection { + title: Translation.tr("Workspaces") + tooltip: Translation.tr("Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr('Show app icons') + checked: Config.options.bar.workspaces.showAppIcons + onCheckedChanged: { + Config.options.bar.workspaces.showAppIcons = checked; + } + } + ConfigSwitch { + text: Translation.tr('Tint app icons') + checked: Config.options.bar.workspaces.monochromeIcons + onCheckedChanged: { + Config.options.bar.workspaces.monochromeIcons = checked; + } + } + } + ConfigSwitch { + text: Translation.tr('Always show numbers') + checked: Config.options.bar.workspaces.alwaysShowNumbers + onCheckedChanged: { + Config.options.bar.workspaces.alwaysShowNumbers = checked; + } + } + ConfigSpinBox { + 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 { + 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("Tray") + + ConfigSwitch { + text: Translation.tr('Tint icons') + checked: Config.options.bar.tray.monochromeIcons + onCheckedChanged: { + Config.options.bar.tray.monochromeIcons = checked; + } + } + } + + ContentSubsection { + title: Translation.tr("Weather") + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.bar.weather.enable + onCheckedChanged: { + Config.options.bar.weather.enable = checked; + } + } + } + } + + ContentSection { + title: Translation.tr("Battery") + + ConfigRow { + uniform: true + ConfigSpinBox { + text: Translation.tr("Low warning") + value: Config.options.battery.low + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.low = value; + } + } + ConfigSpinBox { + text: Translation.tr("Critical warning") + value: Config.options.battery.critical + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.critical = value; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Automatic suspend") + checked: Config.options.battery.automaticSuspend + onCheckedChanged: { + Config.options.battery.automaticSuspend = checked; + } + StyledToolTip { + content: Translation.tr("Automatically suspends the system when battery is low") + } + } + ConfigSpinBox { + text: Translation.tr("Suspend at") + value: Config.options.battery.suspend + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.suspend = value; + } + } + } + } + + ContentSection { + title: Translation.tr("Dock") + + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.dock.enable + onCheckedChanged: { + Config.options.dock.enable = checked; + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Hover to reveal") + checked: Config.options.dock.hoverToReveal + onCheckedChanged: { + Config.options.dock.hoverToReveal = checked; + } + } + ConfigSwitch { + text: Translation.tr("Pinned on startup") + checked: Config.options.dock.pinnedOnStartup + onCheckedChanged: { + Config.options.dock.pinnedOnStartup = checked; + } + } + } + ConfigSwitch { + text: Translation.tr("Tint app icons") + checked: Config.options.dock.monochromeIcons + onCheckedChanged: { + Config.options.dock.monochromeIcons = checked; + } + } + } + + ContentSection { + title: Translation.tr("Sidebars") + ConfigSwitch { + text: Translation.tr('Keep right sidebar loaded') + checked: Config.options.sidebar.keepRightSidebarLoaded + onCheckedChanged: { + Config.options.sidebar.keepRightSidebarLoaded = checked; + } + StyledToolTip { + content: 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 different kernel might help with this delay") + } + } + } + + ContentSection { + title: Translation.tr("On-screen display") + ConfigSpinBox { + text: Translation.tr("Timeout (ms)") + value: Config.options.osd.timeout + from: 100 + to: 3000 + stepSize: 100 + onValueChanged: { + Config.options.osd.timeout = value; + } + } + } + + ContentSection { + title: Translation.tr("Overview") + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.overview.enable + onCheckedChanged: { + Config.options.overview.enable = checked; + } + } + ConfigSpinBox { + 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 { + text: Translation.tr("Rows") + value: Config.options.overview.rows + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.rows = value; + } + } + ConfigSpinBox { + text: Translation.tr("Columns") + value: Config.options.overview.columns + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.columns = value; + } + } + } + } + + ContentSection { + title: Translation.tr("Screenshot tool") + + ConfigSwitch { + text: Translation.tr('Show regions of potential interest') + checked: Config.options.screenshotTool.showContentRegions + onCheckedChanged: { + Config.options.screenshotTool.showContentRegions = checked; + } + StyledToolTip { + content: Translation.tr("Such regions 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.") + } + } + } +} diff --git a/configs/quickshell/ii/modules/settings/ServicesConfig.qml b/configs/quickshell/ii/modules/settings/ServicesConfig.qml new file mode 100644 index 0000000..02b3a3d --- /dev/null +++ b/configs/quickshell/ii/modules/settings/ServicesConfig.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + title: Translation.tr("Audio") + + ConfigSwitch { + text: Translation.tr("Earbang protection") + checked: Config.options.audio.protection.enable + onCheckedChanged: { + Config.options.audio.protection.enable = checked; + } + StyledToolTip { + content: Translation.tr("Prevents abrupt increments and restricts volume limit") + } + } + ConfigRow { + // uniform: true + ConfigSpinBox { + 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 { + text: Translation.tr("Volume limit") + value: Config.options.audio.protection.maxAllowed + from: 0 + to: 100 + stepSize: 2 + onValueChanged: { + Config.options.audio.protection.maxAllowed = value; + } + } + } + } + ContentSection { + title: Translation.tr("AI") + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("System prompt") + text: Config.options.ai.systemPrompt + wrapMode: TextEdit.Wrap + onTextChanged: { + Qt.callLater(() => { + Config.options.ai.systemPrompt = text; + }); + } + } + } + + ContentSection { + title: Translation.tr("Battery") + + ConfigRow { + uniform: true + ConfigSpinBox { + text: Translation.tr("Low warning") + value: Config.options.battery.low + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.low = value; + } + } + ConfigSpinBox { + text: Translation.tr("Critical warning") + value: Config.options.battery.critical + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.critical = value; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Automatic suspend") + checked: Config.options.battery.automaticSuspend + onCheckedChanged: { + Config.options.battery.automaticSuspend = checked; + } + StyledToolTip { + content: Translation.tr("Automatically suspends the system when battery is low") + } + } + ConfigSpinBox { + text: Translation.tr("Suspend at") + value: Config.options.battery.suspend + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.suspend = value; + } + } + } + } + + ContentSection { + title: Translation.tr("Networking") + MaterialTextField { + 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 { + title: Translation.tr("Resources") + ConfigSpinBox { + text: Translation.tr("Polling interval (ms)") + value: Config.options.resources.updateInterval + from: 100 + to: 10000 + stepSize: 100 + onValueChanged: { + Config.options.resources.updateInterval = value; + } + } + } + + ContentSection { + 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 { + content: 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 + + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Action") + text: Config.options.search.prefix.action + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.action = text; + } + } + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Clipboard") + text: Config.options.search.prefix.clipboard + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.clipboard = text; + } + } + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Emojis") + text: Config.options.search.prefix.emojis + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.emojis = text; + } + } + } + } + ContentSubsection { + title: Translation.tr("Web search") + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Base URL") + text: Config.options.search.engineBaseUrl + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.engineBaseUrl = text; + } + } + } + } + + ContentSection { + title: Translation.tr("Time") + + ContentSubsection { + title: Translation.tr("Format") + tooltip: "" + + ConfigSelectionArray { + currentValue: Config.options.time.format + configOptionName: "time.format" + onSelected: newValue => { + 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" + }, + ] + } + } + } +} diff --git a/configs/quickshell/ii/modules/settings/Settings.qml b/configs/quickshell/ii/modules/settings/Settings.qml new file mode 100644 index 0000000..9b5d931 --- /dev/null +++ b/configs/quickshell/ii/modules/settings/Settings.qml @@ -0,0 +1,215 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +// Main Settings Window +// Integrates transparency settings and other configuration options + +ApplicationWindow { + id: settingsWindow + + title: "dots-hyprland Settings" + width: 500 + height: 700 + visible: false + + color: "#1e1e2e" + + // Make window float and center it + flags: Qt.Window | Qt.WindowStaysOnTopHint + + Component.onCompleted: { + // Center the window + x = (Screen.width - width) / 2 + y = (Screen.height - height) / 2 + } + + TabBar { + id: tabBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 50 + + background: Rectangle { + color: "#313244" + } + + TabButton { + text: "Effects" + width: implicitWidth + + background: Rectangle { + color: parent.checked ? "#45475a" : "transparent" + radius: 4 + } + + contentItem: Text { + text: parent.text + color: "#cdd6f4" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + TabButton { + text: "Appearance" + width: implicitWidth + + background: Rectangle { + color: parent.checked ? "#45475a" : "transparent" + radius: 4 + } + + contentItem: Text { + text: parent.text + color: "#cdd6f4" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + TabButton { + text: "Keybinds" + width: implicitWidth + + background: Rectangle { + color: parent.checked ? "#45475a" : "transparent" + radius: 4 + } + + contentItem: Text { + text: parent.text + color: "#cdd6f4" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + + StackLayout { + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + + currentIndex: tabBar.currentIndex + + // Effects Tab (Transparency & Blur) + Item { + TransparencyUI { + anchors.fill: parent + color: "transparent" + border.width: 0 + } + } + + // Appearance Tab (Future: themes, colors, etc.) + Item { + Rectangle { + anchors.fill: parent + color: "#313244" + radius: 8 + + Text { + anchors.centerIn: parent + text: "Appearance settings\n(Coming soon)" + color: "#a6adc8" + horizontalAlignment: Text.AlignHCenter + } + } + } + + // Keybinds Tab (Future: keybind customization) + Item { + Rectangle { + anchors.fill: parent + color: "#313244" + radius: 8 + + Text { + anchors.centerIn: parent + text: "Keybind settings\n(Coming soon)" + color: "#a6adc8" + horizontalAlignment: Text.AlignHCenter + } + } + } + } + + // Close button + Button { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 10 + + width: 30 + height: 30 + + text: "ร—" + + background: Rectangle { + color: parent.hovered ? "#f38ba8" : "#45475a" + radius: 15 + } + + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 16 + font.bold: true + } + + onClicked: settingsWindow.close() + } + + // IPC Handler for external control + IpcHandler { + target: "settings" + + function show() { + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + + function hide() { + settingsWindow.hide() + } + + function toggle() { + if (settingsWindow.visible) { + settingsWindow.hide() + } else { + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + } + + function showEffects() { + tabBar.currentIndex = 0 + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + + function showAppearance() { + tabBar.currentIndex = 1 + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + + function showKeybinds() { + tabBar.currentIndex = 2 + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + } +} diff --git a/configs/quickshell/ii/modules/settings/StyleConfig.qml b/configs/quickshell/ii/modules/settings/StyleConfig.qml new file mode 100644 index 0000000..6eabfab --- /dev/null +++ b/configs/quickshell/ii/modules/settings/StyleConfig.qml @@ -0,0 +1,245 @@ +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.common.functions + +ContentPage { + baseWidth: lightDarkButtonGroup.implicitWidth + forceWidth: true + + Process { + id: konachanWallProc + property string status: "" + command: ["bash", "-c", FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/random_konachan_wall.sh`)] + stdout: SplitParser { + onRead: data => { + console.log(`Konachan wall proc output: ${data}`); + konachanWallProc.status = data.trim(); + } + } + } + + ContentSection { + title: Translation.tr("Colors & Wallpaper") + + // Light/Dark mode preference + ButtonGroup { + id: lightDarkButtonGroup + Layout.fillWidth: true + LightDarkPreferenceButton { + dark: false + } + LightDarkPreferenceButton { + dark: true + } + } + + // Material palette selection + ContentSubsection { + title: Translation.tr("Material palette") + ConfigSelectionArray { + currentValue: Config.options.appearance.palette.type + configOptionName: "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")} + ] + } + } + + + // Wallpaper selection + ContentSubsection { + title: Translation.tr("Wallpaper") + RowLayout { + Layout.alignment: Qt.AlignHCenter + RippleButtonWithIcon { + id: rndWallBtn + buttonRadius: Appearance.rounding.small + materialIcon: "wallpaper" + mainText: konachanWallProc.running ? Translation.tr("Be patient...") : Translation.tr("Random: Konachan") + onClicked: { + console.log(konachanWallProc.command.join(" ")) + konachanWallProc.running = true; + } + StyledToolTip { + content: Translation.tr("Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers") + } + } + RippleButtonWithIcon { + materialIcon: "wallpaper" + StyledToolTip { + content: 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" + } + } + } + } + } + } + } + + StyledText { + Layout.topMargin: 5 + Layout.alignment: Qt.AlignHCenter + text: Translation.tr("Alternatively use /dark, /light, /img in the launcher") + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + } + + } + + ContentSection { + title: Translation.tr("Decorations & Effects") + + ContentSubsection { + title: Translation.tr("Transparency") + + ConfigRow { + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.appearance.transparency + onCheckedChanged: { + Config.options.appearance.transparency = checked; + } + StyledToolTip { + content: Translation.tr("Might look ass. Unsupported.") + } + } + } + } + + ContentSubsection { + title: Translation.tr("Fake screen rounding") + + ButtonGroup { + id: fakeScreenRoundingButtonGroup + property int selectedPolicy: Config.options.appearance.fakeScreenRounding + spacing: 2 + SelectionGroupButton { + property int value: 0 + leftmost: true + buttonText: Translation.tr("No") + toggled: (fakeScreenRoundingButtonGroup.selectedPolicy === value) + onClicked: { + Config.options.appearance.fakeScreenRounding = value; + } + } + SelectionGroupButton { + property int value: 1 + buttonText: Translation.tr("Yes") + toggled: (fakeScreenRoundingButtonGroup.selectedPolicy === value) + onClicked: { + Config.options.appearance.fakeScreenRounding = value; + } + } + SelectionGroupButton { + property int value: 2 + rightmost: true + buttonText: Translation.tr("When not fullscreen") + toggled: (fakeScreenRoundingButtonGroup.selectedPolicy === value) + onClicked: { + Config.options.appearance.fakeScreenRounding = value; + } + } + } + } + + ContentSubsection { + title: Translation.tr("Shell windows") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Title bar") + checked: Config.options.windows.showTitlebar + onCheckedChanged: { + Config.options.windows.showTitlebar = checked; + } + } + ConfigSwitch { + text: Translation.tr("Center title") + checked: Config.options.windows.centerTitle + onCheckedChanged: { + Config.options.windows.centerTitle = checked; + } + } + } + } + + ContentSubsection { + title: Translation.tr("Wallpaper parallax") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Depends on workspace") + checked: Config.options.background.parallax.enableWorkspace + onCheckedChanged: { + Config.options.background.parallax.enableWorkspace = checked; + } + } + ConfigSwitch { + text: Translation.tr("Depends on sidebars") + checked: Config.options.background.parallax.enableSidebar + onCheckedChanged: { + Config.options.background.parallax.enableSidebar = checked; + } + } + } + ConfigSpinBox { + text: Translation.tr("Preferred wallpaper zoom (%)") + value: Config.options.background.parallax.workspaceZoom * 100 + from: 100 + to: 150 + stepSize: 1 + onValueChanged: { + console.log(value/100) + Config.options.background.parallax.workspaceZoom = value / 100; + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/settings/TransparencySettings.qml b/configs/quickshell/ii/modules/settings/TransparencySettings.qml new file mode 100644 index 0000000..e267097 --- /dev/null +++ b/configs/quickshell/ii/modules/settings/TransparencySettings.qml @@ -0,0 +1,209 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemdUser + +// Transparency and Blur Settings Module +// Based on AGS configuration from ~/.config/ags/modules/sideright/centermodules/configure.js + +Rectangle { + id: transparencySettings + + property bool globalTransparency: false + property int terminalOpacity: 100 + property bool blurEnabled: false + property bool blurXray: true + property int blurSize: 8 + property int blurPasses: 4 + + // Storage paths (matching AGS structure) + property string colorModeFile: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/ags/user/colormode.txt" + property string terminalTransparencyFile: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/ags/user/generated/terminal/transparency" + + color: "transparent" + + Component.onCompleted: { + loadSettings() + } + + // Load settings from files (AGS compatibility) + function loadSettings() { + // Load global transparency mode + Process.exec("bash", ["-c", `mkdir -p $(dirname "${colorModeFile}")`]) + let colorModeResult = Process.exec("bash", ["-c", `sed -n '2p' "${colorModeFile}" 2>/dev/null || echo "opaque"`]) + globalTransparency = (colorModeResult.stdout.trim() === "transparent") + + // Load terminal opacity + Process.exec("bash", ["-c", `mkdir -p $(dirname "${terminalTransparencyFile}")`]) + let termOpacityResult = Process.exec("bash", ["-c", `cat "${terminalTransparencyFile}" 2>/dev/null || echo "100"`]) + terminalOpacity = parseInt(termOpacityResult.stdout.trim()) || 100 + + // Load Hyprland blur settings + loadHyprlandSettings() + } + + function loadHyprlandSettings() { + // Load blur enabled + let blurResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:enabled"]) + try { + let blurData = JSON.parse(blurResult.stdout) + blurEnabled = blurData.int !== 0 + } catch (e) { + console.log("Failed to load blur enabled setting:", e) + } + + // Load blur xray + let xrayResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:xray"]) + try { + let xrayData = JSON.parse(xrayResult.stdout) + blurXray = xrayData.int !== 0 + } catch (e) { + console.log("Failed to load blur xray setting:", e) + } + + // Load blur size + let sizeResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:size"]) + try { + let sizeData = JSON.parse(sizeResult.stdout) + blurSize = sizeData.int + } catch (e) { + console.log("Failed to load blur size setting:", e) + } + + // Load blur passes + let passesResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:passes"]) + try { + let passesData = JSON.parse(passesResult.stdout) + blurPasses = passesData.int + } catch (e) { + console.log("Failed to load blur passes setting:", e) + } + } + + // Save and apply global transparency + function setGlobalTransparency(enabled) { + globalTransparency = enabled + let mode = enabled ? "transparent" : "opaque" + + // Save to colormode.txt (line 2) + Process.exec("bash", ["-c", `mkdir -p $(dirname "${colorModeFile}") + if [ ! -f "${colorModeFile}" ]; then + echo "dark" > "${colorModeFile}" + echo "${mode}" >> "${colorModeFile}" + else + sed -i "2s/.*/${mode}/" "${colorModeFile}" + fi`]) + + // Apply color changes (equivalent to AGS switchcolor.sh) + applyColorChanges() + } + + // Save and apply terminal opacity + function setTerminalOpacity(opacity) { + terminalOpacity = opacity + + // Save to terminal transparency file + Process.exec("bash", ["-c", `mkdir -p $(dirname "${terminalTransparencyFile}") + echo "${opacity}" > "${terminalTransparencyFile}"`]) + + // Apply terminal colors (equivalent to AGS applycolor.sh term) + applyTerminalColors() + } + + // Apply Hyprland blur settings + function setBlurEnabled(enabled) { + blurEnabled = enabled + Process.exec("hyprctl", ["keyword", "decoration:blur:enabled", enabled ? "1" : "0"]) + } + + function setBlurXray(enabled) { + blurXray = enabled + Process.exec("hyprctl", ["keyword", "decoration:blur:xray", enabled ? "1" : "0"]) + } + + function setBlurSize(size) { + blurSize = size + Process.exec("hyprctl", ["keyword", "decoration:blur:size", size.toString()]) + } + + function setBlurPasses(passes) { + blurPasses = passes + Process.exec("hyprctl", ["keyword", "decoration:blur:passes", passes.toString()]) + } + + // Apply color changes (equivalent to AGS color generation) + function applyColorChanges() { + // This would call the equivalent of AGS color generation scripts + Process.exec("bash", ["-c", ` + # Apply transparency mode to all shell elements + # This is where we'd integrate with the quickshell theming system + echo "Applying transparency mode: ${globalTransparency ? 'transparent' : 'opaque'}" + + # Reload quickshell to apply changes + quickshell ipc call settings reload || true + `]) + } + + // Apply terminal colors (equivalent to AGS applycolor.sh term) + function applyTerminalColors() { + let alpha = terminalOpacity / 100.0 + + Process.exec("bash", ["-c", ` + # Update foot terminal configuration with new opacity + FOOT_CONFIG="$HOME/.config/foot/foot.ini" + if [ -f "$FOOT_CONFIG" ]; then + # Update alpha value in foot.ini + sed -i "s/^alpha=.*/alpha=${alpha}/" "$FOOT_CONFIG" || echo "alpha=${alpha}" >> "$FOOT_CONFIG" + fi + + # Send terminal escape sequence to update running terminals + # This matches the AGS terminal sequences functionality + echo "Applied terminal opacity: ${terminalOpacity}%" + `]) + } + + // IPC Handler for external control + IpcHandler { + target: "transparencySettings" + + function setTransparency(enabled) { + transparencySettings.setGlobalTransparency(enabled) + } + + function setTerminalOpacity(opacity) { + transparencySettings.setTerminalOpacity(opacity) + } + + function setBlur(enabled) { + transparencySettings.setBlurEnabled(enabled) + } + + function setBlurXray(enabled) { + transparencySettings.setBlurXray(enabled) + } + + function setBlurSize(size) { + transparencySettings.setBlurSize(size) + } + + function setBlurPasses(passes) { + transparencySettings.setBlurPasses(passes) + } + + function getSettings() { + return { + globalTransparency: transparencySettings.globalTransparency, + terminalOpacity: transparencySettings.terminalOpacity, + blurEnabled: transparencySettings.blurEnabled, + blurXray: transparencySettings.blurXray, + blurSize: transparencySettings.blurSize, + blurPasses: transparencySettings.blurPasses + } + } + + function reload() { + transparencySettings.loadSettings() + } + } +} diff --git a/configs/quickshell/ii/modules/settings/TransparencyUI.qml b/configs/quickshell/ii/modules/settings/TransparencyUI.qml new file mode 100644 index 0000000..b288f0d --- /dev/null +++ b/configs/quickshell/ii/modules/settings/TransparencyUI.qml @@ -0,0 +1,469 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +// Transparency and Blur UI Controls +// Replicates the AGS configuration UI from configure.js + +Rectangle { + id: transparencyUI + + property alias transparencySettings: settingsLoader.item + + width: 400 + height: 600 + color: "#1e1e2e" + radius: 12 + border.color: "#45475a" + border.width: 1 + + // Load the settings module + Loader { + id: settingsLoader + source: "TransparencySettings.qml" + } + + ScrollView { + anchors.fill: parent + anchors.margins: 20 + + ColumnLayout { + width: parent.width + spacing: 20 + + // Header + Text { + text: "Effects Configuration" + color: "#cdd6f4" + font.pixelSize: 18 + font.bold: true + Layout.fillWidth: true + } + + // Global Transparency Section + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: transparencySection.height + 20 + color: "#313244" + radius: 8 + + ColumnLayout { + id: transparencySection + anchors.fill: parent + anchors.margins: 15 + spacing: 15 + + // Transparency Toggle + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 24 + height: 24 + color: "#89b4fa" + radius: 4 + + Text { + anchors.centerIn: parent + text: "โ—ซ" + color: "white" + font.pixelSize: 14 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Transparency" + color: "#cdd6f4" + font.pixelSize: 14 + font.bold: true + } + + Text { + text: "Make shell elements transparent\nBlur is also recommended if you enable this" + color: "#a6adc8" + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + id: transparencySwitch + checked: transparencySettings ? transparencySettings.globalTransparency : false + + onToggled: { + if (transparencySettings) { + transparencySettings.setGlobalTransparency(checked) + } + } + } + } + + // Terminal Opacity Slider (subcategory) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: terminalOpacitySection.height + 10 + color: "#45475a" + radius: 6 + Layout.leftMargin: 20 + + ColumnLayout { + id: terminalOpacitySection + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#f9e2af" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โ—‹" + color: "black" + font.pixelSize: 12 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Terminal Opacity" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Changes the opacity of the foot terminal" + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Text { + text: (transparencySettings ? transparencySettings.terminalOpacity : 100) + "%" + color: "#cdd6f4" + font.pixelSize: 12 + Layout.preferredWidth: 40 + } + } + + Slider { + id: terminalOpacitySlider + Layout.fillWidth: true + from: 0 + to: 100 + stepSize: 1 + value: transparencySettings ? transparencySettings.terminalOpacity : 100 + + onValueChanged: { + if (transparencySettings && Math.abs(value - transparencySettings.terminalOpacity) > 0.5) { + transparencySettings.setTerminalOpacity(Math.round(value)) + } + } + } + } + } + } + } + + // Blur Section + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: blurSection.height + 20 + color: "#313244" + radius: 8 + + ColumnLayout { + id: blurSection + anchors.fill: parent + anchors.margins: 15 + spacing: 15 + + // Blur Toggle + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 24 + height: 24 + color: "#94e2d5" + radius: 4 + + Text { + anchors.centerIn: parent + text: "โ—" + color: "black" + font.pixelSize: 14 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Blur" + color: "#cdd6f4" + font.pixelSize: 14 + font.bold: true + } + + Text { + text: "Enable blur on transparent elements\nDoesn't affect performance/power consumption unless you have transparent windows." + color: "#a6adc8" + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + id: blurSwitch + checked: transparencySettings ? transparencySettings.blurEnabled : false + + onToggled: { + if (transparencySettings) { + transparencySettings.setBlurEnabled(checked) + } + } + } + } + + // Blur Subcategory + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: blurSubcategory.height + 10 + color: "#45475a" + radius: 6 + Layout.leftMargin: 20 + visible: transparencySettings ? transparencySettings.blurEnabled : false + + ColumnLayout { + id: blurSubcategory + anchors.fill: parent + anchors.margins: 10 + spacing: 15 + + // X-ray Toggle + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#f38ba8" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โšก" + color: "white" + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "X-ray" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Make everything behind a window/layer except the wallpaper not rendered on its blurred surface\nRecommended to improve performance (if you don't abuse transparency/blur)" + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + checked: transparencySettings ? transparencySettings.blurXray : true + + onToggled: { + if (transparencySettings) { + transparencySettings.setBlurXray(checked) + } + } + } + } + + // Blur Size + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#a6e3a1" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โ—Ž" + color: "black" + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Size" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Adjust the blur radius. Generally doesn't affect performance\nHigher = more color spread" + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + SpinBox { + from: 1 + to: 1000 + value: transparencySettings ? transparencySettings.blurSize : 8 + + onValueChanged: { + if (transparencySettings && value !== transparencySettings.blurSize) { + transparencySettings.setBlurSize(value) + } + } + } + } + + // Blur Passes + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#cba6f7" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โ†ป" + color: "white" + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Passes" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Adjust the number of runs of the blur algorithm\nMore passes = more spread and power consumption\n4 is recommended\n2- would look weird and 6+ would look lame." + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + SpinBox { + from: 1 + to: 10 + value: transparencySettings ? transparencySettings.blurPasses : 4 + + onValueChanged: { + if (transparencySettings && value !== transparencySettings.blurPasses) { + transparencySettings.setBlurPasses(value) + } + } + } + } + } + } + } + } + + // Apply/Reset buttons + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 20 + + Button { + text: "Reset to Defaults" + Layout.fillWidth: true + + onClicked: { + if (transparencySettings) { + transparencySettings.setGlobalTransparency(false) + transparencySettings.setTerminalOpacity(100) + transparencySettings.setBlurEnabled(false) + transparencySettings.setBlurXray(true) + transparencySettings.setBlurSize(8) + transparencySettings.setBlurPasses(4) + } + } + } + + Button { + text: "Reload Settings" + Layout.fillWidth: true + + onClicked: { + if (transparencySettings) { + transparencySettings.loadSettings() + } + } + } + } + } + } + + // IPC Handler for external control + IpcHandler { + target: "transparencyUI" + + function show() { + transparencyUI.visible = true + } + + function hide() { + transparencyUI.visible = false + } + + function toggle() { + transparencyUI.visible = !transparencyUI.visible + } + } +} diff --git a/configs/quickshell/ii/modules/settings/transparency-config.json b/configs/quickshell/ii/modules/settings/transparency-config.json new file mode 120000 index 0000000..af7bb00 --- /dev/null +++ b/configs/quickshell/ii/modules/settings/transparency-config.json @@ -0,0 +1 @@ +/nix/store/vwcn1psxq5k9f06fvcwy6mr5v30fgclf-home-manager-files/.config/quickshell/ii/modules/settings/transparency-config.json \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/AiChat.qml b/configs/quickshell/ii/modules/sidebarLeft/AiChat.qml new file mode 100644 index 0000000..dcbff89 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/AiChat.qml @@ -0,0 +1,701 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./aiChat/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell + +Item { + id: root + property var inputField: messageInputField + property string commandPrefix: "/" + + property var suggestionQuery: "" + property var suggestionList: [] + + onFocusChanged: (focus) => { + if (focus) { + root.inputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + messageInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + messageListView.contentY = Math.max(0, messageListView.contentY - messageListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + messageListView.contentY = Math.min(messageListView.contentHeight - messageListView.height / 2, messageListView.contentY + messageListView.height / 2) + event.accepted = true + } + } + } + + property var allCommands: [ + { + name: "model", + description: Translation.tr("Choose model"), + execute: (args) => { + Ai.setModel(args[0]); + } + }, + { + name: "tool", + description: Translation.tr("Set the tool to use for the model."), + execute: (args) => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.addMessage(Translation.tr("Usage: %1tool TOOL_NAME").arg(root.commandPrefix), Ai.interfaceRole); + } else { + const tool = args[0]; + const switched = Ai.setTool(tool); + if (switched) { + Ai.addMessage(Translation.tr("Tool set to: %1").arg(tool), Ai.interfaceRole); + } + } + } + }, + { + name: "prompt", + description: Translation.tr("Set the system prompt for the model."), + execute: (args) => { + if (args.length === 0 || args[0] === "get") { + Ai.printPrompt(); + return; + } + Ai.loadPrompt(args.join(" ").trim()); + } + }, + { + name: "key", + description: Translation.tr("Set API key"), + execute: (args) => { + if (args[0] == "get") { + Ai.printApiKey() + } else { + Ai.setApiKey(args[0]); + } + } + }, + { + name: "save", + description: Translation.tr("Save chat"), + execute: (args) => { + const joinedArgs = args.join(" ") + if (joinedArgs.trim().length == 0) { + Ai.addMessage(Translation.tr("Usage: %1save CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole); + return; + } + Ai.saveChat(joinedArgs) + } + }, + { + name: "load", + description: Translation.tr("Load chat"), + execute: (args) => { + const joinedArgs = args.join(" ") + if (joinedArgs.trim().length == 0) { + Ai.addMessage(Translation.tr("Usage: %1load CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole); + return; + } + Ai.loadChat(joinedArgs) + } + }, + { + name: "clear", + description: Translation.tr("Clear chat history"), + execute: () => { + Ai.clearMessages(); + } + }, + { + name: "temp", + description: Translation.tr("Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5."), + execute: (args) => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.printTemperature() + } else { + const temp = parseFloat(args[0]); + Ai.setTemperature(temp); + } + } + }, + { + name: "test", + description: Translation.tr("Markdown test"), + execute: () => { + Ai.addMessage(` + +A longer think block to test revealing animation +OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w< +Mowe uwu wem ipsum! + +## โœ๏ธ Markdown test +### Formatting + +- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- Arch lincox icon + +### Table + +Quickshell vs AGS/Astal + +| | Quickshell | AGS/Astal | +|--------------------------|------------------|-------------------| +| UI Toolkit | Qt | Gtk3/Gtk4 | +| Language | QML | Js/Ts/Lua | +| Reactivity | Implied | Needs declaration | +| Widget placement | Mildly difficult | More intuitive | +| Bluetooth & Wifi support | โŒ | โœ… | +| No-delay keybinds | โœ… | โŒ | +| Development | New APIs | New syntax | + +### Code block + +Just a hello world... + +\`\`\`cpp +#include +// This is intentionally very long to test scrolling +const std::string GREETING = \"UwU\"; +int main(int argc, char* argv[]) { + std::cout << GREETING; +} +\`\`\` + +### LaTeX + + +Inline w/ dollar signs: $\\frac{1}{2} = \\frac{2}{4}$ + +Inline w/ double dollar signs: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ + +Inline w/ backslash and square brackets \\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\] + +Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) +`, + Ai.interfaceRole); + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Ai.addMessage(Translation.tr("Unknown command: ") + command, Ai.interfaceRole); + } + } + else { + Ai.sendUserMessage(inputText); + } + } + + component StatusItem: MouseArea { + id: statusItem + property string icon + property string statusText + property string description + hoverEnabled: true + implicitHeight: statusItemRowLayout.implicitHeight + implicitWidth: statusItemRowLayout.implicitWidth + + RowLayout { + id: statusItemRowLayout + spacing: 0 + MaterialSymbol { + text: statusItem.icon + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colSubtext + } + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: statusItem.statusText + color: Appearance.colors.colSubtext + } + } + + StyledToolTip { + content: statusItem.description + extraVisibleCondition: false + alternativeVisibleCondition: statusItem.containsMouse + } + } + + component StatusSeparator: Rectangle { + implicitWidth: 4 + implicitHeight: 4 + radius: implicitWidth / 2 + color: Appearance.colors.colOutlineVariant + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + RowLayout { // Status + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + StatusItem { + icon: Ai.currentModelHasApiKey ? "key" : "key_off" + statusText: "" + description: Ai.currentModelHasApiKey ? Translation.tr("API key is set\nChange with /key YOUR_API_KEY") : Translation.tr("No API key\nSet it with /key YOUR_API_KEY") + } + StatusSeparator {} + StatusItem { + icon: "device_thermostat" + statusText: Ai.temperature.toFixed(1) + description: Translation.tr("Temperature\nChange with /temp VALUE") + } + StatusSeparator { + visible: Ai.tokenCount.total > 0 + } + StatusItem { + visible: Ai.tokenCount.total > 0 + icon: "token" + statusText: Ai.tokenCount.total + description: Translation.tr("Total token count\nInput: %1\nOutput: %2") + .arg(Ai.tokenCount.input) + .arg(Ai.tokenCount.output) + } + } + + Item { // Messages + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { // Message list + id: messageListView + anchors.fill: parent + spacing: 10 + popin: false + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + add: null // Prevent function calls from being janky + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + model: ScriptModel { + values: Ai.messageIDs.filter(id => { + const message = Ai.messageByID[id]; + return message?.visibleToUser ?? true; + }) + } + delegate: AiMessage { + required property var modelData + required property int index + messageIndex: index + messageData: { + Ai.messageByID[modelData] + } + messageInputField: root.inputField + } + } + + Item { // Placeholder when list is empty + opacity: Ai.messageIDs.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 60 + color: Appearance.m3colors.m3outline + text: "neurology" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.title + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Large language models") + } + StyledText { + id: widgetDescriptionText + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + text: Translation.tr("Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window") + } + } + } + } + + DescriptionBox { + text: root.suggestionList[suggestions.selectedIndex]?.description ?? "" + showArrows: root.suggestionList.length > 1 + } + + FlowButtonGroup { // Suggestions + id: suggestions + visible: root.suggestionList.length > 0 && messageInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: suggestionRepeater + model: { + suggestions.selectedIndex = 0 + return root.suggestionList.slice(0, 10) + } + delegate: ApiCommandButton { + id: commandButton + colBackground: suggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer + bounce: false + contentItem: StyledText { + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignHCenter + text: modelData.displayName ?? modelData.name + } + + onHoveredChanged: { + if (commandButton.hovered) { + suggestions.selectedIndex = index; + } + } + onClicked: { + suggestions.acceptSuggestion(modelData.name) + } + } + } + + function acceptSuggestion(word) { + const words = messageInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = word; + } else { + words.push(word); + } + const updatedText = words.join(" ") + " "; + messageInputField.text = updatedText; + messageInputField.cursorPosition = messageInputField.text.length; + messageInputField.forceActiveFocus(); + } + + function acceptSelectedWord() { + if (suggestions.selectedIndex >= 0 && suggestions.selectedIndex < suggestionRepeater.count) { + const word = root.suggestionList[suggestions.selectedIndex].name; + suggestions.acceptSuggestion(word); + } + } + } + + Rectangle { // Input area + id: inputWrapper + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: messageInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + StyledTextArea { // The actual TextArea + id: messageInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + placeholderText: Translation.tr('Message the model... "%1" for commands').arg(root.commandPrefix) + + background: null + + onTextChanged: { // Handle suggestions + if (messageInputField.text.length === 0) { + root.suggestionQuery = "" + root.suggestionList = [] + return + } else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => { + return { + name: Fuzzy.prepare(model), + obj: model, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = modelResults.map(model => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`, + displayName: `${Ai.models[model.target].name}`, + description: `${Ai.models[model.target].description}`, + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}prompt`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.promptFiles.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = promptFileResults.map(file => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "prompt ") : ""}${file.target}`, + displayName: `${FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target))}`, + description: Translation.tr("Load prompt from %1").arg(file.target), + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}save`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = promptFileResults.map(file => { + const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim() + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "save ") : ""}${chatName}`, + displayName: `${chatName}`, + description: Translation.tr("Save chat to %1").arg(chatName), + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}load`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = promptFileResults.map(file => { + const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim() + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "load ") : ""}${chatName}`, + displayName: `${chatName}`, + description: Translation.tr(`Load chat from %1`).arg(file.target), + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => { + return { + name: Fuzzy.prepare(tool), + obj: tool, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = toolResults.map(tool => { + const toolName = tool.target + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`, + displayName: toolName, + description: Ai.toolDescriptions[toolName], + } + }) + } else if(messageInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = messageInputField.text + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}`, + } + }) + } + } + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + suggestions.acceptSelectedWord(); + event.accepted = true; + } else if (event.key === Qt.Key_Up && suggestions.visible) { + suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down && suggestions.visible) { + suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + messageInputField.insert(messageInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = messageInputField.text + messageInputField.clear() + root.handleInput(inputText) + event.accepted = true + } + } + } + } + + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: messageInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = messageInputField.text + root.handleInput(inputText) + messageInputField.clear() + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + // fill: sendButton.enabled ? 1 : 0 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "send" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 10 + anchors.rightMargin: 5 + spacing: 4 + + property var commandsShown: [ + { + name: "", + sendDirectly: false, + dontAddSpace: true, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + ApiInputBoxIndicator { // Model indicator + icon: "api" + text: Ai.getModel().name + tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL") + .arg(Ai.getModel().name) + .arg(root.commandPrefix) + } + + ApiInputBoxIndicator { // Tool indicator + icon: "service_toolbox" + text: Ai.currentTool.charAt(0).toUpperCase() + Ai.currentTool.slice(1) + tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL") + .arg(Ai.currentTool) + .arg(root.commandPrefix) + } + + Item { Layout.fillWidth: true } + + ButtonGroup { // Command buttons + padding: 0 + + Repeater { // Command buttons + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + messageInputField.text = commandRepresentation + (modelData.dontAddSpace ? "" : " ") + messageInputField.cursorPosition = messageInputField.text.length + messageInputField.forceActiveFocus() + } + if (modelData.name === "clear") { + messageInputField.text = "" + } + } + } + } + } + } + + } + + } + +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/Anime.qml b/configs/quickshell/ii/modules/sidebarLeft/Anime.qml new file mode 100644 index 0000000..a728377 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/Anime.qml @@ -0,0 +1,580 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./anime/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell + +Item { + id: root + property var inputField: tagInputField + readonly property var responses: Booru.responses + property string previewDownloadPath: Directories.booruPreviews + property string downloadPath: Directories.booruDownloads + property string nsfwPath: Directories.booruDownloadsNsfw + property string commandPrefix: "/" + property real scrollOnNewResponse: 100 + property int tagSuggestionDelay: 210 + property var suggestionQuery: "" + property var suggestionList: [] + + Connections { + target: Booru + function onTagSuggestion(query, suggestions) { + root.suggestionQuery = query; + root.suggestionList = suggestions; + } + } + + property var allCommands: [ + { + name: "mode", + description: Translation.tr("Set the current API provider"), + execute: (args) => { + Booru.setProvider(args[0]); + } + }, + { + name: "clear", + description: Translation.tr("Clear the current list of images"), + execute: () => { + Booru.clearResponses(); + } + }, + { + name: "next", + description: Translation.tr("Get the next page of results"), + execute: () => { + if (root.responses.length > 0) { + const lastResponse = root.responses[root.responses.length - 1]; + root.handleInput(`${lastResponse.tags.join(" ")} ${parseInt(lastResponse.page) + 1}`); + } + } + }, + { + name: "safe", + description: Translation.tr("Disable NSFW content"), + execute: () => { + Persistent.states.booru.allowNsfw = false; + } + }, + { + name: "lewd", + description: Translation.tr("Allow NSFW content"), + execute: () => { + Persistent.states.booru.allowNsfw = true; + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Booru.addSystemMessage(Translation.tr("Unknown command: ") + command); + } + } + else if (inputText.trim() == "+") { + if (root.responses.length > 0) { + const lastResponse = root.responses[root.responses.length - 1] + root.handleInput(lastResponse.tags.join(" ") + ` ${parseInt(lastResponse.page) + 1}`); + } + } + else { + // Create tag list + const tagList = inputText.split(/\s+/).filter(tag => tag.length > 0); + let pageIndex = 1; + for (let i = 0; i < tagList.length; ++i) { // Detect page number + if (/^\d+$/.test(tagList[i])) { + pageIndex = parseInt(tagList[i], 10); + tagList.splice(i, 1); + break; + } + } + Booru.makeRequest(tagList, Persistent.states.booru.allowNsfw, Config.options.sidebar.booru.limit, pageIndex); + } + } + + onFocusChanged: (focus) => { + if (focus) { + tagInputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + tagInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + booruResponseListView.contentY = Math.max(0, booruResponseListView.contentY - booruResponseListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + booruResponseListView.contentY = Math.min(booruResponseListView.contentHeight - booruResponseListView.height / 2, booruResponseListView.contentY + booruResponseListView.height / 2) + event.accepted = true + } + } + } + + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { // Booru responses + id: booruResponseListView + anchors.fill: parent + spacing: 10 + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + model: ScriptModel { + values: { + if(root.responses.length > booruResponseListView.lastResponseLength) { + if (booruResponseListView.lastResponseLength > 0 && root.responses[booruResponseListView.lastResponseLength].provider != "system") + booruResponseListView.contentY = booruResponseListView.contentY + root.scrollOnNewResponse + booruResponseListView.lastResponseLength = root.responses.length + } + return root.responses + } + } + delegate: BooruResponse { + responseData: modelData + tagInputField: root.inputField + previewDownloadPath: root.previewDownloadPath + downloadPath: root.downloadPath + nsfwPath: root.nsfwPath + } + } + + Item { // Placeholder when list is empty + opacity: root.responses.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 60 + color: Appearance.m3colors.m3outline + text: "bookmark_heart" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.title + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Anime boorus") + } + } + } + + Item { // Queries awaiting response + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + implicitHeight: pendingBackground.implicitHeight + opacity: Booru.runningRequests > 0 ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + Rectangle { + id: pendingBackground + color: Appearance.m3colors.m3inverseSurface + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: pendingText.implicitHeight + 12 * 2 + radius: Appearance.rounding.verysmall + + StyledText { + id: pendingText + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.m3colors.m3inverseOnSurface + wrapMode: Text.Wrap + text: Translation.tr("%1 queries pending").arg(Booru.runningRequests) + } + } + } + } + + DescriptionBox { // Tag suggestion description + text: root.suggestionList[tagSuggestions.selectedIndex]?.description ?? "" + showArrows: root.suggestionList.length > 1 + } + + FlowButtonGroup { // Tag suggestions + id: tagSuggestions + visible: root.suggestionList.length > 0 && tagInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: tagSuggestionRepeater + model: { + tagSuggestions.selectedIndex = 0 + return root.suggestionList.slice(0, 10) + } + delegate: ApiCommandButton { + id: tagButton + colBackground: tagSuggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer + bounce: false + contentItem: RowLayout { + anchors.centerIn: parent + spacing: 5 + StyledText { + Layout.fillWidth: false + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + horizontalAlignment: Text.AlignRight + text: modelData.displayName ?? modelData.name + } + StyledText { + Layout.fillWidth: false + visible: modelData.count !== undefined + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnSecondaryContainer + horizontalAlignment: Text.AlignLeft + text: modelData.count ?? "" + } + } + + onHoveredChanged: { + if (tagButton.hovered) { + tagSuggestions.selectedIndex = index; + } + } + onClicked: { + tagSuggestions.acceptTag(modelData.name) + } + } + } + + function acceptTag(tag) { + const words = tagInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = tag; + } else { + words.push(tag); + } + const updatedText = words.join(" ") + " "; + tagInputField.text = updatedText; + tagInputField.cursorPosition = tagInputField.text.length; + tagInputField.forceActiveFocus(); + } + + function acceptSelectedTag() { + if (tagSuggestions.selectedIndex >= 0 && tagSuggestions.selectedIndex < tagSuggestionRepeater.count) { + const tag = root.suggestionList[tagSuggestions.selectedIndex].name; + tagSuggestions.acceptTag(tag); + } + } + } + + Rectangle { // Tag input area + id: tagInputContainer + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: tagInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + StyledTextArea { // The actual TextArea + id: tagInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + placeholderText: Translation.tr('Enter tags, or "%1" for commands').arg(root.commandPrefix) + + background: null + + property Timer searchTimer: Timer { // Timer for tag suggestions + interval: root.tagSuggestionDelay + repeat: false + onTriggered: { + const inputText = tagInputField.text + const words = inputText.trim().split(/\s+/); + if (words.length > 0) { + Booru.triggerTagSearch(words[words.length - 1]); + } + } + } + + onTextChanged: { // Handle tag suggestions + if(tagInputField.text.length === 0) { + root.suggestionQuery = "" + root.suggestionList = [] + searchTimer.stop(); + return + } + if(tagInputField.text.startsWith(`${root.commandPrefix}mode`)) { + root.suggestionQuery = tagInputField.text.split(" ")[1] ?? "" + const providerResults = Fuzzy.go(root.suggestionQuery, Booru.providerList.map(provider => { + return { + name: Fuzzy.prepare(provider), + obj: provider, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = providerResults.map(provider => { + return { + name: `${tagInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "mode ") : ""}${provider.target}`, + displayName: `${Booru.providers[provider.target].name}`, + description: `${Booru.providers[provider.target].description}`, + } + }) + searchTimer.stop(); + return + } + if(tagInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = tagInputField.text + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(tagInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}`, + } + }) + searchTimer.stop(); + return + } + searchTimer.restart(); + } + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + tagSuggestions.acceptSelectedTag(); + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + tagSuggestions.selectedIndex = Math.max(0, tagSuggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + tagSuggestions.selectedIndex = Math.min(root.suggestionList.length - 1, tagSuggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + tagInputField.insert(tagInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = tagInputField.text + root.handleInput(inputText) + tagInputField.clear() + event.accepted = true + } + } + } + } + + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: tagInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = tagInputField.text + root.handleInput(inputText) + tagInputField.clear() + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + // fill: sendButton.enabled ? 1 : 0 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "send" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 5 + anchors.rightMargin: 5 + spacing: 5 + + property var commandsShown: [ + { + name: "mode", + sendDirectly: false, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + ApiInputBoxIndicator { // Tool indicator + icon: "api" + text: Booru.providers[Booru.currentProvider].name + tooltipText: Translation.tr("Current API endpoint: %1\nSet it with %2mode PROVIDER") + .arg(Booru.providers[Booru.currentProvider].url) + .arg(root.commandPrefix) + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: "โ€ข" + } + + Item { // NSFW toggle + visible: width > 0 + implicitWidth: switchesRow.implicitWidth + Layout.fillHeight: true + + RowLayout { + id: switchesRow + spacing: 5 + anchors.centerIn: parent + + MouseArea { + hoverEnabled: true + PointingHandInteraction {} + onClicked: { + nsfwSwitch.checked = !nsfwSwitch.checked + } + } + + StyledText { + Layout.fillHeight: true + Layout.leftMargin: 10 + Layout.alignment: Qt.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: nsfwSwitch.enabled ? Appearance.colors.colOnLayer1 : Appearance.m3colors.m3outline + text: Translation.tr("Allow NSFW") + } + StyledSwitch { + id: nsfwSwitch + enabled: Booru.currentProvider !== "zerochan" + scale: 0.6 + Layout.alignment: Qt.AlignVCenter + checked: (Persistent.states.booru.allowNsfw && Booru.currentProvider !== "zerochan") + onCheckedChanged: { + if (!nsfwSwitch.enabled) return; + Persistent.states.booru.allowNsfw = checked; + } + } + } + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + padding: 0 + Repeater { // Command buttons + id: commandRepeater + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + colBackground: Appearance.colors.colLayer2 + + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + tagInputField.text = commandRepresentation + " " + tagInputField.cursorPosition = tagInputField.text.length + tagInputField.forceActiveFocus() + } + if (modelData.name === "clear") { + tagInputField.text = "" + } + } + } + } + } + } + + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/ApiCommandButton.qml b/configs/quickshell/ii/modules/sidebarLeft/ApiCommandButton.qml new file mode 100644 index 0000000..efbde15 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/ApiCommandButton.qml @@ -0,0 +1,26 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +GroupButton { + id: button + property string buttonText + + horizontalPadding: 8 + verticalPadding: 6 + + baseWidth: contentItem.implicitWidth + horizontalPadding * 2 + clickedWidth: baseWidth + 20 + baseHeight: contentItem.implicitHeight + verticalPadding * 2 + buttonRadius: down ? Appearance.rounding.verysmall : Appearance.rounding.small + + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: buttonText + color: Appearance.m3colors.m3onSurface + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/ApiInputBoxIndicator.qml b/configs/quickshell/ii/modules/sidebarLeft/ApiInputBoxIndicator.qml new file mode 100644 index 0000000..13fb81c --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/ApiInputBoxIndicator.qml @@ -0,0 +1,47 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +Item { // Model indicator + id: root + property string icon: "api" + property string text: "" + property string tooltipText: "" + implicitHeight: rowLayout.implicitHeight + 4 * 2 + implicitWidth: rowLayout.implicitWidth + 4 * 2 + + RowLayout { + id: rowLayout + anchors.centerIn: parent + + MaterialSymbol { + text: root.icon + iconSize: Appearance.font.pixelSize.normal + } + StyledText { + id: providerName + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.m3colors.m3onSurface + elide: Text.ElideRight + text: root.text + } + } + + Loader { + active: root.tooltipText?.length > 0 + anchors.fill: parent + sourceComponent: MouseArea { + id: mouseArea + hoverEnabled: true + + StyledToolTip { + id: toolTip + extraVisibleCondition: false + alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered + content: root.tooltipText + } + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/DescriptionBox.qml b/configs/quickshell/ii/modules/sidebarLeft/DescriptionBox.qml new file mode 100644 index 0000000..5287ecc --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/DescriptionBox.qml @@ -0,0 +1,62 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { // Tag suggestion description + id: root + property alias text: tagDescriptionText.text + property bool showArrows: true + property bool showTab: true + + visible: tagDescriptionText.text.length > 0 + Layout.fillWidth: true + implicitHeight: tagDescriptionBackground.implicitHeight + + Rectangle { + id: tagDescriptionBackground + color: Appearance.colors.colLayer2 + anchors.fill: parent + radius: Appearance.rounding.verysmall + implicitHeight: descriptionRow.implicitHeight + 5 * 2 + + RowLayout { + id: descriptionRow + spacing: 4 + anchors { + fill: parent + leftMargin: 10 + rightMargin: 10 + } + + StyledText { + id: tagDescriptionText + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer2 + wrapMode: Text.Wrap + } + KeyboardKey { + visible: root.showArrows + key: "โ†‘" + } + KeyboardKey { + visible: root.showArrows + key: "โ†“" + } + StyledText { + visible: root.showArrows && root.showTab + text: Translation.tr("or") + font.pixelSize: Appearance.font.pixelSize.smaller + } + KeyboardKey { + id: tagDescriptionKey + visible: root.showTab + key: "Tab" + Layout.alignment: Qt.AlignVCenter + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/SidebarLeft.qml b/configs/quickshell/ii/modules/sidebarLeft/SidebarLeft.qml new file mode 100644 index 0000000..0aed7b7 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/SidebarLeft.qml @@ -0,0 +1,202 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property int sidebarPadding: 15 + property bool detach: false + property Component contentComponent: SidebarLeftContent {} + property Item sidebarContent + + Component.onCompleted: { + root.sidebarContent = contentComponent.createObject(null, { + "scopeRoot": root, + }); + sidebarLoader.item.contentParent.children = [root.sidebarContent]; + } + + onDetachChanged: { + if (root.detach) { + sidebarContent.parent = null; // Detach content from sidebar + sidebarLoader.active = false; // Unload sidebar + detachedSidebarLoader.active = true; // Load detached window + detachedSidebarLoader.item.contentParent.children = [sidebarContent]; + } else { + sidebarContent.parent = null; // Detach content from window + detachedSidebarLoader.active = false; // Unload detached window + sidebarLoader.active = true; // Load sidebar + sidebarLoader.item.contentParent.children = [sidebarContent]; + } + } + + Loader { + id: sidebarLoader + active: true + + sourceComponent: PanelWindow { // Window + id: sidebarRoot + visible: GlobalStates.sidebarLeftOpen + + property bool extend: false + property real sidebarWidth: sidebarRoot.extend ? Appearance.sizes.sidebarWidthExtended : Appearance.sizes.sidebarWidth + property var contentParent: sidebarLeftBackground + + function hide() { + GlobalStates.sidebarLeftOpen = false + } + + exclusiveZone: 0 + implicitWidth: Appearance.sizes.sidebarWidthExtended + Appearance.sizes.elevationMargin + WlrLayershell.namespace: "quickshell:sidebarLeft" + // Hyprland 0.49: OnDemand is Exclusive, Exclusive just breaks click-outside-to-close + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + color: "transparent" + + anchors { + top: true + left: true + bottom: true + } + + mask: Region { + item: sidebarLeftBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [ sidebarRoot ] + active: sidebarRoot.visible + onActiveChanged: { // Focus the selected tab + if (active) sidebarLeftBackground.children[0].focusActiveItem() + } + onCleared: () => { + if (!active) sidebarRoot.hide() + } + } + + // Content + StyledRectangularShadow { + target: sidebarLeftBackground + radius: sidebarLeftBackground.radius + } + Rectangle { + id: sidebarLeftBackground + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Appearance.sizes.hyprlandGapsOut + anchors.leftMargin: Appearance.sizes.hyprlandGapsOut + width: sidebarRoot.sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin + height: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sidebarRoot.hide(); + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_O) { + sidebarRoot.extend = !sidebarRoot.extend; + } + else if (event.key === Qt.Key_P) { + root.detach = !root.detach; + } + event.accepted = true; + } + } + } + } + } + + Loader { + id: detachedSidebarLoader + active: false + + sourceComponent: FloatingWindow { + id: detachedSidebarRoot + visible: GlobalStates.sidebarLeftOpen + property var contentParent: detachedSidebarBackground + + Rectangle { + id: detachedSidebarBackground + anchors.fill: parent + color: Appearance.colors.colLayer0 + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_P) { + root.detach = !root.detach; + } + event.accepted = true; + } + } + } + } + } + + IpcHandler { + target: "sidebarLeft" + + function toggle(): void { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen + } + + function close(): void { + GlobalStates.sidebarLeftOpen = false + } + + function open(): void { + GlobalStates.sidebarLeftOpen = true + } + } + + GlobalShortcut { + name: "sidebarLeftToggle" + description: "Toggles left sidebar on press" + + onPressed: { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + } + + GlobalShortcut { + name: "sidebarLeftOpen" + description: "Opens left sidebar on press" + + onPressed: { + GlobalStates.sidebarLeftOpen = true; + } + } + + GlobalShortcut { + name: "sidebarLeftClose" + description: "Closes left sidebar on press" + + onPressed: { + GlobalStates.sidebarLeftOpen = false; + } + } + + GlobalShortcut { + name: "sidebarLeftToggleDetach" + description: "Detach left sidebar into a window/Attach it back" + + onPressed: { + root.detach = !root.detach; + } + } + +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/SidebarLeft.qml.template b/configs/quickshell/ii/modules/sidebarLeft/SidebarLeft.qml.template new file mode 100644 index 0000000..4104059 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/SidebarLeft.qml.template @@ -0,0 +1,241 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: sidebarLeft + + property bool visible: false + + width: 350 + height: Screen.height + color: "@SURFACE_COLOR@" + + // Slide animation + x: visible ? 0 : -width + Behavior on x { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + + ColumnLayout { + width: parent.width + spacing: 15 + + // AI Chat Section + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 300 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "AI Assistant" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + + TextArea { + id: aiChatArea + placeholderText: "Ask me anything..." + color: "@ON_SURFACE_COLOR@" + wrapMode: TextArea.Wrap + readOnly: true + } + } + + TextField { + id: aiInput + Layout.fillWidth: true + placeholderText: "Type your message..." + color: "@ON_SURFACE_COLOR@" + + onAccepted: { + sendAiMessage(text) + text = "" + } + } + } + } + + // Calendar Widget + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 200 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "Calendar" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + // Simple calendar display + Text { + text: new Date().toLocaleDateString() + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 24 + font.bold: true + } + + Text { + text: new Date().toLocaleTimeString() + color: "@ON_SURFACE_VARIANT_COLOR@" + font.pixelSize: 14 + } + } + } + + // Todo List + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 250 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "Todo List" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + + ListView { + id: todoList + model: ListModel { + ListElement { text: "Welcome to dots-hyprland!"; completed: false } + ListElement { text: "Customize your desktop"; completed: false } + ListElement { text: "Explore AI features"; completed: false } + } + + delegate: Row { + width: parent.width + spacing: 10 + + CheckBox { + checked: model.completed + onToggled: model.completed = checked + } + + Text { + text: model.text + color: "@ON_SURFACE_COLOR@" + font.strikeout: model.completed + } + } + } + } + + TextField { + Layout.fillWidth: true + placeholderText: "Add new todo..." + color: "@ON_SURFACE_COLOR@" + + onAccepted: { + if (text.trim() !== "") { + todoList.model.append({text: text, completed: false}) + text = "" + } + } + } + } + } + + // System Information + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 150 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "System Info" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + Text { + text: "CPU: " + getCpuUsage() + "%" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + + Text { + text: "Memory: " + getMemoryUsage() + "%" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + + Text { + text: "Uptime: " + getUptime() + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + } + } + } + + function sendAiMessage(message) { + // Send message to AI service + aiChatArea.append("You: " + message) + + // Call AI service (implementation depends on provider) + callAiService(message) + } + + function callAiService(message) { + // Implementation for AI service calls + // This would integrate with the AI module + aiChatArea.append("AI: I received your message: " + message) + } + + function getCpuUsage() { + // Placeholder - would integrate with system monitoring + return Math.floor(Math.random() * 100) + } + + function getMemoryUsage() { + // Placeholder - would integrate with system monitoring + return Math.floor(Math.random() * 100) + } + + function getUptime() { + // Placeholder - would integrate with system monitoring + return "2h 30m" + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/SidebarLeftContent.qml b/configs/quickshell/ii/modules/sidebarLeft/SidebarLeftContent.qml new file mode 100644 index 0000000..fc60618 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/SidebarLeftContent.qml @@ -0,0 +1,106 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +Item { + id: root + required property var scopeRoot + anchors.fill: parent + property var tabButtonList: [ + ...(Config.options.policies.ai !== 0 ? [{"icon": "neurology", "name": Translation.tr("Intelligence")}] : []), + {"icon": "translate", "name": Translation.tr("Translator")}, + ...(Config.options.policies.weeb === 1 ? [{"icon": "bookmark_heart", "name": Translation.tr("Anime")}] : []) + ] + property int selectedTab: 0 + + function focusActiveItem() { + swipeView.currentItem.forceActiveFocus() + } + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + event.accepted = true; + } + else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + event.accepted = true; + } + else if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length; + event.accepted = true; + } + else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length; + event.accepted = true; + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: sidebarPadding + + spacing: sidebarPadding + + PrimaryTabBar { // Tab strip + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { // Content pages + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + currentIndex: tabBar.externalTrackedTab + onCurrentIndexChanged: { + tabBar.enableIndicatorAnimation = true + root.selectedTab = currentIndex + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + contentChildren: [ + ...(Config.options.policies.ai !== 0 ? [aiChat.createObject()] : []), + translator.createObject(), + ...(Config.options.policies.weeb === 0 ? [] : [anime.createObject()]) + ] + } + + Component { + id: aiChat + AiChat {} + } + Component { + id: translator + Translator {} + } + Component { + id: anime + Anime {} + } + + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/Translator.qml b/configs/quickshell/ii/modules/sidebarLeft/Translator.qml new file mode 100644 index 0000000..e9e3d6f --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/Translator.qml @@ -0,0 +1,246 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./translator/" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +/** + * Translator widget with the `trans` commandline tool. + */ +Item { + id: root + // Widgets + property var inputField: inputCanvas.inputTextArea + // Widget variables + property bool translationFor: false // Indicates if the translation is for an autocorrected text + property string translatedText: "" + property list languages: [] + // Options + property string targetLanguage: Config.options.language.translator.targetLanguage + property string sourceLanguage: Config.options.language.translator.sourceLanguage + property string hostLanguage: targetLanguage + + property bool showLanguageSelector: false + property bool languageSelectorTarget: false // true for target language, false for source language + + function showLanguageSelectorDialog(isTargetLang: bool) { + root.languageSelectorTarget = isTargetLang; + root.showLanguageSelector = true + } + + onFocusChanged: (focus) => { + if (focus) { + root.inputField.forceActiveFocus() + } + } + + Timer { + id: translateTimer + interval: Config.options.sidebar.translator.delay + repeat: false + onTriggered: () => { + if (root.inputField.text.trim().length > 0) { + // console.log("Translating with command:", translateProc.command); + translateProc.running = false; + translateProc.buffer = ""; // Clear the buffer + translateProc.running = true; // Restart the process + } else { + root.translatedText = ""; + } + } + } + + Process { + id: translateProc + command: ["bash", "-c", `trans -no-theme -no-bidi` + + ` -source '${StringUtils.shellSingleQuoteEscape(root.sourceLanguage)}'` + + ` -target '${StringUtils.shellSingleQuoteEscape(root.targetLanguage)}'` + + ` -no-ansi '${StringUtils.shellSingleQuoteEscape(root.inputField.text.trim())}'`] + property string buffer: "" + stdout: SplitParser { + onRead: data => { + translateProc.buffer += data + "\n"; + } + } + onExited: (exitCode, exitStatus) => { + // 1. Split into sections by double newlines + const sections = translateProc.buffer.trim().split(/\n\s*\n/); + // console.log("BUFFER:", translateProc.buffer); + // console.log("SECTIONS:", sections); + + // 2. Extract relevant data + root.translatedText = sections.length > 1 ? sections[1].trim() : ""; + } + } + + Process { + id: getLanguagesProc + command: ["trans", "-list-languages", "-no-bidi"] + property list bufferList: ["auto"] + running: true + stdout: SplitParser { + onRead: data => { + getLanguagesProc.bufferList.push(data.trim()); + } + } + onExited: (exitCode, exitStatus) => { + // Ensure "auto" is always the first language + let langs = getLanguagesProc.bufferList + .filter(lang => lang.trim().length > 0 && lang !== "auto") + .sort((a, b) => a.localeCompare(b)); + langs.unshift("auto"); + root.languages = langs; + getLanguagesProc.bufferList = []; // Clear the buffer + } + } + + ColumnLayout { + anchors.fill: parent + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: contentColumn.implicitHeight + + ColumnLayout { + id: contentColumn + anchors.fill: parent + + LanguageSelectorButton { // Target language button + id: targetLanguageButton + displayText: root.targetLanguage + onClicked: { + root.showLanguageSelectorDialog(true); + } + } + + TextCanvas { // Content translation + id: outputCanvas + isInput: false + placeholderText: Translation.tr("Translation goes here...") + property bool hasTranslation: (root.translatedText.trim().length > 0) + text: hasTranslation ? root.translatedText : "" + GroupButton { + id: copyButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: outputCanvas.displayedText.trim().length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "content_copy" + color: copyButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + Quickshell.clipboardText = outputCanvas.displayedText + } + } + GroupButton { + id: searchButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: outputCanvas.displayedText.trim().length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "travel_explore" + color: searchButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + let url = Config.options.search.engineBaseUrl + outputCanvas.displayedText; + for (let site of Config.options.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + } + } + + } + } + + LanguageSelectorButton { // Source language button + id: sourceLanguageButton + displayText: root.sourceLanguage + onClicked: { + root.showLanguageSelectorDialog(false); + } + } + + TextCanvas { // Content input + id: inputCanvas + isInput: true + placeholderText: Translation.tr("Enter text to translate...") + onInputTextChanged: { + translateTimer.restart(); + } + GroupButton { + id: pasteButton + baseWidth: height + buttonRadius: Appearance.rounding.small + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "content_paste" + color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + root.inputField.text = Quickshell.clipboardText + } + } + GroupButton { + id: deleteButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: inputCanvas.inputTextArea.text.length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "close" + color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + root.inputField.text = "" + } + } + } + } + + Loader { + anchors.fill: parent + active: root.showLanguageSelector + visible: root.showLanguageSelector + z: 9999 + sourceComponent: SelectionDialog { + id: languageSelectorDialog + titleText: Translation.tr("Select Language") + items: root.languages + defaultChoice: root.languageSelectorTarget ? root.targetLanguage : root.sourceLanguage + onCanceled: () => { + root.showLanguageSelector = false; + } + onSelected: (result) => { + root.showLanguageSelector = false; + if (!result || result.length === 0) return; // No selection made + + if (root.languageSelectorTarget) { + root.targetLanguage = result; + Config.options.language.translator.targetLanguage = result; // Save to config + } else { + root.sourceLanguage = result; + Config.options.language.translator.sourceLanguage = result; // Save to config + } + + translateTimer.restart(); // Restart translation after language change + } + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/AiMessage.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/AiMessage.qml new file mode 100644 index 0000000..d2b72d1 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/AiMessage.qml @@ -0,0 +1,302 @@ +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 + +Rectangle { + id: root + property int messageIndex + property var messageData + property var messageInputField + + property real messagePadding: 7 + property real contentSpacing: 3 + + property bool enableMouseSelection: false + property bool renderMarkdown: true + property bool editing: false + + property list messageBlocks: StringUtils.splitMarkdownBlocks(root.messageData?.content) + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2 + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + function saveMessage() { + if (!root.editing) return; + // Get all Loader children (each represents a segment) + const segments = messageContentColumnLayout.children + .map(child => child.segment) + .filter(segment => (segment)); + + // Reconstruct markdown + const newContent = segments.map(segment => { + if (segment.type === "code") { + const lang = segment.lang ? segment.lang : ""; + // Remove trailing newlines + const code = segment.content.replace(/\n+$/, ""); + return "```" + lang + "\n" + code + "\n```"; + } else { + return segment.content; + } + }).join(""); + + root.editing = false + root.messageData.content = newContent; + } + + Keys.onPressed: (event) => { + if ( // Prevent de-select + event.key === Qt.Key_Control || + event.key == Qt.Key_Shift || + event.key == Qt.Key_Alt || + event.key == Qt.Key_Meta + ) { + event.accepted = true + } + // Ctrl + S to save + if ((event.key === Qt.Key_S) && event.modifiers == Qt.ControlModifier) { + root.saveMessage(); + event.accepted = true; + } + } + + ColumnLayout { // Main layout of the whole thing + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: messagePadding + spacing: root.contentSpacing + + RowLayout { // Header + spacing: 15 + Layout.fillWidth: true + + Rectangle { // Name + id: nameWrapper + color: Appearance.colors.colSecondaryContainer + // color: "transparent" + radius: Appearance.rounding.small + implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30) + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + RowLayout { + id: nameRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 7 + + Item { + Layout.alignment: Qt.AlignVCenter + Layout.fillHeight: true + implicitWidth: messageData?.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth + implicitHeight: messageData?.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight + + CustomIcon { + id: modelIcon + anchors.centerIn: parent + visible: messageData?.role == 'assistant' && Ai.models[messageData?.model].icon + width: Appearance.font.pixelSize.large + height: Appearance.font.pixelSize.large + source: messageData?.role == 'assistant' ? Ai.models[messageData?.model].icon : + messageData?.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic' + + colorize: true + color: Appearance.m3colors.m3onSecondaryContainer + } + + MaterialSymbol { + id: roleIcon + anchors.centerIn: parent + visible: !modelIcon.visible + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData?.role == 'user' ? 'person' : + messageData?.role == 'interface' ? 'settings' : + messageData?.role == 'assistant' ? 'neurology' : + 'computer' + } + } + + StyledText { + id: providerName + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData?.role == 'assistant' ? Ai.models[messageData?.model].name : + (messageData?.role == 'user' && SystemInfo.username) ? SystemInfo.username : + Translation.tr("Interface") + } + } + } + + Button { // Not visible to model + id: modelVisibilityIndicator + visible: messageData?.role == 'interface' + implicitWidth: 16 + implicitHeight: 30 + Layout.alignment: Qt.AlignVCenter + + background: Item + + MaterialSymbol { + id: notVisibleToModelText + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + text: "visibility_off" + } + StyledToolTip { + content: Translation.tr("Not visible to model") + } + } + + ButtonGroup { + spacing: 5 + + AiMessageControlButton { + id: copyButton + buttonIcon: activated ? "inventory" : "content_copy" + + onClicked: { + Quickshell.clipboardText = root.messageData?.content + copyButton.activated = true + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyButton.activated = false + } + } + + StyledToolTip { + content: Translation.tr("Copy") + } + } + AiMessageControlButton { + id: editButton + activated: root.editing + enabled: root.messageData?.done ?? false + buttonIcon: "edit" + onClicked: { + root.editing = !root.editing + if (!root.editing) { // Save changes + root.saveMessage() + } + } + StyledToolTip { + content: root.editing ? Translation.tr("Save") : Translation.tr("Edit") + } + } + AiMessageControlButton { + id: toggleMarkdownButton + activated: !root.renderMarkdown + buttonIcon: "code" + onClicked: { + root.renderMarkdown = !root.renderMarkdown + } + StyledToolTip { + content: Translation.tr("View Markdown source") + } + } + AiMessageControlButton { + id: deleteButton + buttonIcon: "close" + onClicked: { + Ai.removeMessage(root.messageIndex) + } + StyledToolTip { + content: Translation.tr("Delete") + } + } + } + } + + ColumnLayout { // Message content + id: messageContentColumnLayout + + spacing: 0 + Repeater { + model: root.messageBlocks.length + delegate: Loader { + required property int index + property var thisBlock: root.messageBlocks[index] + Layout.fillWidth: true + // property var segment: thisBlock + property var segmentContent: thisBlock.content + property var segmentLang: thisBlock.lang + property var messageData: root.messageData + property var editing: root.editing + property var renderMarkdown: root.renderMarkdown + property var enableMouseSelection: root.enableMouseSelection + property bool thinking: root.messageData?.thinking ?? true + property bool done: root.messageData?.done ?? false + property bool completed: thisBlock.completed ?? false + + source: thisBlock.type === "code" ? "MessageCodeBlock.qml" : + thisBlock.type === "think" ? "MessageThinkBlock.qml" : + "MessageTextBlock.qml" + + } + } + } + + Flow { // Annotations + visible: root.messageData?.annotationSources?.length > 0 + spacing: 5 + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + Repeater { + model: ScriptModel { + values: root.messageData?.annotationSources || [] + } + delegate: AnnotationSourceButton { + required property var modelData + displayText: modelData.text + url: modelData.url + } + } + } + + Flow { // Search queries + visible: root.messageData?.searchQueries?.length > 0 + spacing: 5 + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + Repeater { + model: ScriptModel { + values: root.messageData?.searchQueries || [] + } + delegate: SearchQueryButton { + required property var modelData + query: modelData + } + } + } + + } +} + diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml new file mode 100644 index 0000000..64fc772 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml @@ -0,0 +1,26 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick + +GroupButton { + id: button + property string buttonIcon + property bool activated: false + toggled: activated + + baseWidth: height + + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: buttonIcon + color: button.activated ? Appearance.m3colors.m3onPrimary : + button.enabled ? Appearance.m3colors.m3onSurface : + Appearance.colors.colOnLayer1Inactive + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml new file mode 100644 index 0000000..75687e4 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml @@ -0,0 +1,52 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland + +RippleButton { + id: root + property string displayText + property string url + + property real faviconSize: 20 + implicitHeight: 30 + leftPadding: (implicitHeight - faviconSize) / 2 + rightPadding: 10 + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + PointingHandInteraction {} + onClicked: { + if (url) { + Qt.openUrlExternally(url) + GlobalStates.sidebarLeftOpen = false + } + } + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 5 + Favicon { + url: root.url + size: root.faviconSize + displayText: root.displayText + } + StyledText { + id: text + horizontalAlignment: Text.AlignHCenter + text: displayText + color: Appearance.m3colors.m3onSurface + } + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml new file mode 100644 index 0000000..f8b0bac --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -0,0 +1,297 @@ +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.Controls +import QtQuick.Layouts +import Quickshell +import org.kde.syntaxhighlighting + +ColumnLayout { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property var segmentContent: parent?.segmentContent ?? ({}) + property var segmentLang: parent?.segmentLang ?? "txt" + property bool isCommandRequest: segmentLang === "command" + property var displayLang: (isCommandRequest ? "bash" : segmentLang) + property var messageData: parent?.messageData ?? {} + + property real codeBlockBackgroundRounding: Appearance.rounding.small + property real codeBlockHeaderPadding: 3 + property real codeBlockComponentSpacing: 2 + + spacing: codeBlockComponentSpacing + anchors.left: parent.left + anchors.right: parent.right + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: codeBlockBackgroundRounding + topRightRadius: codeBlockBackgroundRounding + bottomLeftRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colSurfaceContainerHighest + implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + codeBlockHeaderPadding * 2 + + RowLayout { // Language and buttons + id: codeBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: codeBlockHeaderPadding + anchors.rightMargin: codeBlockHeaderPadding + spacing: 5 + + StyledText { + id: codeBlockLanguage + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 10 + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.DemiBold + color: Appearance.colors.colOnLayer2 + text: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain" + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + AiMessageControlButton { + id: copyCodeButton + buttonIcon: activated ? "inventory" : "content_copy" + + onClicked: { + Quickshell.clipboardText = segmentContent + copyCodeButton.activated = true + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyCodeButton.activated = false + } + } + StyledToolTip { + content: Translation.tr("Copy code") + } + } + AiMessageControlButton { + id: saveCodeButton + buttonIcon: activated ? "check" : "save" + + onClicked: { + const downloadPath = FileUtils.trimFileProtocol(Directories.downloads) + Quickshell.execDetached(["bash", "-c", + `echo '${StringUtils.shellSingleQuoteEscape(segmentContent)}' > '${downloadPath}/code.${segmentLang || "txt"}'` + ]) + Quickshell.execDetached(["notify-send", + Translation.tr("Code saved to file"), + Translation.tr("Saved to %1").arg(`${downloadPath}/code.${segmentLang || "txt"}`), + "-a", "Shell" + ]) + saveCodeButton.activated = true + saveIconTimer.restart() + } + + Timer { + id: saveIconTimer + interval: 1500 + repeat: false + onTriggered: { + saveCodeButton.activated = false + } + } + StyledToolTip { + content: Translation.tr("Save to Downloads") + } + } + } + } + } + + RowLayout { // Line numbers and code + spacing: codeBlockComponentSpacing + + Rectangle { // Line numbers + implicitWidth: 40 + implicitHeight: lineNumberColumnLayout.implicitHeight + Layout.fillHeight: true + Layout.fillWidth: false + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: codeBlockBackgroundRounding + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colLayer2 + + ColumnLayout { + id: lineNumberColumnLayout + anchors { + left: parent.left + right: parent.right + rightMargin: 5 + top: parent.top + topMargin: 6 + } + spacing: 0 + + Repeater { + model: codeTextArea.text.split("\n").length + Text { + required property int index + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + font.family: Appearance.font.family.monospace + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: index + 1 + } + } + } + } + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: Appearance.rounding.unsharpen + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: codeBlockBackgroundRounding + color: Appearance.colors.colLayer2 + implicitHeight: codeColumnLayout.implicitHeight + + ColumnLayout { + id: codeColumnLayout + anchors.fill: parent + spacing: 0 + ScrollView { + id: codeScrollView + Layout.fillWidth: true + // Layout.fillHeight: true + implicitWidth: parent.width + implicitHeight: codeTextArea.implicitHeight + 1 + contentWidth: codeTextArea.width - 1 + // contentHeight: codeTextArea.contentHeight + clip: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ScrollBar.horizontal: ScrollBar { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + padding: 5 + policy: ScrollBar.AsNeeded + opacity: visualSize == 1 ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + contentItem: Rectangle { + implicitHeight: 6 + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2Active + } + } + + TextArea { // Code + id: codeTextArea + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.monospace + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + // wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + + text: segmentContent + onTextChanged: { + segmentContent = text + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + // Insert 4 spaces at cursor + const cursor = codeTextArea.cursorPosition; + codeTextArea.insert(cursor, " "); + codeTextArea.cursorPosition = cursor + 4; + event.accepted = true; + } else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { + codeTextArea.copy(); + event.accepted = true; + } + } + + SyntaxHighlighter { + id: highlighter + textEdit: codeTextArea + repository: Repository + definition: Repository.definitionForName(root.displayLang || "plaintext") + theme: Appearance.syntaxHighlightingTheme + } + } + } + Loader { + active: root.isCommandRequest && root.messageData.functionPending + visible: active + Layout.fillWidth: true + Layout.margins: 6 + Layout.topMargin: 0 + sourceComponent: RowLayout { + Item { Layout.fillWidth: true } + ButtonGroup { + GroupButton { + contentItem: StyledText { + text: Translation.tr("Reject") + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer2 + } + onClicked: Ai.rejectCommand(root.messageData) + } + GroupButton { + toggled: true + contentItem: StyledText { + text: Translation.tr("Approve") + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnPrimary + } + onClicked: Ai.approveCommand(root.messageData) + } + } + } + } + } + + // MouseArea to block scrolling + // MouseArea { + // id: codeBlockMouseArea + // anchors.fill: parent + // acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton + // cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + // onWheel: (event) => { + // event.accepted = false + // } + // } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml new file mode 100644 index 0000000..d0d9d64 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml @@ -0,0 +1,142 @@ +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.Controls +import QtQuick.Layouts +import Quickshell.Hyprland + +ColumnLayout { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property list renderedLatexHashes: [] + + property string renderedSegmentContent: "" + + Layout.fillWidth: true + + Timer { + id: renderTimer + interval: 1000 + repeat: false + onTriggered: { + renderLatex() + for (const hash of renderedLatexHashes) { + handleRenderedLatex(hash, true); + } + } + } + + function renderLatex() { + // Regex for $...$, $$...$$, \[...\] + // Note: This is a simple approach and may need refinement for edge cases + let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])|(\\\(([\s\S]+?)\\\))/g; + let match; + while ((match = regex.exec(segmentContent)) !== null) { + let expression = match[1] || match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8]; + if (expression) { + Qt.callLater(() => { + const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim()); + if (!renderedLatexHashes.includes(renderHash)) { + renderedLatexHashes.push(renderHash); + } + }); + } + } + } + + function handleRenderedLatex(hash, force = false) { + if (renderedLatexHashes.includes(hash) || force) { + const imagePath = LatexRenderer.renderedImagePaths[hash]; + const markdownImage = `![latex](${imagePath})`; + + const expression = LatexRenderer.processedExpressions[hash]; + renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage); + } + } + + onDoneChanged: { + renderTimer.restart(); + } + onEditingChanged: { + if (!editing) { + renderLatex() + } else { + // console.log("Editing mode enabled", segmentContent) + textArea.text = segmentContent + } + } + + onSegmentContentChanged: { + // console.log("Segment content changed: " + segmentContent); + renderedSegmentContent = segmentContent; + if (!root.editing && segmentContent) { + root.renderLatex(); + } + } + + onRenderedSegmentContentChanged: { + // console.log("Rendered segment content changed: " + renderedSegmentContent); + if (renderedSegmentContent) { + textArea.text = renderedSegmentContent; + } + } + + // When something finishes rendering + // 1. Check if the hash is in the list + // 2. If it is, replace the expression with the image path + Connections { + target: LatexRenderer + function onRenderFinished(hash, imagePath) { + const expression = LatexRenderer.processedExpressions[hash]; + // console.log("Render finished: " + hash + " " + expression); + handleRenderedLatex(hash); + } + } + + TextArea { + id: textArea + + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.reading + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText + text: Translation.tr("Waiting for response...") + + onTextChanged: { + if (!root.editing) return + segmentContent = text + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.sidebarLeftOpen = false + } + + MouseArea { // Pointing hand for links + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : + (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml new file mode 100644 index 0000000..326c26d --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml @@ -0,0 +1,173 @@ +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 Qt5Compat.GraphicalEffects + +Item { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property bool completed: parent?.completed ?? false + + property real thinkBlockBackgroundRounding: Appearance.rounding.small + property real thinkBlockHeaderPaddingVertical: 3 + property real thinkBlockHeaderPaddingHorizontal: 10 + property real thinkBlockComponentSpacing: 2 + + property var collapseAnimation: messageTextBlock.implicitHeight > 40 ? Appearance.animation.elementMoveEnter : Appearance.animation.elementMoveFast + property bool collapsed: true /* should be root.completed but its kinda buggy rn so nope */ + + Layout.fillWidth: true + implicitHeight: collapsed ? header.implicitHeight : columnLayout.implicitHeight + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: thinkBlockBackgroundRounding + } + } + + Behavior on implicitHeight { + enabled: root.completed ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + ColumnLayout { + id: columnLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 0 + + Rectangle { // Header background + id: header + color: Appearance.colors.colSurfaceContainerHighest + Layout.fillWidth: true + implicitHeight: thinkBlockTitleBarRowLayout.implicitHeight + thinkBlockHeaderPaddingVertical * 2 + + MouseArea { // Click to reveal + id: headerMouseArea + enabled: root.completed + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + root.collapsed = !root.collapsed + } + } + + RowLayout { // Header content + id: thinkBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: thinkBlockHeaderPaddingHorizontal + anchors.rightMargin: thinkBlockHeaderPaddingHorizontal + spacing: 10 + + MaterialSymbol { + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 3 + text: "linked_services" + } + StyledText { + id: thinkBlockLanguage + Layout.fillWidth: false + Layout.alignment: Qt.AlignLeft + text: root.completed ? Translation.tr("Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4)) + } + Item { Layout.fillWidth: true } + RippleButton { // Expand button + id: expandButton + visible: root.completed + implicitWidth: 22 + implicitHeight: 22 + colBackground: headerMouseArea.containsMouse ? Appearance.colors.colLayer2Hover + : ColorUtils.transparentize(Appearance.colors.colLayer2, 1) + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + onClicked: { root.collapsed = !root.collapsed } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "keyboard_arrow_down" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer2 + rotation: root.collapsed ? 0 : 180 + Behavior on rotation { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } + + } + + } + + } + + Item { + id: content + Layout.fillWidth: true + implicitHeight: collapsed ? 0 : contentBackground.implicitHeight + thinkBlockComponentSpacing + clip: true + + Behavior on implicitHeight { + enabled: root.completed ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + Rectangle { + id: contentBackground + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: messageTextBlock.implicitHeight + color: Appearance.colors.colLayer2 + + // Load data for the message at the correct scope + property bool editing: root.editing + property bool renderMarkdown: root.renderMarkdown + property bool enableMouseSelection: root.enableMouseSelection + property string segmentContent: root.segmentContent + property var messageData: root.messageData + property bool done: root.done + + MessageTextBlock { + id: messageTextBlock + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml b/configs/quickshell/ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml new file mode 100644 index 0000000..4ad60ac --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml @@ -0,0 +1,53 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland + +RippleButton { + id: root + property string query + + implicitHeight: 30 + leftPadding: 6 + rightPadding: 10 + buttonRadius: Appearance.rounding.verysmall + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + PointingHandInteraction {} + onClicked: { + let url = Config.options.search.engineBaseUrl + root.query; + for (let site of (Config?.options?.search.excludedSites ?? [])) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + GlobalStates.sidebarLeftOpen = false; + } + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + RowLayout { + id: rowLayout + anchors.centerIn: parent + spacing: 5 + MaterialSymbol { + text: "search" + iconSize: 20 + color: Appearance.m3colors.m3onSurface + } + StyledText { + id: text + horizontalAlignment: Text.AlignHCenter + text: root.query + color: Appearance.m3colors.m3onSurface + } + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/anime/BooruImage.qml b/configs/quickshell/ii/modules/sidebarLeft/anime/BooruImage.qml new file mode 100644 index 0000000..abb7461 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/anime/BooruImage.qml @@ -0,0 +1,190 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQml +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Button { + id: root + property var imageData + property var rowHeight + property bool manualDownload: true + property string previewDownloadPath + property string downloadPath + property string nsfwPath + property string fileName: decodeURIComponent((imageData.file_url).substring((imageData.file_url).lastIndexOf('/') + 1)) + property string filePath: `${root.previewDownloadPath}/${root.fileName}` + property int maxTagStringLineLength: 50 + property real imageRadius: Appearance.rounding.small + + property bool showActions: false + Process { + id: downloadProcess + running: false + command: ["bash", "-c", `[ -f ${root.filePath} ] || curl -sSL '${root.imageData.preview_url ?? root.imageData.sample_url}' -o '${root.filePath}'`] + onExited: (exitCode, exitStatus) => { + imageObject.source = `${previewDownloadPath}/${root.fileName}` + } + } + + Component.onCompleted: { + if (root.manualDownload) { + downloadProcess.running = true + } + } + + StyledToolTip { + content: `${StringUtils.wordWrap(root.imageData.tags, root.maxTagStringLineLength)}` + } + + padding: 0 + implicitWidth: root.rowHeight * modelData.aspect_ratio + implicitHeight: root.rowHeight + + background: Rectangle { + implicitWidth: root.rowHeight * modelData.aspect_ratio + implicitHeight: root.rowHeight + radius: imageRadius + color: Appearance.colors.colLayer2 + } + + contentItem: Item { + anchors.fill: parent + + Image { + id: imageObject + anchors.fill: parent + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + visible: opacity > 0 + opacity: status === Image.Ready ? 1 : 0 + fillMode: Image.PreserveAspectFit + source: modelData.preview_url + sourceSize.width: root.rowHeight * modelData.aspect_ratio + sourceSize.height: root.rowHeight + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + radius: imageRadius + } + } + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + + RippleButton { + id: menuButton + anchors.top: parent.top + anchors.right: parent.right + property real buttonSize: 30 + anchors.margins: Math.max(root.imageRadius - buttonSize / 2, 8) + implicitHeight: buttonSize + implicitWidth: buttonSize + + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.transparentize(Appearance.m3colors.m3surface, 0.3) + colBackgroundHover: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.8), 0.2) + colRipple: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.6), 0.1) + + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSurface + text: "more_vert" + } + + onClicked: { + root.showActions = !root.showActions + } + } + + Loader { + id: contextMenuLoader + active: root.showActions + anchors.top: menuButton.bottom + anchors.right: parent.right + anchors.margins: 8 + + sourceComponent: Item { + width: contextMenu.width + height: contextMenu.height + + StyledRectangularShadow { + target: contextMenu + } + Rectangle { + id: contextMenu + anchors.centerIn: parent + opacity: root.showActions ? 1 : 0 + visible: opacity > 0 + radius: Appearance.rounding.small + color: Appearance.colors.colSurfaceContainer + implicitHeight: contextMenuColumnLayout.implicitHeight + radius * 2 + implicitWidth: contextMenuColumnLayout.implicitWidth + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + ColumnLayout { + id: contextMenuColumnLayout + anchors.centerIn: parent + spacing: 0 + + MenuButton { + id: openFileLinkButton + Layout.fillWidth: true + buttonText: Translation.tr("Open file link") + onClicked: { + root.showActions = false + Hyprland.dispatch("keyword cursor:no_warps true") + Qt.openUrlExternally(root.imageData.file_url) + Hyprland.dispatch("keyword cursor:no_warps false") + } + } + MenuButton { + id: sourceButton + visible: root.imageData.source && root.imageData.source.length > 0 + Layout.fillWidth: true + buttonText: Translation.tr("Go to source (%1)").arg(StringUtils.getDomain(root.imageData.source)) + enabled: root.imageData.source && root.imageData.source.length > 0 + onClicked: { + root.showActions = false + Hyprland.dispatch("keyword cursor:no_warps true") + Qt.openUrlExternally(root.imageData.source) + Hyprland.dispatch("keyword cursor:no_warps false") + } + } + MenuButton { + id: downloadButton + Layout.fillWidth: true + buttonText: Translation.tr("Download") + onClicked: { + root.showActions = false + Quickshell.execDetached(["bash", "-c", + `curl '${root.imageData.file_url}' -o '${root.imageData.is_nsfw ? root.nsfwPath : root.downloadPath}/${root.fileName}' && notify-send '${Translation.tr("Download complete")}' '${root.downloadPath}/${root.fileName}' -a 'Shell'` + ]) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/anime/BooruResponse.qml b/configs/quickshell/ii/modules/sidebarLeft/anime/BooruResponse.qml new file mode 100644 index 0000000..baf4771 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/anime/BooruResponse.qml @@ -0,0 +1,294 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "../" +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Rectangle { + id: root + property var responseData + property var tagInputField + + property string previewDownloadPath + property string downloadPath + property string nsfwPath + + property real availableWidth: parent.width + property real rowTooShortThreshold: 190 + property real imageSpacing: 5 + property real responsePadding: 5 + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.responsePadding * 2 + + Component.onCompleted: { + // Break property bind to prevent aggressive updates + availableWidth = parent.width + } + + Connections { + target: parent + function onWidthChanged() { + updateWidthTimer.restart() + } + } + + Timer { + id: updateWidthTimer + interval: 100 + onTriggered: { + availableWidth = parent.width + } + } + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + ColumnLayout { + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: responsePadding + spacing: root.imageSpacing + + RowLayout { // Header + Rectangle { // Provider name + id: providerNameWrapper + color: Appearance.colors.colSecondaryContainer + radius: Appearance.rounding.small + implicitWidth: providerName.implicitWidth + 10 * 2 + implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: providerName + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSecondaryContainer + text: Booru.providers[root.responseData.provider].name + } + } + Item { Layout.fillWidth: true } + Item { // Page number + visible: root.responseData.page != "" && root.responseData.page > 0 + implicitWidth: Math.max(pageNumber.implicitWidth + 10 * 2, 30) + implicitHeight: pageNumber.implicitHeight + 5 * 2 + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: pageNumber + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer2 + // text: `Page ${root.responseData.page}` + text: Translation.tr("Page %1").arg(root.responseData.page) + } + } + } + + StyledFlickable { // Tag strip + id: tagsFlickable + visible: root.responseData.tags.length > 0 + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: { + return true + } + implicitHeight: tagRowLayout.implicitHeight + // height: tagRowLayout.implicitHeight + contentWidth: tagRowLayout.implicitWidth + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: tagsFlickable.width + height: tagsFlickable.height + radius: Appearance.rounding.small + } + } + + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { + id: tagRowLayout + Layout.alignment: Qt.AlignBottom + + Repeater { + id: tagRepeater + model: root.responseData.tags + + ApiCommandButton { + Layout.fillWidth: false + buttonText: modelData + onClicked: { + if(root.tagInputField.text.length !== 0) root.tagInputField.text += " " + root.tagInputField.text += modelData + } + } + } + + } + } + + StyledText { // Message + id: messageText + Layout.fillWidth: true + visible: root.responseData.message.length > 0 + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: root.responseData.message + wrapMode: Text.WordWrap + Layout.margins: responsePadding + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.sidebarLeftOpen = false + } + PointingHandLinkHover {} + } + + Repeater { + model: ScriptModel { + values: { + // Greedily add images to a row as long as rowHeight >= rowTooShortThreshold + let i = 0; + let rows = []; + const responseList = root.responseData.images; + const minRowHeight = rowTooShortThreshold; + const availableImageWidth = availableWidth - root.imageSpacing - (responsePadding * 2); + + while (i < responseList.length) { + let row = { + height: 0, + images: [], + }; + let j = i; + let combinedAspect = 0; + let rowHeight = 0; + + // Try to add as many images as possible without going below minRowHeight + while (j < responseList.length) { + combinedAspect += responseList[j].aspect_ratio; + // Subtract imageSpacing for each gap between images in the row + let imagesInRow = j - i + 1; + let totalSpacing = root.imageSpacing * (imagesInRow - 1); + let rowAvailableWidth = availableImageWidth - totalSpacing; + rowHeight = rowAvailableWidth / combinedAspect; + if (rowHeight < minRowHeight) { + combinedAspect -= responseList[j].aspect_ratio; + imagesInRow -= 1; + totalSpacing = root.imageSpacing * (imagesInRow - 1); + rowAvailableWidth = availableImageWidth - totalSpacing; + rowHeight = rowAvailableWidth / combinedAspect; + break; + } + j++; + } + + // If we couldn't add any image (shouldn't happen), add at least one + if (j === i) { + row.images.push(responseList[i]); + row.height = availableImageWidth / responseList[i].aspect_ratio; + rows.push(row); + i++; + } else { + for (let k = i; k < j; k++) { + row.images.push(responseList[k]); + } + // Recalculate spacing for the final row + let imagesInRow = j - i; + let totalSpacing = root.imageSpacing * (imagesInRow - 1); + let rowAvailableWidth = availableImageWidth - totalSpacing; + row.height = rowAvailableWidth / combinedAspect; + rows.push(row); + i = j; + } + } + return rows; + } + } + delegate: RowLayout { + id: imageRow + required property var modelData + property var rowHeight: modelData.height + spacing: root.imageSpacing + + Repeater { + model: modelData.images + delegate: BooruImage { + required property var modelData + imageData: modelData + rowHeight: imageRow.rowHeight + imageRadius: imageRow.modelData.images.length == 1 ? 50 : Appearance.rounding.normal + // Download manually to reduce redundant requests or make sure downloading works + // manualDownload: ["danbooru", "waifu.im", "t.alcy.cc"].includes(root.responseData.provider) + previewDownloadPath: root.previewDownloadPath + downloadPath: root.downloadPath + nsfwPath: root.nsfwPath + } + } + } + } + + RippleButton { // Next page button + id: button + property string buttonText + visible: root.responseData.page != "" && root.responseData.page > 0 + + Layout.alignment: Qt.AlignRight + implicitHeight: 30 + leftPadding: 10 + rightPadding: 5 + + onClicked: { + tagInputField.text = `${responseData.tags.join(" ")} ${parseInt(root.responseData.page) + 1}` + tagInputField.accept() + } + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + contentItem: Item { + anchors.fill: parent + implicitHeight: nextPageRow.implicitHeight + implicitWidth: nextPageRow.implicitWidth + + RowLayout { + id: nextPageRow + anchors.centerIn: parent + spacing: 0 + StyledText { + Layout.alignment: Qt.AlignVCenter + verticalAlignment: Text.AlignVCenter + text: "Next page" + color: Appearance.m3colors.m3onSurface + } + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSurface + text: "chevron_right" + } + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarLeft/qmldir b/configs/quickshell/ii/modules/sidebarLeft/qmldir new file mode 100644 index 0000000..49a7be0 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/qmldir @@ -0,0 +1,10 @@ +module qs.modules.sidebarLeft + +AiChat 1.0 AiChat.qml +Anime 1.0 Anime.qml +ApiCommandButton 1.0 ApiCommandButton.qml +Calculator 1.0 Calculator.qml +Cliphist 1.0 Cliphist.qml +SidebarLeft 1.0 SidebarLeft.qml +Todo 1.0 Todo.qml +Translator 1.0 Translator.qml diff --git a/configs/quickshell/ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml b/configs/quickshell/ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml new file mode 100644 index 0000000..f23e3b8 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml @@ -0,0 +1,41 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: root + property string displayText: "" + colBackground: Appearance.colors.colLayer2 + + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: contentItem.implicitHeight + verticalPadding * 2 + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: languageRow.implicitWidth + implicitHeight: languageText.implicitHeight + RowLayout { + id: languageRow + anchors.centerIn: parent + spacing: 0 + StyledText { + id: languageText + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 5 + text: root.displayText + color: Appearance.colors.colOnLayer2 + font.pixelSize: Appearance.font.pixelSize.small + } + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.hugeass + text: "arrow_drop_down" + color: Appearance.colors.colOnLayer2 + } + } + } +} diff --git a/configs/quickshell/ii/modules/sidebarLeft/translator/TextCanvas.qml b/configs/quickshell/ii/modules/sidebarLeft/translator/TextCanvas.qml new file mode 100644 index 0000000..c29265c --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarLeft/translator/TextCanvas.qml @@ -0,0 +1,89 @@ +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 + +Rectangle { + id: root + property bool isInput: true // true for input, false for output + property string placeholderText + property string text: "" + property var inputTextArea: isInput ? inputLoader.item : undefined + readonly property string displayedText: isInput ? inputLoader.item.text : + root.text.length > 0 ? outputLoader.item.text : "" + default property alias actionButtons: actions.data + Layout.fillWidth: true + implicitHeight: Math.max(150, inputColumn.implicitHeight) + color: isInput ? Appearance.colors.colLayer1 : Appearance.colors.colSurfaceContainer + radius: Appearance.rounding.normal + border.color: isInput ? Appearance.colors.colOutlineVariant : "transparent" + border.width: isInput ? 1 : 0 + + signal inputTextChanged(); // Signal emitted when text changes + + ColumnLayout { + id: inputColumn + anchors.fill: parent + spacing: 0 + + Loader { + id: inputLoader + active: root.isInput + visible: root.isInput + Layout.fillWidth: true + sourceComponent: StyledTextArea { // Input area + id: inputTextArea + placeholderText: root.placeholderText + wrapMode: TextEdit.Wrap + textFormat: TextEdit.PlainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + padding: 15 + background: null + onTextChanged: root.inputTextChanged() + } + } + + Loader { + id: outputLoader + active: !root.isInput + visible: !root.isInput + Layout.fillWidth: true + sourceComponent: StyledText { // Output area + id: outputTextArea + padding: 15 + wrapMode: Text.Wrap + font.pixelSize: Appearance.font.pixelSize.small + color: root.text.length > 0 ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + text: root.text.length > 0 ? root.text : root.placeholderText + } + } + + Item { Layout.fillHeight: true } + + RowLayout { // Status row + Layout.fillWidth: true + Layout.margins: 10 + spacing: 10 + + Loader { + active: root.isInput + visible: root.isInput + Layout.leftMargin: 10 + sourceComponent: Text { + text: Translation.tr("%1 characters").arg(inputLoader.item.text.length) + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.smaller + } + } + Item { Layout.fillWidth: true } + ButtonGroup { + id: actions + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/BottomWidgetGroup.qml b/configs/quickshell/ii/modules/sidebarRight/BottomWidgetGroup.qml new file mode 100644 index 0000000..3840116 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/BottomWidgetGroup.qml @@ -0,0 +1,241 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import qs.services +import "./calendar" +import "./todo" +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + clip: true + implicitHeight: collapsed ? collapsedBottomWidgetGroupRow.implicitHeight : bottomWidgetGroupRow.implicitHeight + property int selectedTab: Persistent.states.sidebar.bottomGroup.tab + property bool collapsed: Persistent.states.sidebar.bottomGroup.collapsed + property var tabs: [ + {"type": "calendar", "name": Translation.tr("Calendar"), "icon": "calendar_month", "widget": calendarWidget}, + {"type": "todo", "name": Translation.tr("To Do"), "icon": "done_outline", "widget": todoWidget} + ] + + 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 + onClicked: { + 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 + height: tabStack.height + 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 { + showToggledHighlight: false + toggled: root.selectedTab == index + buttonText: modelData.name + buttonIcon: modelData.icon + onClicked: { + root.selectedTab = index + Persistent.states.sidebar.bottomGroup.tab = index + } + } + } + } + // Collapse button + CalendarHeaderButton { + anchors.left: parent.left + anchors.top: parent.top + forceCircle: true + onClicked: { + root.setCollapsed(true) + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Content area + StackLayout { + id: tabStack + Layout.fillWidth: true + // Take the highest one, because the TODO list has no implicit height. This way the heigth of the calendar is used when it's initially loaded with the TODO list + height: Math.max(...tabStack.children.map(child => child.tabLoader?.implicitHeight || 0)) // TODO: make this less stupid + Layout.alignment: Qt.AlignVCenter + property int realIndex: root.selectedTab + property int animationDuration: Appearance.animation.elementMoveFast.duration * 1.5 + currentIndex: root.selectedTab + + // Switch the tab on halfway of the anim duration + Connections { + target: root + function onSelectedTabChanged() { + delayedStackSwitch.start() + tabStack.realIndex = root.selectedTab + } + } + Timer { + id: delayedStackSwitch + interval: tabStack.animationDuration / 2 + repeat: false + onTriggered: { + tabStack.currentIndex = root.selectedTab + } + } + + Repeater { + model: tabs + Item { // TODO: make behavior on y also act for the item that's switched to + id: tabItem + property int tabIndex: index + property string tabType: modelData.type + property int animDistance: 5 + property var tabLoader: tabLoader + // Opacity: show up only when being animated to + opacity: (tabStack.currentIndex === tabItem.tabIndex && tabStack.realIndex === tabItem.tabIndex) ? 1 : 0 + // Y: starts animating when user selects a different tab + y: (tabStack.realIndex === tabItem.tabIndex) ? 0 : (tabStack.realIndex < tabItem.tabIndex) ? animDistance : -animDistance + Behavior on opacity { NumberAnimation { duration: tabStack.animationDuration / 2; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: tabStack.animationDuration; easing.type: Easing.OutExpo } } + Loader { + id: tabLoader + anchors.fill: parent + sourceComponent: modelData.widget + focus: root.selectedTab === tabItem.tabIndex + } + } + } + } + } + + // Calendar component + Component { + id: calendarWidget + + CalendarWidget { + anchors.centerIn: parent + } + } + + // To Do component + Component { + id: todoWidget + TodoWidget { + anchors.fill: parent + anchors.margins: 5 + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/CenterWidgetGroup.qml b/configs/quickshell/ii/modules/sidebarRight/CenterWidgetGroup.qml new file mode 100644 index 0000000..4aeef71 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/CenterWidgetGroup.qml @@ -0,0 +1,80 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import "./notifications" +import "./volumeMixer" +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + property int selectedTab: 0 + property var tabButtonList: [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}] + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + } + event.accepted = true; + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length + } else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length + } + event.accepted = true; + } + } + + ColumnLayout { + anchors.margins: 5 + anchors.fill: parent + spacing: 0 + + PrimaryTabBar { + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + currentIndex: root.selectedTab + onCurrentIndexChanged: { + tabBar.enableIndicatorAnimation = true + root.selectedTab = currentIndex + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + NotificationList {} + VolumeMixer {} + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/SidebarRight.qml b/configs/quickshell/ii/modules/sidebarRight/SidebarRight.qml new file mode 100644 index 0000000..3d2c406 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/SidebarRight.qml @@ -0,0 +1,238 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./quickToggles/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property int sidebarWidth: Appearance.sizes.sidebarWidth + property int sidebarPadding: 12 + property string settingsQmlPath: Quickshell.shellPath("settings.qml") + + 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: Item { + 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 + + RowLayout { + Layout.fillHeight: false + spacing: 10 + Layout.margins: 10 + Layout.topMargin: 5 + Layout.bottomMargin: 0 + + CustomIcon { + id: distroIcon + width: 25 + height: 25 + source: SystemInfo.distroIcon + colorize: true + color: Appearance.colors.colOnLayer0 + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Up %1").arg(DateTime.uptime) + textFormat: Text.MarkdownText + } + + Item { + Layout.fillWidth: true + } + + ButtonGroup { + QuickToggleButton { + toggled: false + buttonIcon: "restart_alt" + onClicked: { + Hyprland.dispatch("reload") + Quickshell.reload(true) + } + StyledToolTip { + content: Translation.tr("Reload Hyprland & Quickshell") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "settings" + onClicked: { + GlobalStates.sidebarRightOpen = false + Quickshell.execDetached(["qs", "-p", root.settingsQmlPath]) + } + StyledToolTip { + content: Translation.tr("Settings") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "power_settings_new" + onClicked: { + GlobalStates.sessionOpen = true + } + StyledToolTip { + content: Translation.tr("Session") + } + } + } + } + + ButtonGroup { + Layout.alignment: Qt.AlignHCenter + spacing: 5 + padding: 5 + color: Appearance.colors.colLayer1 + + NetworkToggle {} + BluetoothToggle {} + NightLight {} + GameMode {} + IdleInhibitor {} + EasyEffectsToggle {} + CloudflareWarp {} + } + + // Center widget group + CenterWidgetGroup { + focus: sidebarRoot.visible + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + } + + BottomWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + } + } + } + } + + + } + + IpcHandler { + target: "sidebarRight" + + function toggle(): void { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + + function close(): void { + GlobalStates.sidebarRightOpen = false; + } + + function open(): void { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "sidebarRightToggle" + description: "Toggles right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightOpen" + description: "Opens right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightClose" + description: "Closes right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = false; + } + } + +} diff --git a/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarDayButton.qml b/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarDayButton.qml new file mode 100644 index 0000000..ab1aca5 --- /dev/null +++ b/configs/quickshell/ii/modules/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/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarHeaderButton.qml b/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarHeaderButton.qml new file mode 100644 index 0000000..6b5e5aa --- /dev/null +++ b/configs/quickshell/ii/modules/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 { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarWidget.qml b/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarWidget.qml new file mode 100644 index 0000000..3af804e --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/calendar/CalendarWidget.qml @@ -0,0 +1,123 @@ +import qs.modules.common +import qs +import qs.modules.common.widgets +import "./calendar_layout.js" as CalendarLayout +import QtQuick +import QtQuick.Controls +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") + onClicked: { + monthShift = 0; + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: false + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + monthShift--; + } + contentItem: MaterialSymbol { + text: "chevron_left" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + 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/configs/quickshell/ii/modules/sidebarRight/calendar/calendar_layout.js b/configs/quickshell/ii/modules/sidebarRight/calendar/calendar_layout.js new file mode 100644 index 0000000..7f750b4 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/calendar/calendar_layout.js @@ -0,0 +1,115 @@ +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/configs/quickshell/ii/modules/sidebarRight/notifications/NotificationList.qml b/configs/quickshell/ii/modules/sidebarRight/notifications/NotificationList.qml new file mode 100644 index 0000000..882829b --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/notifications/NotificationList.qml @@ -0,0 +1,118 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +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 + Item { + anchors.fill: listview + + visible: opacity > 0 + opacity: (Notifications.list.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "notifications_active" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("No notifications") + } + } + } + + Item { + id: statusRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + Layout.fillWidth: true + implicitHeight: Math.max( + controls.implicitHeight, + statusText.implicitHeight + ) + + StyledText { + id: statusText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("%1 notifications").arg(Notifications.list.length) + + opacity: Notifications.list.length > 0 ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + ButtonGroup { + id: controls + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 5 + + NotificationStatusButton { + buttonIcon: "notifications_paused" + buttonText: Translation.tr("Silent") + toggled: Notifications.silent + onClicked: () => { + Notifications.silent = !Notifications.silent; + } + } + NotificationStatusButton { + buttonIcon: "clear_all" + buttonText: Translation.tr("Clear") + onClicked: () => { + Notifications.discardAllNotifications() + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/notifications/NotificationStatusButton.qml b/configs/quickshell/ii/modules/sidebarRight/notifications/NotificationStatusButton.qml new file mode 100644 index 0000000..d6001a3 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/notifications/NotificationStatusButton.qml @@ -0,0 +1,43 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +GroupButton { + id: button + property string buttonText: "" + property string buttonIcon: "" + + baseWidth: content.implicitWidth + 10 * 2 + baseHeight: 30 + + 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 { + text: buttonIcon + iconSize: Appearance.font.pixelSize.large + color: button.colText + } + StyledText { + text: buttonText + font.pixelSize: Appearance.font.pixelSize.small + color: button.colText + } + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/qmldir b/configs/quickshell/ii/modules/sidebarRight/qmldir new file mode 100644 index 0000000..939dc4c --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/qmldir @@ -0,0 +1,6 @@ +module qs.modules.sidebarRight + +BottomWidgetGroup 1.0 BottomWidgetGroup.qml +CenterWidgetGroup 1.0 CenterWidgetGroup.qml +SidebarRight 1.0 SidebarRight.qml +TopWidgetGroup 1.0 TopWidgetGroup.qml diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/BluetoothToggle.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/BluetoothToggle.qml new file mode 100644 index 0000000..5122bf0 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/BluetoothToggle.qml @@ -0,0 +1,36 @@ +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.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Bluetooth.bluetoothEnabled + buttonIcon: Bluetooth.bluetoothConnected ? "bluetooth_connected" : Bluetooth.bluetoothEnabled ? "bluetooth" : "bluetooth_disabled" + onClicked: { + toggleBluetooth.running = true + } + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.bluetooth}`]) + GlobalStates.sidebarRightOpen = false + } + Process { + id: toggleBluetooth + command: ["bash", "-c", `bluetoothctl power ${Bluetooth.bluetoothEnabled ? "off" : "on"}`] + onRunningChanged: { + if(!running) { + Bluetooth.update() + } + } + } + StyledToolTip { + content: Translation.tr("%1 | Right-click to configure").arg( + (Bluetooth.bluetoothEnabled && Bluetooth.bluetoothDeviceName.length > 0) ? + Bluetooth.bluetoothDeviceName : Translation.tr("Bluetooth")) + + } +} diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/CloudflareWarp.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/CloudflareWarp.qml new file mode 100644 index 0000000..39416ab --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/CloudflareWarp.qml @@ -0,0 +1,92 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +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 { + content: Translation.tr("Cloudflare WARP (1.1.1.1)") + } +} diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml new file mode 100644 index 0000000..5fbef04 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml @@ -0,0 +1,49 @@ +import qs.modules.common.widgets +import qs +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +QuickToggleButton { + id: root + toggled: false + visible: false + buttonIcon: "instant_mix" + + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["pkill", "easyeffects"]) + } else { + root.toggled = true + Quickshell.execDetached(["easyeffects", "--gapplication-service"]) + } + } + + altAction: () => { + Quickshell.execDetached(["easyeffects"]) + GlobalStates.sidebarRightOpen = false + } + + Process { + id: fetchAvailability + running: true + command: ["bash", "-c", "command -v easyeffects"] + onExited: (exitCode, exitStatus) => { + root.visible = exitCode === 0 + } + } + + Process { + id: fetchActiveState + running: true + command: ["pidof", "easyeffects"] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode === 0 + } + } + + StyledToolTip { + content: Translation.tr("EasyEffects | Right-click to configure") + } +} diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/GameMode.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/GameMode.qml new file mode 100644 index 0000000..1907080 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/GameMode.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +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 { + content: Translation.tr("Game mode") + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/IdleInhibitor.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/IdleInhibitor.qml new file mode 100644 index 0000000..949842d --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/IdleInhibitor.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import Quickshell.Io +import Quickshell + +QuickToggleButton { + id: root + toggled: false + buttonIcon: "coffee" + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["pkill", "wayland-idle"]) // pkill doesn't accept too long names + } else { + root.toggled = true + Quickshell.execDetached([`${Directories.scriptPath}/wayland-idle-inhibitor.py`]) + } + } + Process { + id: fetchActiveState + running: true + command: ["pidof", "wayland-idle-inhibitor.py"] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode === 0 + } + } + StyledToolTip { + content: Translation.tr("Keep system awake") + } +} diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/NetworkToggle.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/NetworkToggle.qml new file mode 100644 index 0000000..7492eae --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/NetworkToggle.qml @@ -0,0 +1,34 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "../" +import qs +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Network.networkName.length > 0 && Network.networkName != "lo" + buttonIcon: Network.materialSymbol + onClicked: { + toggleNetwork.running = true + } + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Network.ethernet ? Config.options.apps.networkEthernet : Config.options.apps.network}`]) + GlobalStates.sidebarRightOpen = false + } + Process { + id: toggleNetwork + command: ["bash", "-c", "nmcli radio wifi | grep -q enabled && nmcli radio wifi off || nmcli radio wifi on"] + onRunningChanged: { + if(!running) { + Network.update() + } + } + } + StyledToolTip { + content: Translation.tr("%1 | Right-click to configure").arg(Network.networkName) + } +} diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/NightLight.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/NightLight.qml new file mode 100644 index 0000000..f026512 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/NightLight.qml @@ -0,0 +1,28 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.widgets +import qs +import qs.services +import Quickshell.Io + +QuickToggleButton { + id: nightLightButton + property bool enabled: Hyprsunset.active + toggled: enabled + 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 { + content: Translation.tr("Night Light | Right-click to toggle Auto mode") + } +} diff --git a/configs/quickshell/ii/modules/sidebarRight/quickToggles/QuickToggleButton.qml b/configs/quickshell/ii/modules/sidebarRight/quickToggles/QuickToggleButton.qml new file mode 100644 index 0000000..25a53de --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/quickToggles/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: 20 + 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/configs/quickshell/ii/modules/sidebarRight/todo/TaskList.qml b/configs/quickshell/ii/modules/sidebarRight/todo/TaskList.qml new file mode 100644 index 0000000..4f6a430 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/todo/TaskList.qml @@ -0,0 +1,180 @@ +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 + + StyledFlickable { + id: flickable + anchors.fill: parent + contentHeight: columnLayout.height + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: flickable.width + height: flickable.height + radius: Appearance.rounding.small + } + } + + ColumnLayout { + id: columnLayout + width: parent.width + spacing: 0 + Repeater { + model: ScriptModel { + values: taskList + } + delegate: Item { + id: todoItem + property bool pendingDoneToggle: false + property bool pendingDelete: false + property bool enableHeightAnimation: false + + Layout.fillWidth: true + implicitHeight: todoItemRectangle.implicitHeight + todoListItemSpacing + height: implicitHeight + 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 + } + } + + function startAction() { + enableHeightAnimation = true + todoItem.implicitHeight = 0 + actionTimer.start() + } + + Timer { + id: actionTimer + interval: Appearance.animation.elementMoveFast.duration + repeat: false + onTriggered: { + if (todoItem.pendingDelete) { + Todo.deleteItem(modelData.originalIndex) + } else if (todoItem.pendingDoneToggle) { + if (!modelData.done) Todo.markDone(modelData.originalIndex) + else Todo.markUnfinished(modelData.originalIndex) + } + } + } + + 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 { + Layout.fillWidth: true // Needed for wrapping + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: todoListItemPadding + id: todoContentText + text: modelData.content + wrapMode: Text.Wrap + } + RowLayout { + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: todoListItemPadding + Item { + Layout.fillWidth: true + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDoneToggle = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: modelData.done ? "remove_done" : "check" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDelete = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "delete_forever" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + + } + // Bottom padding + Item { + implicitHeight: listBottomPadding + } + } + } + + 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 + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/todo/TodoItemActionButton.qml b/configs/quickshell/ii/modules/sidebarRight/todo/TodoItemActionButton.qml new file mode 100644 index 0000000..b0a6e7b --- /dev/null +++ b/configs/quickshell/ii/modules/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 { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/todo/TodoWidget.qml b/configs/quickshell/ii/modules/sidebarRight/todo/TodoWidget.qml new file mode 100644 index 0000000..50c659a --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/todo/TodoWidget.qml @@ -0,0 +1,294 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property int currentTab: 0 + 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) { + currentTab = Math.min(currentTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + currentTab = Math.max(currentTab - 1, 0) + } + 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 + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: currentTab + onCurrentIndexChanged: currentTab = currentIndex + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + selected: (index == currentTab) + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + property bool enableIndicatorAnimation: false + Connections { + target: root + function onCurrentTabChanged() { + tabIndicator.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem.children[0].children[tabBar.currentIndex].tabContentWidth + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: Appearance.colors.colPrimary + radius: Appearance.rounding.full + + Behavior on x { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: currentTab + onCurrentIndexChanged: { + tabIndicator.enableIndicatorAnimation = true + currentTab = 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 + + contentItem: MaterialSymbol { + text: "add" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onPrimaryContainer + } + } + + 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 + root.currentTab = 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/configs/quickshell/ii/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml b/configs/quickshell/ii/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml new file mode 100644 index 0000000..a1e589d --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml @@ -0,0 +1,53 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire + +GroupButton { + id: button + required property bool input + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + clickedWidth: baseWidth + 30 + + 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 + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/volumeMixer/VolumeMixer.qml b/configs/quickshell/ii/modules/sidebarRight/volumeMixer/VolumeMixer.qml new file mode 100644 index 0000000..f9e0118 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/volumeMixer/VolumeMixer.qml @@ -0,0 +1,282 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + + +Item { + id: root + property bool showDeviceSelector: false + property bool deviceSelectorInput + property int dialogMargins: 16 + property PwNode selectedDevice + readonly property list appPwNodes: Pipewire.nodes.values.filter((node) => { + // return node.type == "21" // Alternative, not as clean + return node.isSink && node.isStream + }) + + function showDeviceSelectorDialog(input: bool) { + root.selectedDevice = null + root.showDeviceSelector = true + root.deviceSelectorInput = input + } + + Keys.onPressed: (event) => { + // Close dialog on pressing Esc if open + if (event.key === Qt.Key_Escape && root.showDeviceSelector) { + root.showDeviceSelector = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + Item { + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { + id: listView + model: root.appPwNodes + clip: true + anchors { + fill: parent + topMargin: 10 + bottomMargin: 10 + } + spacing: 6 + + delegate: VolumeMixerEntry { + // Layout.fillWidth: true + anchors { + left: parent.left + right: parent.right + leftMargin: 10 + rightMargin: 10 + } + required property var modelData + node: modelData + } + } + + // Placeholder when list is empty + Item { + anchors.fill: listView + + visible: opacity > 0 + opacity: (root.appPwNodes.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "brand_awareness" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("No audio source") + } + } + } + } + + // Separator + Rectangle { + color: Appearance.m3colors.m3outlineVariant + implicitHeight: 1 + Layout.fillWidth: true + } + + + // Device selector + ButtonGroup { + id: deviceSelectorRowLayout + Layout.fillWidth: true + Layout.fillHeight: false + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: false + onClicked: root.showDeviceSelectorDialog(input) + } + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: true + onClicked: root.showDeviceSelectorDialog(input) + } + } + } + + // Device selector dialog + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showDeviceSelector ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + 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.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 30 + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: root.deviceSelectorInput ? Translation.tr("Select input device") : Translation.tr("Select output device") + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + StyledFlickable { + id: dialogFlickable + Layout.fillWidth: true + clip: true + implicitHeight: Math.min(scrimOverlay.height - dialogMargins * 8 - dialogTitle.height - dialogButtonsRowLayout.height, devicesColumnLayout.implicitHeight) + + contentHeight: devicesColumnLayout.implicitHeight + + ColumnLayout { + id: devicesColumnLayout + anchors.fill: parent + Layout.fillWidth: true + spacing: 0 + + Repeater { + model: ScriptModel { + values: Pipewire.nodes.values.filter(node => { + return !node.isStream && node.isSink !== root.deviceSelectorInput && node.audio + }) + } + + // This could and should be refractored, but all data becomes null when passed wtf + delegate: StyledRadioButton { + id: radioButton + required property var modelData + Layout.leftMargin: root.dialogMargins + Layout.rightMargin: root.dialogMargins + Layout.fillWidth: true + + description: modelData.description + checked: modelData.id === Pipewire.defaultAudioSink?.id + + Connections { + target: root + function onShowDeviceSelectorChanged() { + if(!root.showDeviceSelector) return; + radioButton.checked = (modelData.id === Pipewire.defaultAudioSink?.id) + } + } + + onCheckedChanged: { + if (checked) { + root.selectedDevice = modelData + } + } + } + } + Item { + implicitHeight: dialogMargins + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: { + root.showDeviceSelector = false + } + } + DialogButton { + buttonText: Translation.tr("OK") + onClicked: { + root.showDeviceSelector = false + if (root.selectedDevice) { + if (root.deviceSelectorInput) { + Pipewire.preferredDefaultAudioSource = root.selectedDevice + } else { + Pipewire.preferredDefaultAudioSink = root.selectedDevice + } + } + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/ii/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml b/configs/quickshell/ii/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml new file mode 100644 index 0000000..5ee3980 --- /dev/null +++ b/configs/quickshell/ii/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml @@ -0,0 +1,63 @@ +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: [node] + } + + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 6 + + Image { + property real size: slider.height * 0.9 + 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 = root.node.properties["application.name"] ?? (root.node.description != "" ? root.node.description : root.node.name); + const media = root.node.properties["media.name"]; + return media != undefined ? `${app} โ€ข ${media}` : app; + } + } + + StyledSlider { + id: slider + value: root.node.audio.volume + onValueChanged: root.node.audio.volume = value + } + } + } +} diff --git a/configs/quickshell/ii/qmldir b/configs/quickshell/ii/qmldir new file mode 100644 index 0000000..c93f1b5 --- /dev/null +++ b/configs/quickshell/ii/qmldir @@ -0,0 +1 @@ +module qs diff --git a/configs/quickshell/ii/qs/modules/common/Appearance.qml b/configs/quickshell/ii/qs/modules/common/Appearance.qml new file mode 100644 index 0000000..9c93748 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/Appearance.qml @@ -0,0 +1,316 @@ +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 + + // Extremely conservative transparency values for consistency and readability + property real transparency: Config.options?.appearance.transparency ? (m3colors.darkmode ? 0.1 : 0.07) : 0 + property real contentTransparency: Config.options?.appearance.transparency ? (m3colors.darkmode ? 0.55 : 0.55) : 0 + + m3colors: QtObject { + property bool darkmode: false + property bool transparent: false + property color m3primary_paletteKeyColor: "#91689E" + property color m3secondary_paletteKeyColor: "#837186" + property color m3tertiary_paletteKeyColor: "#9D6A67" + property color m3neutral_paletteKeyColor: "#7C757B" + property color m3neutral_variant_paletteKeyColor: "#7D747D" + property color m3background: "#161217" + property color m3onBackground: "#EAE0E7" + property color m3surface: "#161217" + property color m3surfaceDim: "#161217" + property color m3surfaceBright: "#3D373D" + property color m3surfaceContainerLowest: "#110D12" + property color m3surfaceContainerLow: "#1F1A1F" + property color m3surfaceContainer: "#231E23" + property color m3surfaceContainerHigh: "#2D282E" + property color m3surfaceContainerHighest: "#383339" + property color m3onSurface: "#EAE0E7" + property color m3surfaceVariant: "#4C444D" + property color m3onSurfaceVariant: "#CFC3CD" + property color m3inverseSurface: "#EAE0E7" + property color m3inverseOnSurface: "#342F34" + property color m3outline: "#988E97" + property color m3outlineVariant: "#4C444D" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#E5B6F2" + property color m3primary: "#E5B6F2" + property color m3onPrimary: "#452152" + property color m3primaryContainer: "#5D386A" + property color m3onPrimaryContainer: "#F9D8FF" + property color m3inversePrimary: "#775084" + property color m3secondary: "#D5C0D7" + property color m3onSecondary: "#392C3D" + property color m3secondaryContainer: "#534457" + property color m3onSecondaryContainer: "#F2DCF3" + property color m3tertiary: "#F5B7B3" + property color m3onTertiary: "#4C2523" + property color m3tertiaryContainer: "#BA837F" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#FFB4AB" + property color m3onError: "#690005" + property color m3errorContainer: "#93000A" + property color m3onErrorContainer: "#FFDAD6" + property color m3primaryFixed: "#F9D8FF" + property color m3primaryFixedDim: "#E5B6F2" + property color m3onPrimaryFixed: "#2E0A3C" + property color m3onPrimaryFixedVariant: "#5D386A" + property color m3secondaryFixed: "#F2DCF3" + property color m3secondaryFixedDim: "#D5C0D7" + property color m3onSecondaryFixed: "#241727" + property color m3onSecondaryFixedVariant: "#514254" + property color m3tertiaryFixed: "#FFDAD7" + property color m3tertiaryFixedDim: "#F5B7B3" + property color m3onTertiaryFixed: "#331110" + property color m3onTertiaryFixedVariant: "#663B39" + 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.transparency), 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(ColorUtils.mix(m3colors.m3surfaceContainerLow, m3colors.m3background, 0.8), root.contentTransparency); + property color colOnLayer1: m3colors.m3onSurfaceVariant; + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45); + property color colLayer2: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainer, m3colors.m3surfaceContainerHigh, 0.1), root.contentTransparency) + property color colOnLayer2: m3colors.m3onSurface; + property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4); + property color colLayer3: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainerHigh, m3colors.m3onSurface, 0.96), root.contentTransparency) + property color colOnLayer3: m3colors.m3onSurface; + 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 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 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, colLayer1Hover, 0.7) + property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colLayer1Active, 0.6) + property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer + property color colSecondary: m3colors.m3secondary + 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, colLayer1Active, 0.54) + 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 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 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 colOutlineVariant: m3colors.m3outlineVariant + } + + 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: "Rubik" + property string title: "Gabarito" + property string iconMaterial: "Material Symbols Rounded" + property string iconNerd: "SpaceMono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property QtObject pixelSize: QtObject { + property int smallest: 10 + property int smaller: 12 + 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 Component colorAnimation: Component { + ColorAnimation { + 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 { + 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 { + 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 { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + } + + property QtObject clickBounce: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveFastSpatial + property int velocity: 850 + property Component numberAnimation: Component { NumberAnimation { + duration: root.animation.clickBounce.duration + easing.type: root.animation.clickBounce.type + easing.bezierCurve: root.animation.clickBounce.bezierCurve + }} + } + property QtObject scroll: QtObject { + property int duration: 400 + 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 + Appearance.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 sidebarWidth: 460 + property real sidebarWidthExtended: 750 + property real osdWidth: 200 + property real mediaControlsWidth: 440 + property real mediaControlsHeight: 160 + property real notificationPopupWidth: 410 + property real searchWidthCollapsed: 260 + property real searchWidth: 450 + property real hyprlandGapsOut: 5 + property real elevationMargin: 10 + property real fabShadowRadius: 5 + property real fabHoveredShadowRadius: 7 + } + + syntaxHighlightingTheme: Appearance.m3colors.darkmode ? "Monokai" : "ayu Light" +} diff --git a/configs/quickshell/ii/qs/modules/common/Config.qml b/configs/quickshell/ii/qs/modules/common/Config.qml new file mode 100644 index 0000000..bcbbd1e --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/Config.qml @@ -0,0 +1,272 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: 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; + } + + FileView { + path: root.filePath + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + property JsonObject policies: JsonObject { + property int ai: 1 // 0: No | 1: Yes | 2: Local + property int weeb: 1 // 0: No | 1: Open | 2: Closet + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## ๐Ÿง Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n" + property string tool: "functions" // search, functions, or none + property list extraModels: [ + { + "api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models + "description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory + "icon": "spark-symbolic", // Not mandatory + "key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory + "key_id": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b:free", + "name": "Custom: DS R1 Dstl. LLaMA 70B", + "requires_key": true + } + ] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property bool transparency: false + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + } + 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: true + property real maxAllowedIncrease: 10 + property real maxAllowed: 90 // Realistically should already provide some protection when it's 99... + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6 kcm_bluetooth" + property string network: "plasmawindowed org.kde.plasma.networkmanagement" + 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 JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property string wallpaperPath: "" + property string thumbnailPath: "" + property JsonObject parallax: JsonObject { + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + } + } + + property JsonObject bar: JsonObject { + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: distro, spark + property bool showBackground: true + property bool verbose: true + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: false + } + 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 JsonObject tray: JsonObject { + property bool monochromeIcons: true + } + 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 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 battery: JsonObject { + property int low: 20 + property int critical: 5 + property bool automaticSuspend: true + property int suspend: 3 + } + + 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 language: JsonObject { + 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 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 osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + 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 JsonObject resources: JsonObject { + property int updateInterval: 3000 + } + + 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"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property string action: "/" + property string clipboard: ";" + property string emojis: ":" + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string dateFormat: "ddd, dd/MM" + } + + 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 screenshotTool: JsonObject { + property bool showContentRegions: true + } + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/Directories.qml b/configs/quickshell/ii/qs/modules/common/Directories.qml new file mode 100644 index 0000000..a1748ec --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/Directories.qml @@ -0,0 +1,49 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import Qt.labs.platform +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + 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 pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[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 booruPreviews: FileUtils.trimFileProtocol(`${Directories.cache}/media/boorus`) + property string booruDownloads: FileUtils.trimFileProtocol(Directories.pictures + "/homework") + property string booruDownloadsNsfw: FileUtils.trimFileProtocol(Directories.pictures + "/homework/๐ŸŒถ๏ธ") + 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 notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) + property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) + 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 defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts") + property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`) + property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`) + // 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 '${booruPreviews}'; mkdir -p '${booruPreviews}'`]) + Quickshell.execDetached(["bash", "-c", `mkdir -p '${booruDownloads}' && mkdir -p '${booruDownloadsNsfw}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${latexOutput}'; mkdir -p '${latexOutput}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${cliphistDecode}'; mkdir -p '${cliphistDecode}'`]) + Quickshell.execDetached(["mkdir", "-p", `${aiChats}`]) + } +} diff --git a/configs/quickshell/ii/qs/modules/common/Persistent.qml b/configs/quickshell/ii/qs/modules/common/Persistent.qml new file mode 100644 index 0000000..abd062d --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/Persistent.qml @@ -0,0 +1,49 @@ +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}` + + FileView { + path: root.filePath + + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: { + writeAdapter() + } + onLoadFailed: error => { + console.log("Failed to load persistent states file:", error); + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + adapter: JsonAdapter { + id: persistentStatesJsonAdapter + 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 booru: JsonObject { + property bool allowNsfw: false + property string provider: "yandere" + } + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/functions/ColorUtils.qml b/configs/quickshell/ii/qs/modules/common/functions/ColorUtils.qml new file mode 100644 index 0000000..27d4818 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/functions/ColorUtils.qml @@ -0,0 +1,114 @@ +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)); + } +} diff --git a/configs/quickshell/ii/qs/modules/common/functions/FileUtils.qml b/configs/quickshell/ii/qs/modules/common/functions/FileUtils.qml new file mode 100644 index 0000000..c051674 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/functions/FileUtils.qml @@ -0,0 +1,41 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } + + /** + * 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(); + } + + /** + * 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; + } +} diff --git a/configs/quickshell/ii/qs/modules/common/functions/Fuzzy.qml b/configs/quickshell/ii/qs/modules/common/functions/Fuzzy.qml new file mode 100644 index 0000000..7a132ad --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/functions/Levendist.qml b/configs/quickshell/ii/qs/modules/common/functions/Levendist.qml new file mode 100644 index 0000000..a327c3c --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/functions/ObjectUtils.qml b/configs/quickshell/ii/qs/modules/common/functions/ObjectUtils.qml new file mode 100644 index 0000000..d1204cd --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/functions/StringUtils.qml b/configs/quickshell/ii/qs/modules/common/functions/StringUtils.qml new file mode 100644 index 0000000..e824183 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/functions/StringUtils.qml @@ -0,0 +1,221 @@ +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 + */ + 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) { + // escape single quotes + return String(str) + // .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); + } + + /** + * Splits markdown blocks into three different types: text, think, and code. + * @param { string } markdown + */ + 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"); + } + + 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(); + } + + 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')}`; + } + } + + function escapeHtml(str) { + if (typeof str !== 'string') + return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } +} diff --git a/configs/quickshell/ii/qs/modules/common/functions/fuzzysort.js b/configs/quickshell/ii/qs/modules/common/functions/fuzzysort.js new file mode 100644 index 0000000..1c1d9b9 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/functions/levendist.js b/configs/quickshell/ii/qs/modules/common/functions/levendist.js new file mode 100644 index 0000000..90180d2 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/functions/qmldir b/configs/quickshell/ii/qs/modules/common/functions/qmldir new file mode 100644 index 0000000..18ce7c0 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/functions/qmldir @@ -0,0 +1,8 @@ +module qs.modules.common.functions + +ColorUtils 1.0 ColorUtils.qml +FileUtils 1.0 FileUtils.qml +Fuzzy 1.0 Fuzzy.qml +Levendist 1.0 Levendist.qml +ObjectUtils 1.0 ObjectUtils.qml +StringUtils 1.0 StringUtils.qml diff --git a/configs/quickshell/ii/qs/modules/common/qmldir b/configs/quickshell/ii/qs/modules/common/qmldir new file mode 100644 index 0000000..adbdc38 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/qmldir @@ -0,0 +1,6 @@ +module qs.modules.common + +singleton Appearance 1.0 Appearance.qml +singleton Config 1.0 Config.qml +singleton Directories 1.0 Directories.qml +singleton Persistent 1.0 Persistent.qml diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ButtonGroup.qml b/configs/quickshell/ii/qs/modules/common/widgets/ButtonGroup.qml new file mode 100644 index 0000000..7dc7a59 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/ButtonGroup.qml @@ -0,0 +1,46 @@ +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 real spacing: 5 + property real padding: 0 + property int clickIndex: rowLayout.clickIndex + + 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 + }] +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/CircularProgress.qml b/configs/quickshell/ii/qs/modules/common/widgets/CircularProgress.qml new file mode 100644 index 0000000..7ff2724 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/CircularProgress.qml @@ -0,0 +1,97 @@ +// From https://github.com/rafzby/circular-progressbar with modifications +// License: LGPL-3.0 - A copy can be found in `licenses` folder of repo + +import QtQuick +import qs.modules.common + +/** + * Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs + */ +Item { + id: root + + property int size: 30 + property int lineWidth: 2 + property real value: 0 + property color primaryColor: Appearance.m3colors.m3onSecondaryContainer + property color secondaryColor: Appearance.colors.colSecondaryContainer + property real gapAngle: Math.PI / 9 + property bool fill: false + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 1000 + property var easingType: Easing.OutCubic + + width: size + height: size + + signal animationFinished(); + + onValueChanged: { + canvas.degree = value * 360; + } + onPrimaryColorChanged: { + canvas.requestPaint(); + } + onSecondaryColorChanged: { + canvas.requestPaint(); + } + + Canvas { + id: canvas + + property real degree: 0 + + anchors.fill: parent + antialiasing: true + + onDegreeChanged: { + requestPaint(); + } + + onPaint: { + var ctx = getContext("2d"); + var x = root.width / 2; + var y = root.height / 2; + var radius = root.size / 2 - root.lineWidth; + var startAngle = (Math.PI / 180) * 270; + var fullAngle = (Math.PI / 180) * (270 + 360); + var progressAngle = (Math.PI / 180) * (270 + degree); + var epsilon = 0.01; // Small angle in radians + + ctx.reset(); + if (root.fill) { + ctx.fillStyle = root.secondaryColor; + ctx.beginPath(); + ctx.arc(x, y, radius + fillOverflow, startAngle, fullAngle); + ctx.fill(); + } + ctx.lineCap = 'round'; + ctx.lineWidth = root.lineWidth; + + // Secondary + ctx.beginPath(); + ctx.arc(x, y, radius, progressAngle + gapAngle, fullAngle - gapAngle); + ctx.strokeStyle = root.secondaryColor; + ctx.stroke(); + + // Primary (value indication) + var endAngle = progressAngle + (value > 0 ? 0 : epsilon); + ctx.beginPath(); + ctx.arc(x, y, radius, startAngle, endAngle); + ctx.strokeStyle = root.primaryColor; + ctx.stroke(); + } + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + } + +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/CliphistImage.qml b/configs/quickshell/ii/qs/modules/common/widgets/CliphistImage.qml new file mode 100644 index 0000000..ce15ef3 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/CliphistImage.qml @@ -0,0 +1,96 @@ +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 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 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}'`]) + } + + Image { + 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 + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: image.width + height: image.height + radius: root.radius + } + } + } +} + diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ConfigRow.qml b/configs/quickshell/ii/qs/modules/common/widgets/ConfigRow.qml new file mode 100644 index 0000000..3cdc3f8 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/ConfigRow.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts + +RowLayout { + property bool uniform: false + spacing: 10 + uniformCellSizes: uniform +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ConfigSelectionArray.qml b/configs/quickshell/ii/qs/modules/common/widgets/ConfigSelectionArray.qml new file mode 100644 index 0000000..318ffe1 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/ConfigSelectionArray.qml @@ -0,0 +1,43 @@ +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: [] + property string configOptionName: "" + 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 + buttonText: modelData.displayName; + toggled: root.currentValue === modelData.value + onClicked: { + root.selected(modelData.value); + } + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ConfigSpinBox.qml b/configs/quickshell/ii/qs/modules/common/widgets/ConfigSpinBox.qml new file mode 100644 index 0000000..375f78e --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/ConfigSpinBox.qml @@ -0,0 +1,30 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + property string text: "" + 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 + + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + + StyledSpinBox { + id: spinBoxWidget + Layout.fillWidth: false + value: root.value + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ConfigSwitch.qml b/configs/quickshell/ii/qs/modules/common/widgets/ConfigSwitch.qml new file mode 100644 index 0000000..e10f74d --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/ConfigSwitch.qml @@ -0,0 +1,32 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +RippleButton { + id: root + Layout.fillWidth: true + implicitHeight: contentItem.implicitHeight + 8 * 2 + onClicked: checked = !checked + + contentItem: RowLayout { + spacing: 10 + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + StyledSwitch { + id: switchWidget + down: root.down + scale: 0.6 + Layout.fillWidth: false + checked: root.checked + onClicked: root.clicked() + } + } +} + diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ContentPage.qml b/configs/quickshell/ii/qs/modules/common/widgets/ContentPage.qml new file mode 100644 index 0000000..5b110f8 --- /dev/null +++ b/configs/quickshell/ii/qs/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: 550 + 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: 10 + } + spacing: 20 + } + +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ContentSection.qml b/configs/quickshell/ii/qs/modules/common/widgets/ContentSection.qml new file mode 100644 index 0000000..2f038e1 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/ContentSection.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title + default property alias data: sectionContent.data + + Layout.fillWidth: true + spacing: 8 + StyledText { + text: root.title + font.pixelSize: Appearance.font.pixelSize.larger + font.weight: Font.Medium + } + ColumnLayout { + id: sectionContent + spacing: 8 + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ContentSubsection.qml b/configs/quickshell/ii/qs/modules/common/widgets/ContentSubsection.qml new file mode 100644 index 0000000..b78f3aa --- /dev/null +++ b/configs/quickshell/ii/qs/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 + content: root.tooltip + } + } + } + Item { Layout.fillWidth: true } + } + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 2 + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/ContentSubsectionLabel.qml b/configs/quickshell/ii/qs/modules/common/widgets/ContentSubsectionLabel.qml new file mode 100644 index 0000000..5d29e0e --- /dev/null +++ b/configs/quickshell/ii/qs/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: 4 +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/CustomIcon.qml b/configs/quickshell/ii/qs/modules/common/widgets/CustomIcon.qml new file mode 100644 index 0000000..d7a1c63 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/DialogButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/DialogButton.qml new file mode 100644 index 0000000..972c29b --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/DialogButton.qml @@ -0,0 +1,33 @@ +import qs.modules.common +import QtQuick + +/** + * Material 3 dialog button. See https://m3.material.io/components/dialogs/overview + */ +RippleButton { + id: button + + property string buttonText + implicitHeight: 30 + implicitWidth: buttonTextWidget.implicitWidth + 15 * 2 + buttonRadius: Appearance?.rounding.full ?? 9999 + + property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F" + property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96" + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small ?? 12 + color: button.enabled ? button.colEnabled : button.colDisabled + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/DragManager.qml b/configs/quickshell/ii/qs/modules/common/widgets/DragManager.qml new file mode 100644 index 0000000..9a430d9 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/Favicon.qml b/configs/quickshell/ii/qs/modules/common/widgets/Favicon.qml new file mode 100644 index 0000000..04e9285 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/FloatingActionButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/FloatingActionButton.qml new file mode 100644 index 0000000..14702aa --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/FloatingActionButton.qml @@ -0,0 +1,59 @@ +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: Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize) + implicitHeight: baseSize + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colPrimaryContainer + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + contentItem: RowLayout { + 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 + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + iconSize: 24 + color: Appearance.colors.colOnPrimaryContainer + text: root.iconText + } + Loader { + 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 + } + text: root.buttonText + color: Appearance.colors.colOnPrimaryContainer + font.pixelSize: 14 + font.weight: 450 + } + } + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/FlowButtonGroup.qml b/configs/quickshell/ii/qs/modules/common/widgets/FlowButtonGroup.qml new file mode 100644 index 0000000..ec9526e --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/GroupButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/GroupButton.qml new file mode 100644 index 0000000..4a524e1 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/GroupButton.qml @@ -0,0 +1,130 @@ +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 real clickedWidth: baseWidth + 20 + property real clickedHeight: baseHeight + property var parentGroup: root.parent + property int clickIndex: parentGroup?.clickIndex ?? -1 + + Layout.fillWidth: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + Layout.fillHeight: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth + implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 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 { + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on implicitHeight { + 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) + } + + MouseArea { + 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; + }; + } + + + 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) + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/KeyboardKey.qml b/configs/quickshell/ii/qs/modules/common/widgets/KeyboardKey.qml new file mode 100644 index 0000000..14c75c6 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/KeyboardKey.qml @@ -0,0 +1,42 @@ +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 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: Appearance.font.pixelSize.smaller + text: key + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/LightDarkPreferenceButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/LightDarkPreferenceButton.qml new file mode 100644 index 0000000..63dbd2c --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/LightDarkPreferenceButton.qml @@ -0,0 +1,122 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell + +GroupButton { + 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 + sperm: true + animateSperm: 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/configs/quickshell/ii/qs/modules/common/widgets/MaterialSymbol.qml b/configs/quickshell/ii/qs/modules/common/widgets/MaterialSymbol.qml new file mode 100644 index 0000000..92da991 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/MaterialSymbol.qml @@ -0,0 +1,32 @@ +import qs.modules.common +import QtQuick + +Text { + id: root + property real iconSize: Appearance?.font.pixelSize.small ?? 16 + property real fill: 0 + property real truncatedFill: Math.round(fill * 100) / 100 // Reduce memory consumption spikes from constant font remapping + renderType: Text.NativeRendering + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded" + pixelSize: iconSize + weight: Font.Normal + (Font.DemiBold - Font.Normal) * fill + variableAxes: { + "FILL": truncatedFill, + // "wght": font.weight, + // "GRAD": 0, + "opsz": iconSize, + } + } + verticalAlignment: Text.AlignVCenter + color: Appearance.m3colors.m3onBackground + + // 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/configs/quickshell/ii/qs/modules/common/widgets/MaterialTextField.qml b/configs/quickshell/ii/qs/modules/common/widgets/MaterialTextField.qml new file mode 100644 index 0000000..241cc90 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/MaterialTextField.qml @@ -0,0 +1,52 @@ +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 ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + wrapMode: TextEdit.Wrap +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/MenuButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/MenuButton.qml new file mode 100644 index 0000000..9185bc9 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/NavigationRail.qml b/configs/quickshell/ii/qs/modules/common/widgets/NavigationRail.qml new file mode 100644 index 0000000..11082a7 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailButton.qml new file mode 100644 index 0000000..0b83b45 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailButton.qml @@ -0,0 +1,148 @@ +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 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 + 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/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailExpandButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailExpandButton.qml new file mode 100644 index 0000000..57e15f0 --- /dev/null +++ b/configs/quickshell/ii/qs/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 + onClicked: { + 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/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailTabArray.qml b/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailTabArray.qml new file mode 100644 index 0000000..6596141 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NavigationRailTabArray.qml @@ -0,0 +1,41 @@ +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 + property real baseHighlightHeight: tabBarColumn.children[0].baseHighlightHeight + 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 + + 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/configs/quickshell/ii/qs/modules/common/widgets/NotificationActionButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/NotificationActionButton.qml new file mode 100644 index 0000000..2a73725 --- /dev/null +++ b/configs/quickshell/ii/qs/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: 30 + leftPadding: 15 + rightPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSurfaceContainerHighestHover + colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colSurfaceContainerHighestActive + + 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/configs/quickshell/ii/qs/modules/common/widgets/NotificationAppIcon.qml b/configs/quickshell/ii/qs/modules/common/widgets/NotificationAppIcon.qml new file mode 100644 index 0000000..5158d64 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NotificationAppIcon.qml @@ -0,0 +1,103 @@ +import qs.modules.common +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications + +Rectangle { // App icon + id: root + property var appIcon: "" + property var summary: "" + property var urgency: NotificationUrgency.Normal + property var image: "" + property real scale: 1 + property real size: 45 * scale + property real materialIconScale: 0.57 + property real appIconScale: 0.7 + property real smallAppIconScale: 0.49 + property real materialIconSize: size * materialIconScale + property real appIconSize: size * appIconScale + property real smallAppIconSize: size * smallAppIconScale + + implicitWidth: size + implicitHeight: size + radius: Appearance.rounding.full + color: 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) ? + "release_alert" : guessedIcon + } + anchors.fill: parent + color: (root.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) : + Appearance.m3colors.m3onSecondaryContainer + 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/configs/quickshell/ii/qs/modules/common/widgets/NotificationGroup.qml b/configs/quickshell/ii/qs/modules/common/widgets/NotificationGroup.qml new file mode 100644 index 0000000..d63bbb3 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NotificationGroup.qml @@ -0,0 +1,236 @@ +import qs.modules.common +import qs.services +import qs.modules.common.functions +import "./notification_utils.js" as NotificationUtils +import QtQuick +import QtQuick.Layouts +import Quickshell + +/** + * A group of notifications from the same app. + * Similar to Android's notifications + */ +Item { // 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 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + function destroyWithAnimation() { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.running = true; + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: root.width + root.dismissOvershoot + 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 + + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + root.toggleExpanded(); + else 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 (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + StyledRectangularShadow { + target: background + visible: popup + } + Rectangle { // Background of the notification + id: background + anchors.left: parent.left + width: parent.width + color: Appearance.colors.colSurfaceContainer + 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: 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: notificationGroup?.appIcon + summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary + } + + 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() } + } + } + + 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/configs/quickshell/ii/qs/modules/common/widgets/NotificationGroupExpandButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/NotificationGroupExpandButton.qml new file mode 100644 index 0000000..ba2b7e1 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NotificationGroupExpandButton.qml @@ -0,0 +1,48 @@ +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/configs/quickshell/ii/qs/modules/common/widgets/NotificationItem.qml b/configs/quickshell/ii/qs/modules/common/widgets/NotificationItem.qml new file mode 100644 index 0000000..d5e9c4f --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NotificationItem.qml @@ -0,0 +1,313 @@ +import qs +import qs.modules.common +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +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 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 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, 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 (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + 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.colSurfaceContainerHigh) : + ColorUtils.transparentize(Appearance.colors.colSurfaceContainerHighest) + + 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 + // Layout.fillWidth: true + StyledText { + id: summaryText + visible: !root.onlyNotification + font.pixelSize: root.fontSize + color: Appearance.colors.colOnLayer2 + 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 {} + } + + StyledFlickable { // Notification actions + id: actionsFlickable + Layout.fillWidth: true + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + clip: !onlyNotification + + 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.large + 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.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + } + } + } + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/NotificationListView.qml b/configs/quickshell/ii/qs/modules/common/widgets/NotificationListView.qml new file mode 100644 index 0000000..389a5a8 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,27 @@ +import qs +import qs.modules.common +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 + anchors.left: parent?.left + anchors.right: parent?.right + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/qs/modules/common/widgets/PointingHandInteraction.qml b/configs/quickshell/ii/qs/modules/common/widgets/PointingHandInteraction.qml new file mode 100644 index 0000000..cf8b065 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/PointingHandLinkHover.qml b/configs/quickshell/ii/qs/modules/common/widgets/PointingHandLinkHover.qml new file mode 100644 index 0000000..4d14c81 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/PrimaryTabBar.qml b/configs/quickshell/ii/qs/modules/common/widgets/PrimaryTabBar.qml new file mode 100644 index 0000000..63f5e17 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/PrimaryTabBar.qml @@ -0,0 +1,97 @@ +import qs.modules.common +import qs +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + spacing: 0 + required property var tabButtonList // Something like [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}] + required property var externalTrackedTab + property bool enableIndicatorAnimation: false + property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F" + property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0" + signal currentIndexChanged(int index) + + property bool centerTabBar: parent.width > 500 + Layout.fillWidth: !centerTabBar + Layout.alignment: Qt.AlignHCenter + implicitWidth: Math.max(tabBar.implicitWidth, 600) + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: root.externalTrackedTab + onCurrentIndexChanged: { + root.onCurrentIndexChanged(currentIndex) + } + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: PrimaryTabButton { + selected: (index == root.externalTrackedTab) + buttonText: modelData.name + buttonIcon: modelData.icon + minimumWidth: 160 + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + Connections { + target: root + function onExternalTrackedTabChanged() { + root.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem?.children[0]?.children[tabBar.currentIndex]?.tabContentWidth ?? 0 + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: root.colIndicator + radius: Appearance?.rounding.full ?? 9999 + + Behavior on x { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + implicitHeight: 1 + color: root.colBorder + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/PrimaryTabButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/PrimaryTabButton.qml new file mode 100644 index 0000000..0b4b6f8 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/PrimaryTabButton.qml @@ -0,0 +1,171 @@ +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 + +TabButton { + id: button + property string buttonText + property string buttonIcon + property real minimumWidth: 110 + property bool selected: false + property int tabContentWidth: contentItem.children[0].implicitWidth + property int rippleDuration: 1200 + height: buttonBackground.height + implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth) + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colActive: Appearance?.colors.colPrimary ?? "#65558F" + property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F" + + 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) => { + 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) => { + button.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + 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: Appearance?.rounding.small + implicitHeight: 50 + color: (button.hovered ? button.colBackgroundHover : button.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: button.colRipple } + GradientStop { position: 0.3; color: button.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + visible: buttonIcon?.length > 0 + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: buttonIcon + iconSize: Appearance?.font.pixelSize.hugeass ?? 25 + fill: selected ? 1 : 0 + color: selected ? button.colActive : button.colInactive + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + StyledText { + id: buttonTextWidget + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small + color: selected ? button.colActive : button.colInactive + text: buttonText + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/qs/modules/common/widgets/Revealer.qml b/configs/quickshell/ii/qs/modules/common/widgets/Revealer.qml new file mode 100644 index 0000000..bbbe2ef --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/Revealer.qml @@ -0,0 +1,25 @@ +import qs.modules.common +import QtQuick + +/** + * Recreation of GTK revealer. Expects one single child. + */ +Item { + id: root + property bool reveal + property bool vertical: false + clip: true + + implicitWidth: (reveal || vertical) ? childrenRect.width : 0 + implicitHeight: (reveal || !vertical) ? childrenRect.height : 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/configs/quickshell/ii/qs/modules/common/widgets/RippleButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/RippleButton.qml new file mode 100644 index 0000000..7487203 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/RippleButton.qml @@ -0,0 +1,183 @@ +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 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" + + property color buttonColor: root.enabled ? (root.toggled ? + (root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.hovered ? colBackgroundHover : + colBackground)) : colBackground + 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: 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(); + 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 + 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: 50 + + 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/configs/quickshell/ii/qs/modules/common/widgets/RippleButtonWithIcon.qml b/configs/quickshell/ii/qs/modules/common/widgets/RippleButtonWithIcon.qml new file mode 100644 index 0000000..f84ae4d --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/RippleButtonWithIcon.qml @@ -0,0 +1,55 @@ +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 { + text: buttonWithIconRoot.mainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + } + implicitHeight: 35 + horizontalPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + + contentItem: RowLayout { + Item { + 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 { + sourceComponent: buttonWithIconRoot.mainContentComponent + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/RoundCorner.qml b/configs/quickshell/ii/qs/modules/common/widgets/RoundCorner.qml new file mode 100644 index 0000000..6fba4b9 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/RoundCorner.qml @@ -0,0 +1,61 @@ +import QtQuick 2.9 + +Item { + id: root + + enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight } + property var corner: RoundCorner.CornerEnum.TopLeft // Default to TopLeft + + property int size: 25 + property color color: "#000000" + + onColorChanged: { + canvas.requestPaint(); + } + onCornerChanged: { + canvas.requestPaint(); + } + + implicitWidth: size + implicitHeight: size + + Canvas { + id: canvas + + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d"); + var r = root.size; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.beginPath(); + switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: + ctx.arc(r, r, r, Math.PI, 3 * Math.PI / 2); + ctx.lineTo(0, 0); + break; + case RoundCorner.CornerEnum.TopRight: + ctx.arc(0, r, r, 3 * Math.PI / 2, 2 * Math.PI); + ctx.lineTo(r, 0); + break; + case RoundCorner.CornerEnum.BottomLeft: + ctx.arc(r, 0, r, Math.PI / 2, Math.PI); + ctx.lineTo(0, r); + break; + case RoundCorner.CornerEnum.BottomRight: + ctx.arc(0, 0, r, 0, Math.PI / 2); + ctx.lineTo(r, r); + break; + } + ctx.closePath(); + ctx.fillStyle = root.color; + ctx.fill(); + } + } + + Behavior on size { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/SecondaryTabButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/SecondaryTabButton.qml new file mode 100644 index 0000000..983dd02 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/SecondaryTabButton.qml @@ -0,0 +1,161 @@ +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 + +TabButton { + id: root + property string buttonText + property string buttonIcon + property bool selected: false + property int rippleDuration: 1200 + height: buttonBackground.height + property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2 + + property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + property color colBackgroundHover: Appearance.colors.colLayer1Hover + property color colRipple: Appearance.colors.colLayer1Active + + 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) => { + 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) => { + root.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + 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: Appearance?.rounding.small ?? 7 + implicitHeight: 37 + 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) + } + + Rectangle { + id: ripple + + radius: Appearance.rounding.full + color: root.colRipple + opacity: 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: selected ? 1 : 0 + color: selected ? 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: selected ? 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/configs/quickshell/ii/qs/modules/common/widgets/SelectionDialog.qml b/configs/quickshell/ii/qs/modules/common/widgets/SelectionDialog.qml new file mode 100644 index 0000000..72da7ec --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/SelectionDialog.qml @@ -0,0 +1,132 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +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 + } + + ListView { + id: choiceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1 + spacing: 6 + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + 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/configs/quickshell/ii/qs/modules/common/widgets/SelectionGroupButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/SelectionGroupButton.qml new file mode 100644 index 0000000..6a225eb --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/SelectionGroupButton.qml @@ -0,0 +1,24 @@ +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 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 + contentItem: StyledText { + color: parent.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.buttonText + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledFlickable.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledFlickable.qml new file mode 100644 index 0000000..14b3af0 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledFlickable.qml @@ -0,0 +1,6 @@ +import QtQuick + +Flickable { + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledLabel.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledLabel.qml new file mode 100644 index 0000000..35b3cbf --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledLabel.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +Label { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledListView.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledListView.qml new file mode 100644 index 0000000..7021f24 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledListView.qml @@ -0,0 +1,108 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick + +/** + * 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 + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + add: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + from: 0, + to: 1, + }), + ] + } + + addDisplaced: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + to: 1, + }), + ] + } + + // displaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + // move: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + // moveDisplaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + remove: Transition { + animations: [ + 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: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledProgressBar.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledProgressBar.qml new file mode 100644 index 0000000..fa1cd0b --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledProgressBar.qml @@ -0,0 +1,103 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +/** + * 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 sperm: false // If true, the progress bar will have a wavy fill effect + property bool animateSperm: true + property real spermAmplitudeMultiplier: sperm ? 0.5 : 0 + property real spermFrequency: 6 + property real spermFps: 60 + + Behavior on spermAmplitudeMultiplier { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Behavior on value { + animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + background: Item { + anchors.fill: parent + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Item { + anchors.fill: parent + + Canvas { + id: wavyFill + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + height: parent.height * 6 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var progress = root.visualPosition; + var fillWidth = progress * width; + var amplitude = parent.height * root.spermAmplitudeMultiplier; + var frequency = root.spermFrequency; + var phase = Date.now() / 400.0; + var centerY = height / 2; + + ctx.strokeStyle = root.highlightColor; + ctx.lineWidth = parent.height; + ctx.lineCap = "round"; + ctx.beginPath(); + for (var x = ctx.lineWidth / 2; x <= fillWidth; x += 1) { + var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / width + phase); + if (x === 0) + ctx.moveTo(x, waveY); + else + ctx.lineTo(x, waveY); + } + ctx.stroke(); + } + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + Timer { + interval: 1000 / root.spermFps + running: root.animateSperm + repeat: root.sperm + onTriggered: wavyFill.requestPaint() + } + } + Rectangle { // Right remaining part fill + anchors.right: parent.right + width: (1 - root.visualPosition) * parent.width - valueBarGap + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.trackColor + } + Rectangle { // Stop point + anchors.right: parent.right + width: valueBarGap + height: valueBarGap + radius: Appearance?.rounding.full ?? 9999 + color: root.highlightColor + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledRadioButton.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledRadioButton.qml new file mode 100644 index 0000000..a6a63b7 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledRadioButton.qml @@ -0,0 +1,87 @@ +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 + implicitHeight: contentItem.implicitHeight + 4 * 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/configs/quickshell/ii/qs/modules/common/widgets/StyledRectangularShadow.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledRectangularShadow.qml new file mode 100644 index 0000000..a3c842c --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/StyledSlider.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledSlider.qml new file mode 100644 index 0000000..e940f1a --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledSlider.qml @@ -0,0 +1,155 @@ +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 { + 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.m3colors.m3onSecondaryContainer + 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 + : 6 + property real handleHeight: Math.max(33, trackWidth + 9) + property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth + property real handleMargins: 4 + onHandleMarginsChanged: { + console.log("Handle margins changed to", handleMargins); + } + property real trackDotSize: 3 + property string tooltipContent: `${Math.round(value * 100)}%` + + leftPadding: handleMargins + rightPadding: handleMargins + property real effectiveDraggingWidth: width - leftPadding - rightPadding + + Layout.fillWidth: true + from: 0 + to: 1 + + Behavior on value { // This makes the adjusted value (like volume) shift smoothly + SmoothedAnimation { + velocity: Appearance.animation.elementMoveFast.velocity + } + } + + Behavior on handleMargins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + component TrackDot: Rectangle { + required property real value + anchors.verticalCenter: parent.verticalCenter + x: root.handleMargins + (value * root.effectiveDraggingWidth) - (root.trackDotSize / 2) + width: root.trackDotSize + height: root.trackDotSize + radius: Appearance.rounding.full + color: value > 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 + Rectangle { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: trackWidth + color: root.highlightColor + topLeftRadius: root.trackRadius + bottomLeftRadius: root.trackRadius + topRightRadius: root.unsharpenRadius + bottomRightRadius: root.unsharpenRadius + } + + // 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 + content: root.tooltipContent + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledSpinBox.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledSpinBox.qml new file mode 100644 index 0000000..c11f241 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledSpinBox.qml @@ -0,0 +1,92 @@ +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 + + 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.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/configs/quickshell/ii/qs/modules/common/widgets/StyledSwitch.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledSwitch.qml new file mode 100644 index 0000000..f16e213 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledSwitch.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * Material 3 switch. See https://m3.material.io/components/switch/overview + */ +Switch { + id: root + property real scale: 0.6 // 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 { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledText.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledText.qml new file mode 100644 index 0000000..6024fc6 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledText.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Text { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledTextArea.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledTextArea.qml new file mode 100644 index 0000000..e0abba3 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledTextArea.qml @@ -0,0 +1,18 @@ +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 + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledTextInput.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledTextInput.qml new file mode 100644 index 0000000..57d0c72 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledTextInput.qml @@ -0,0 +1,17 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextInput { + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/configs/quickshell/ii/qs/modules/common/widgets/StyledToolTip.qml b/configs/quickshell/ii/qs/modules/common/widgets/StyledToolTip.qml new file mode 100644 index 0000000..813c9ed --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/StyledToolTip.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolTip { + id: root + property string content + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + property bool internalVisibleCondition: { + const ans = (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + return ans + } + verticalPadding: 5 + horizontalPadding: 10 + opacity: internalVisibleCondition ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + background: null + + contentItem: Item { + id: contentItemBackground + implicitWidth: tooltipTextObject.width + 2 * root.horizontalPadding + implicitHeight: tooltipTextObject.height + 2 * root.verticalPadding + + Rectangle { + id: backgroundRectangle + anchors.bottom: contentItemBackground.bottom + anchors.horizontalCenter: contentItemBackground.horizontalCenter + color: Appearance?.colors.colTooltip ?? "#3C4043" + radius: Appearance?.rounding.verysmall ?? 7 + width: internalVisibleCondition ? (tooltipTextObject.width + 2 * padding) : 0 + height: internalVisibleCondition ? (tooltipTextObject.height + 2 * padding) : 0 + clip: true + + Behavior on width { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledText { + id: tooltipTextObject + anchors.centerIn: parent + text: content + font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + font.hintingPreference: Font.PreferNoHinting // Prevent shaky text + color: Appearance?.colors.colOnTooltip ?? "#FFFFFF" + wrapMode: Text.Wrap + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/ii/qs/modules/common/widgets/VerticalButtonGroup.qml b/configs/quickshell/ii/qs/modules/common/widgets/VerticalButtonGroup.qml new file mode 100644 index 0000000..b1ca845 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/WaveVisualizer.qml b/configs/quickshell/ii/qs/modules/common/widgets/WaveVisualizer.qml new file mode 100644 index 0000000..64559c1 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/modules/common/widgets/notification_utils.js b/configs/quickshell/ii/qs/modules/common/widgets/notification_utils.js new file mode 100644 index 0000000..9b15105 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/notification_utils.js @@ -0,0 +1,77 @@ + +/** + * @param { string } summary + * @returns { string } + */ +function findSuitableMaterialSymbol(summary = "") { + const defaultType = 'chat'; + if(summary.length === 0) return defaultType; + + const keywordsToTypes = { + 'reboot': 'restart_alt', + 'recording': 'screen_record', + 'battery': 'power', + 'power': 'power', + 'screenshot': 'screenshot_monitor', + 'welcome': 'waving_hand', + 'time': 'scheduleb', + 'installed': 'download', + 'configuration reloaded': 'reset_wrench', + 'config': 'reset_wrench', + 'update': 'update', + 'ai response': 'neurology', + 'control': 'settings', + 'upscale': 'compare', + 'install': 'deployed_code_update', + '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 } + */ +const 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"); +}; \ No newline at end of file diff --git a/configs/quickshell/ii/qs/modules/common/widgets/qmldir b/configs/quickshell/ii/qs/modules/common/widgets/qmldir new file mode 100644 index 0000000..a4a69f9 --- /dev/null +++ b/configs/quickshell/ii/qs/modules/common/widgets/qmldir @@ -0,0 +1,61 @@ +module qs.modules.common.widgets + +ButtonGroup 1.0 ButtonGroup.qml +CircularProgress 1.0 CircularProgress.qml +CliphistImage 1.0 CliphistImage.qml +ConfigRow 1.0 ConfigRow.qml +ConfigSelectionArray 1.0 ConfigSelectionArray.qml +ConfigSpinBox 1.0 ConfigSpinBox.qml +ConfigSwitch 1.0 ConfigSwitch.qml +ContentPage 1.0 ContentPage.qml +ContentSection 1.0 ContentSection.qml +ContentSubsectionLabel 1.0 ContentSubsectionLabel.qml +ContentSubsection 1.0 ContentSubsection.qml +CustomIcon 1.0 CustomIcon.qml +DialogButton 1.0 DialogButton.qml +DragManager 1.0 DragManager.qml +Favicon 1.0 Favicon.qml +FloatingActionButton 1.0 FloatingActionButton.qml +FlowButtonGroup 1.0 FlowButtonGroup.qml +GroupButton 1.0 GroupButton.qml +KeyboardKey 1.0 KeyboardKey.qml +LightDarkPreferenceButton 1.0 LightDarkPreferenceButton.qml +MaterialSymbol 1.0 MaterialSymbol.qml +MaterialTextField 1.0 MaterialTextField.qml +MenuButton 1.0 MenuButton.qml +NavigationRailButton 1.0 NavigationRailButton.qml +NavigationRailExpandButton 1.0 NavigationRailExpandButton.qml +NavigationRail 1.0 NavigationRail.qml +NavigationRailTabArray 1.0 NavigationRailTabArray.qml +NotificationActionButton 1.0 NotificationActionButton.qml +NotificationAppIcon 1.0 NotificationAppIcon.qml +NotificationGroupExpandButton 1.0 NotificationGroupExpandButton.qml +NotificationGroup 1.0 NotificationGroup.qml +NotificationItem 1.0 NotificationItem.qml +NotificationListView 1.0 NotificationListView.qml +PointingHandInteraction 1.0 PointingHandInteraction.qml +PointingHandLinkHover 1.0 PointingHandLinkHover.qml +PrimaryTabBar 1.0 PrimaryTabBar.qml +PrimaryTabButton 1.0 PrimaryTabButton.qml +Revealer 1.0 Revealer.qml +RippleButton 1.0 RippleButton.qml +RippleButtonWithIcon 1.0 RippleButtonWithIcon.qml +RoundCorner 1.0 RoundCorner.qml +SecondaryTabButton 1.0 SecondaryTabButton.qml +SelectionDialog 1.0 SelectionDialog.qml +SelectionGroupButton 1.0 SelectionGroupButton.qml +StyledFlickable 1.0 StyledFlickable.qml +StyledLabel 1.0 StyledLabel.qml +StyledListView 1.0 StyledListView.qml +StyledProgressBar 1.0 StyledProgressBar.qml +StyledRadioButton 1.0 StyledRadioButton.qml +StyledRectangularShadow 1.0 StyledRectangularShadow.qml +StyledSlider 1.0 StyledSlider.qml +StyledSpinBox 1.0 StyledSpinBox.qml +StyledSwitch 1.0 StyledSwitch.qml +StyledTextArea 1.0 StyledTextArea.qml +StyledTextInput 1.0 StyledTextInput.qml +StyledText 1.0 StyledText.qml +StyledToolTip 1.0 StyledToolTip.qml +VerticalButtonGroup 1.0 VerticalButtonGroup.qml +WaveVisualizer 1.0 WaveVisualizer.qml diff --git a/configs/quickshell/ii/qs/qmldir b/configs/quickshell/ii/qs/qmldir new file mode 100644 index 0000000..d1f4683 --- /dev/null +++ b/configs/quickshell/ii/qs/qmldir @@ -0,0 +1,30 @@ +module qs + +# Re-export all services as singletons +singleton Ai 1.0 services/Ai.qml +singleton AppSearch 1.0 services/AppSearch.qml +singleton Audio 1.0 services/Audio.qml +singleton Battery 1.0 services/Battery.qml +singleton Bluetooth 1.0 services/Bluetooth.qml +singleton Booru 1.0 services/Booru.qml +BooruResponseData 1.0 services/BooruResponseData.qml +singleton Brightness 1.0 services/Brightness.qml +singleton Cliphist 1.0 services/Cliphist.qml +singleton DateTime 1.0 services/DateTime.qml +singleton Emojis 1.0 services/Emojis.qml +singleton FirstRunExperience 1.0 services/FirstRunExperience.qml +singleton HyprlandData 1.0 services/HyprlandData.qml +singleton HyprlandKeybinds 1.0 services/HyprlandKeybinds.qml +singleton HyprlandXkb 1.0 services/HyprlandXkb.qml +singleton Hyprsunset 1.0 services/Hyprsunset.qml +singleton KeyringStorage 1.0 services/KeyringStorage.qml +singleton LatexRenderer 1.0 services/LatexRenderer.qml +singleton MaterialThemeLoader 1.0 services/MaterialThemeLoader.qml +singleton MprisController 1.0 services/MprisController.qml +singleton Network 1.0 services/Network.qml +singleton Notifications 1.0 services/Notifications.qml +singleton ResourceUsage 1.0 services/ResourceUsage.qml +singleton SystemInfo 1.0 services/SystemInfo.qml +singleton Todo 1.0 services/Todo.qml +singleton Weather 1.0 services/Weather.qml +singleton Ydotool 1.0 services/Ydotool.qml diff --git a/configs/quickshell/ii/qs/services/Ai.qml b/configs/quickshell/ii/qs/services/Ai.qml new file mode 100644 index 0000000..2839d7f --- /dev/null +++ b/configs/quickshell/ii/qs/services/Ai.qml @@ -0,0 +1,891 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions as CF +import qs.modules.common +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import "./ai/" + +/** + * Basic service to handle LLM chats. Supports Google's and OpenAI's API formats. + * Supports Gemini and OpenAI models. + * Limitations: + * - For now functions only work with Gemini API format + */ +Singleton { + id: root + + property Component aiMessageComponent: AiMessageData {} + property Component aiModelComponent: AiModel {} + property Component geminiApiStrategy: GeminiApiStrategy {} + property Component openaiApiStrategy: OpenAiApiStrategy {} + property Component mistralApiStrategy: MistralApiStrategy {} + readonly property string interfaceRole: "interface" + readonly property string apiKeyEnvVarName: "API_KEY" + + property string systemPrompt: { + let prompt = Config.options?.ai?.systemPrompt ?? ""; + for (let key in root.promptSubstitutions) { + // prompt = prompt.replaceAll(key, root.promptSubstitutions[key]); + // QML/JS doesn't support replaceAll, so use split/join + prompt = prompt.split(key).join(root.promptSubstitutions[key]); + } + return prompt; + } + // property var messages: [] + property var messageIDs: [] + property var messageByID: ({}) + readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} + readonly property var apiKeysLoaded: KeyringStorage.loaded + readonly property bool currentModelHasApiKey: { + const model = models[currentModelId]; + if (!model || !model.requires_key) return true; + if (!apiKeysLoaded) return false; + const key = apiKeys[model.key_id]; + return (key?.length > 0); + } + property var postResponseHook + property real temperature: Persistent.states?.ai?.temperature ?? 0.5 + property QtObject tokenCount: QtObject { + property int input: -1 + property int output: -1 + property int total: -1 + } + + function idForMessage(message) { + // Generate a unique ID using timestamp and random value + return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); + } + + function safeModelName(modelName) { + return modelName.replace(/:/g, "_").replace(/ /g, "-").replace(/\//g, "-") + } + + property list defaultPrompts: [] + property list userPrompts: [] + property list promptFiles: [...defaultPrompts, ...userPrompts] + property list savedChats: [] + + property var promptSubstitutions: { + "{DISTRO}": SystemInfo.distroName, + "{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`, + "{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown", + "{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})` + } + + // Gemini: https://ai.google.dev/gemini-api/docs/function-calling + // OpenAI: https://platform.openai.com/docs/guides/function-calling + property string currentTool: Config?.options.ai.tool ?? "search" + property var tools: { + "gemini": { + "functions": [{"functionDeclarations": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ]}], + "search": [{ + "google_search": {} + }], + "none": [] + }, + "openai": { + "functions": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ], + "search": [], + "none": [], + }, + "mistral": { + "functions": [ + { + "type": "function", + "function": { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + "parameters": {} + }, + }, + { + "type": "function", + "function": { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + }, + ], + "search": [], + "none": [], + } + } + property list availableTools: Object.keys(root.tools[models[currentModelId]?.api_format]) + property var toolDescriptions: { + "functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"), + "search": Translation.tr("Gives the model search capabilities (immediately)"), + "none": Translation.tr("Disable tools") + } + + // Model properties: + // - name: Name of the model + // - icon: Icon name of the model + // - description: Description of the model + // - endpoint: Endpoint of the model + // - model: Model name of the model + // - requires_key: Whether the model requires an API key + // - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + // - key_get_link: Link to get an API key + // - key_get_description: Description of pricing and how to get an API key + // - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + // - extraParams: Extra parameters to be passed to the model. This is a JSON object. + property var models: { + "gemini-2.0-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.0 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nFast, can perform searches for up-to-date information"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent", + "model": "gemini-2.5-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-pro": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Pro", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent", + "model": "gemini-2.5-pro", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-lite": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash-Lite", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent", + "model": "gemini-2.5-flash-lite", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "mistral-medium-3": aiModelComponent.createObject(this, { + "name": "Mistral Medium 3", + "icon": "mistral-symbolic", + "description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"), + "homepage": "https://mistral.ai/news/mistral-medium-3", + "endpoint": "https://api.mistral.ai/v1/chat/completions", + "model": "mistral-medium-2505", + "requires_key": true, + "key_id": "mistral", + "key_get_link": "https://console.mistral.ai/api-keys", + "key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"), + "api_format": "mistral", + }), + "openrouter-deepseek-r1": aiModelComponent.createObject(this, { + "name": "DeepSeek R1", + "icon": "deepseek-symbolic", + "description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"), + "homepage": "https://openrouter.ai/deepseek/deepseek-r1:free", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "deepseek/deepseek-r1:free", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }), + } + property var modelList: Object.keys(root.models) + property var currentModelId: Persistent.states?.ai?.model || modelList[0] + + property var apiStrategies: { + "openai": openaiApiStrategy.createObject(this), + "gemini": geminiApiStrategy.createObject(this), + "mistral": mistralApiStrategy.createObject(this), + } + property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"] + + Connections { + target: Config + function onReadyChanged() { + if (!Config.ready) return; + (Config?.options.ai?.extraModels ?? []).forEach(model => { + const safeModelName = root.safeModelName(model["model"]); + root.addModel(safeModelName, model) + }); + } + } + + Component.onCompleted: { + setModel(currentModelId, false, false); // Do necessary setup for model + } + + function guessModelLogo(model) { + if (model.includes("llama")) return "ollama-symbolic"; + if (model.includes("gemma")) return "google-gemini-symbolic"; + if (model.includes("deepseek")) return "deepseek-symbolic"; + if (/^phi\d*:/i.test(model)) return "microsoft-symbolic"; + return "ollama-symbolic"; + } + + function guessModelName(model) { + const replaced = model.replace(/-/g, ' ').replace(/:/g, ' '); + let words = replaced.split(' '); + words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`) + words = words.map((word) => { + return (word.charAt(0).toUpperCase() + word.slice(1)) + }); + if (words[words.length - 1] === "Latest") words.pop(); + else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets + const result = words.join(' '); + return result; + } + + function addModel(modelName, data) { + root.models[modelName] = aiModelComponent.createObject(this, data); + } + + Process { + id: getOllamaModels + running: true + command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")] + stdout: SplitParser { + onRead: data => { + try { + if (data.length === 0) return; + const dataJson = JSON.parse(data); + root.modelList = [...root.modelList, ...dataJson]; + dataJson.forEach(model => { + const safeModelName = root.safeModelName(model); + root.addModel(safeModelName, { + "name": guessModelName(model), + "icon": guessModelLogo(model), + "description": Translation.tr("Local Ollama model | %1").arg(model), + "homepage": `https://ollama.com/library/${model}`, + "endpoint": "http://localhost:11434/v1/chat/completions", + "model": model, + "requires_key": false, + }) + }); + + root.modelList = Object.keys(root.models); + + } catch (e) { + console.log("Could not fetch Ollama models:", e); + } + } + } + } + + Process { + id: getDefaultPrompts + running: true + command: ["ls", "-1", Directories.defaultAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.defaultPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.defaultAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getUserPrompts + running: true + command: ["ls", "-1", Directories.userAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.userPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.userAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getSavedChats + running: true + command: ["ls", "-1", Directories.aiChats] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.savedChats = text.split("\n") + .filter(fileName => fileName.endsWith(".json")) + .map(fileName => `${Directories.aiChats}/${fileName}`) + } + } + } + + FileView { + id: promptLoader + watchChanges: false; + onLoadedChanged: { + if (!promptLoader.loaded) return; + Config.options.ai.systemPrompt = promptLoader.text(); + root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + } + + function printPrompt() { + root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + + function loadPrompt(filePath) { + promptLoader.path = "" // Unload + promptLoader.path = filePath; // Load + promptLoader.reload(); + } + + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "rawContent": message, + "thinking": false, + "done": true, + }); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function removeMessage(index) { + if (index < 0 || index >= messageIDs.length) return; + const id = root.messageIDs[index]; + root.messageIDs.splice(index, 1); + root.messageIDs = [...root.messageIDs]; + delete root.messageByID[id]; + } + + function addApiKeyAdvice(model) { + root.addMessage( + Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3') + .arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("No further instruction provided")).arg("/key"), + Ai.interfaceRole + ); + } + + function getModel() { + return models[currentModelId]; + } + + function setModel(modelId, feedback = true, setPersistentState = true) { + if (!modelId) modelId = "" + modelId = modelId.toLowerCase() + if (modelList.indexOf(modelId) !== -1) { + const model = models[modelId] + // Fetch API keys if needed + if (model?.requires_key) KeyringStorage.fetchKeyringData(); + // See if policy prevents online models + if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) { + root.addMessage( + Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"), + root.interfaceRole + ); + return; + } + if (setPersistentState) Persistent.states.ai.model = modelId; + if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole); + if (model.requires_key) { + // If key not there show advice + if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) { + root.addApiKeyAdvice(model) + } + } + } else { + if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```" + } + } + + function setTool(tool) { + if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) { + root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole); + return false; + } + Config.options.ai.tool = tool; + return true; + } + + function getTemperature() { + return root.temperature; + } + + function setTemperature(value) { + if (value == NaN || value < 0 || value > 2) { + root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole); + return; + } + Persistent.states.ai.temperature = value; + root.temperature = value; + root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole); + } + + function setApiKey(key) { + const model = models[currentModelId]; + if (!model.requires_key) { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + return; + } + if (!key || key.length === 0) { + const model = models[currentModelId]; + root.addApiKeyAdvice(model) + return; + } + KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim()); + root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole); + } + + function printApiKey() { + const model = models[currentModelId]; + if (model.requires_key) { + const key = root.apiKeys[model.key_id]; + if (key) { + root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole); + } else { + root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole); + } + } else { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + } + } + + function printTemperature() { + root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole); + } + + function clearMessages() { + root.messageIDs = []; + root.messageByID = ({}); + root.tokenCount.input = -1; + root.tokenCount.output = -1; + root.tokenCount.total = -1; + } + + Process { + id: requester + property list baseCommand: ["bash", "-c"] + property AiMessageData message + property ApiStrategy currentStrategy + + function markDone() { + requester.message.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; // Reset hook after use + } + root.saveChat("lastSession") + } + + function makeRequest() { + const model = models[currentModelId]; + requester.currentStrategy = root.currentApiStrategy; + requester.currentStrategy.reset(); // Reset strategy state + + /* Put API key in environment variable */ + if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "" + + /* Build endpoint, request data */ + const endpoint = root.currentApiStrategy.buildEndpoint(model); + const messageArray = root.messageIDs.map(id => root.messageByID[id]); + const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole); + const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool]); + // console.log("[Ai] Request data: ", JSON.stringify(data, null, 2)); + + let requestHeaders = { + "Content-Type": "application/json", + } + + /* Create local message object */ + requester.message = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": currentModelId, + "content": "", + "rawContent": "", + "thinking": true, + "done": false, + }); + const id = idForMessage(requester.message); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = requester.message; + + /* Build header string for curl */ + let headerString = Object.entries(requestHeaders) + .filter(([k, v]) => v && v.length > 0) + .map(([k, v]) => `-H '${k}: ${v}'`) + .join(' '); + + // console.log("Request headers: ", JSON.stringify(requestHeaders)); + // console.log("Header string: ", headerString); + + /* Get authorization header from strategy */ + const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName); + + /* Create command string */ + const requestCommandString = `curl --no-buffer "${endpoint}"` + + ` ${headerString}` + + (authHeader ? ` ${authHeader}` : "") + + ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + + /* Send the request */ + requester.command = baseCommand.concat([requestCommandString]); + requester.running = true + } + + stdout: SplitParser { + onRead: data => { + if (data.length === 0) return; + if (requester.message.thinking) requester.message.thinking = false; + // console.log("[Ai] Raw response line: ", data); + + // Handle response line + try { + const result = requester.currentStrategy.parseResponseLine(data, requester.message); + // console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2)); + + if (result.functionCall) { + requester.message.functionCall = result.functionCall; + root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message); + } + if (result.tokenUsage) { + root.tokenCount.input = result.tokenUsage.input; + root.tokenCount.output = result.tokenUsage.output; + root.tokenCount.total = result.tokenUsage.total; + } + if (result.finished) { + requester.markDone(); + } + + } catch (e) { + console.log("[AI] Could not parse response: ", e); + requester.message.rawContent += data; + requester.message.content += data; + } + } + } + + onExited: (exitCode, exitStatus) => { + const result = requester.currentStrategy.onRequestFinished(requester.message); + + if (result.finished) { + requester.markDone(); + } else if (!requester.message.done) { + requester.markDone(); + } + + // Handle error responses + if (requester.message.content.includes("API key not valid")) { + root.addApiKeyAdvice(models[requester.message.model]); + } + } + } + + function sendUserMessage(message) { + if (message.length === 0) return; + root.addMessage(message, "user"); + requester.makeRequest(); + } + + function createFunctionOutputMessage(name, output, includeOutputInChat = true) { + return aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + // "visibleToUser": false, + }); + } + + function addFunctionOutputMessage(name, output) { + const aiMessage = createFunctionOutputMessage(name, output); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function rejectCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user")) + } + + function approveCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + + const responseMessage = createFunctionOutputMessage(message.functionName, "", false); + const id = idForMessage(responseMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = responseMessage; + + commandExecutionProc.message = responseMessage; + commandExecutionProc.baseMessageContent = responseMessage.content; + commandExecutionProc.shellCommand = message.functionCall.args.command; + commandExecutionProc.running = true; // Start the command execution + } + + Process { + id: commandExecutionProc + property string shellCommand: "" + property AiMessageData message + property string baseMessageContent: "" + command: ["bash", "-c", shellCommand] + stdout: SplitParser { + onRead: (output) => { + commandExecutionProc.message.functionResponse += output + "\n\n"; + const updatedContent = commandExecutionProc.baseMessageContent + `\n\n\n${commandExecutionProc.message.functionResponse}\n`; + commandExecutionProc.message.rawContent = updatedContent; + commandExecutionProc.message.content = updatedContent; + } + } + onExited: (exitCode, exitStatus) => { + commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`; + requester.makeRequest(); // Continue + } + } + + function handleFunctionCall(name, args: var, message: AiMessageData) { + if (name === "switch_to_search_mode") { + const modelId = root.currentModelId; + root.currentTool = "search" + root.postResponseHook = () => { root.currentTool = "functions" } + addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request.")) + requester.makeRequest(); + } else if (name === "get_shell_config") { + const configJson = CF.ObjectUtils.toPlainObject(Config.options) + addFunctionOutputMessage(name, JSON.stringify(configJson)); + requester.makeRequest(); + } else if (name === "set_shell_config") { + if (!args.key || !args.value) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`.")); + return; + } + const key = args.key; + const value = args.value; + Config.setNestedValue(key, value); + } else if (name === "run_shell_command") { + if (!args.command || args.command.length === 0) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`.")); + return; + } + const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``; + message.rawContent += contentToAppend; + message.content += contentToAppend; + message.functionPending = true; // Use thinking to indicate the command is waiting for approval + } + else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant"); + } + + function chatToJson() { + return root.messageIDs.map(id => { + const message = root.messageByID[id] + return ({ + "role": message.role, + "rawContent": message.rawContent, + "model": message.model, + "thinking": false, + "done": true, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }) + }) + } + + FileView { + id: chatSaveFile + property string chatName: "chat" + path: `${Directories.aiChats}/${chatName}.json` + blockLoading: true + } + + /** + * Saves chat to a JSON list of message objects. + * @param chatName name of the chat + */ + function saveChat(chatName) { + chatSaveFile.chatName = chatName.trim() + const saveContent = JSON.stringify(root.chatToJson()) + chatSaveFile.setText(saveContent) + getSavedChats.running = true; + } + + /** + * Loads chat from a JSON list of message objects. + * @param chatName name of the chat + */ + function loadChat(chatName) { + try { + chatSaveFile.chatName = chatName.trim() + chatSaveFile.reload() + const saveContent = chatSaveFile.text() + // console.log(saveContent) + const saveData = JSON.parse(saveContent) + root.clearMessages() + root.messageIDs = saveData.map((_, i) => { + return i + }) + // console.log(JSON.stringify(messageIDs)) + for (let i = 0; i < saveData.length; i++) { + const message = saveData[i]; + root.messageByID[i] = root.aiMessageComponent.createObject(root, { + "role": message.role, + "rawContent": message.rawContent, + "content": message.rawContent, + "model": message.model, + "thinking": message.thinking, + "done": message.done, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }); + } + } catch (e) { + console.log("[AI] Could not load chat: ", e); + } finally { + getSavedChats.running = true; + } + } +} diff --git a/configs/quickshell/ii/qs/services/AppSearch.qml b/configs/quickshell/ii/qs/services/AppSearch.qml new file mode 100644 index 0000000..44d0912 --- /dev/null +++ b/configs/quickshell/ii/qs/services/AppSearch.qml @@ -0,0 +1,148 @@ +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", + "zen": "zen-browser", + "brave-browser": "brave-desktop" + }) + 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" + } + ] + + readonly property list list: Array.from(DesktopEntries.applications.values) + .sort((a, b) => a.name.localeCompare(b.name)) + + 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 guessIcon(str) { + if (!str || str.length == 0) return "image-missing"; + + // 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; + + + // 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; + } + + + // Give up + return str; + } +} diff --git a/configs/quickshell/ii/qs/services/Audio.qml b/configs/quickshell/ii/qs/services/Audio.qml new file mode 100644 index 0000000..0651ebc --- /dev/null +++ b/configs/quickshell/ii/qs/services/Audio.qml @@ -0,0 +1,54 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for default Pipewire audio sink and source. + */ +Singleton { + id: root + + property bool ready: Pipewire.defaultAudioSink?.ready ?? false + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + + signal sinkProtectionTriggered(string reason); + + 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; + if (!lastReady) { + lastVolume = sink.audio.volume; + lastReady = true; + return; + } + const newVolume = sink.audio.volume; + 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("Illegal increment"); + } else if (newVolume > maxAllowed) { + root.sinkProtectionTriggered("Exceeded max allowed"); + sink.audio.volume = Math.min(lastVolume, maxAllowed); + } + if (sink.ready && (isNaN(sink.audio.volume) || sink.audio.volume === undefined || sink.audio.volume === null)) { + sink.audio.volume = 0; + } + lastVolume = sink.audio.volume; + } + + } + +} diff --git a/configs/quickshell/ii/qs/services/Battery.qml b/configs/quickshell/ii/qs/services/Battery.qml new file mode 100644 index 0000000..8a3cf31 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Battery.qml @@ -0,0 +1,50 @@ +pragma Singleton + +import qs +import qs.modules.common +import Quickshell +import Quickshell.Services.UPower + +Singleton { + 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 + readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend + + property bool isLow: percentage <= Config.options.battery.low / 100 + property bool isCritical: percentage <= Config.options.battery.critical / 100 + property bool isSuspending: percentage <= Config.options.battery.suspend / 100 + + property bool isLowAndNotCharging: isLow && !isCharging + property bool isCriticalAndNotCharging: isCritical && !isCharging + property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging + + onIsLowAndNotChargingChanged: { + if (available && isLowAndNotCharging) Quickshell.execDetached([ + "notify-send", + Translation.tr("Low battery"), + Translation.tr("Consider plugging in your device"), + "-u", "critical", + "-a", "Shell" + ]) + } + + onIsCriticalAndNotChargingChanged: { + if (available && isCriticalAndNotCharging) 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" + ]); + + } + + onIsSuspendingAndNotChargingChanged: { + if (available && isSuspendingAndNotCharging) { + Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]); + } + } +} diff --git a/configs/quickshell/ii/qs/services/Bluetooth.qml b/configs/quickshell/ii/qs/services/Bluetooth.qml new file mode 100644 index 0000000..817bbc9 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Bluetooth.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * Basic polled Bluetooth state. + */ +Singleton { + id: root + + property int updateInterval: 1000 + property string bluetoothDeviceName: "" + property string bluetoothDeviceAddress: "" + property bool bluetoothEnabled: false + property bool bluetoothConnected: false + + function update() { + updateBluetoothDevice.running = true + updateBluetoothStatus.running = true + updateBluetoothEnabled.running = true + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + update() + interval = root.updateInterval + } + } + + // Check if Bluetooth is enabled (controller powered on) + Process { + id: updateBluetoothEnabled + command: ["sh", "-c", "bluetoothctl show | grep -q 'Powered: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothEnabled = (parseInt(data) === 1) + } + } + } + + // Get the name and address of the first connected Bluetooth device + Process { + id: updateBluetoothDevice + command: ["sh", "-c", "bluetoothctl info | awk -F': ' '/Name: /{name=$2} /Device /{addr=$2} END{print name \":\" addr}'"] + running: true + stdout: SplitParser { + onRead: data => { + let parts = data.split(":") + root.bluetoothDeviceName = parts[0] || "" + root.bluetoothDeviceAddress = parts[1] || "" + } + } + } + + // Check if any device is connected + Process { + id: updateBluetoothStatus + command: ["sh", "-c", "bluetoothctl info | grep -q 'Connected: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothConnected = (parseInt(data) === 1) + } + } + } +} diff --git a/configs/quickshell/ii/qs/services/Booru.qml b/configs/quickshell/ii/qs/services/Booru.qml new file mode 100644 index 0000000..e2a9d19 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Booru.qml @@ -0,0 +1,467 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs +import Quickshell; +import QtQuick; + +/** + * A service for interacting with various booru APIs. + */ +Singleton { + id: root + property Component booruResponseDataComponent: BooruResponseData {} + + signal tagSuggestion(string query, var suggestions) + + property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number") + property var responses: [] + property int runningRequests: 0 + property var defaultUserAgent: Config.options?.networking?.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 var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api) + property var providers: { + "system": { "name": Translation.tr("System") }, + "yandere": { + "name": "yande.re", + "url": "https://yande.re", + "api": "https://yande.re/post.json", + "description": Translation.tr("All-rounder | Good quality, decent quantity"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://yande.re/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "konachan": { + "name": "Konachan", + "url": "https://konachan.net", + "api": "https://konachan.net/post.json", + "description": Translation.tr("For desktop wallpapers | Good quality"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://konachan.net/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "zerochan": { + "name": "Zerochan", + "url": "https://www.zerochan.net", + "api": "https://www.zerochan.net/?json", + "description": Translation.tr("Clean stuff | Excellent quality, no NSFW"), + "mapFunc": (response) => { + response = response.items + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.join(" "), + "rating": "safe", // Zerochan doesn't have nsfw + "is_nsfw": false, + "md5": item.md5, + "preview_url": item.thumbnail, + "sample_url": item.thumbnail, + "file_url": item.thumbnail, + "file_ext": "avif", + "source": getWorkingImageSource(item.source) ?? item.thumbnail, + "character": item.tag + } + }) + } + }, + "danbooru": { + "name": "Danbooru", + "url": "https://danbooru.donmai.us", + "api": "https://danbooru.donmai.us/posts.json", + "description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.image_width, + "height": item.image_height, + "aspect_ratio": item.image_width / item.image_height, + "tags": item.tag_string, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_file_url, + "sample_url": item.file_url ?? item.large_file_url, + "file_url": item.large_file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://danbooru.donmai.us/tags.json?search[name_matches]={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.post_count + } + }) + } + + }, + "gelbooru": { + "name": "Gelbooru", + "url": "https://gelbooru.com", + "api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1", + "description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"), + "mapFunc": (response) => { + response = response.post + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating.replace('general', 's').charAt(0), + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_url.split('.').pop(), + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&name_pattern={{query}}%", + "tagMapFunc": (response) => { + return response.tag.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "waifu.im": { + "name": "waifu.im", + "url": "https://waifu.im", + "api": "https://api.waifu.im/search", + "description": Translation.tr("Waifus only | Excellent quality, limited quantity"), + "mapFunc": (response) => { + response = response.images + return response.map(item => { + return { + "id": item.image_id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.map(tag => {return tag.name}).join(" "), + "rating": item.is_nsfw ? "e" : "s", + "is_nsfw": item.is_nsfw, + "md5": item.md5, + "preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk) + "sample_url": item.url, + "file_url": item.url, + "file_ext": item.extension, + "source": getWorkingImageSource(item.source) ?? item.url, + } + }) + }, + "tagSearchTemplate": "https://api.waifu.im/tags", + "tagMapFunc": (response) => { + return [...response.versatile.map(item => {return {"name": item}}), + ...response.nsfw.map(item => {return {"name": item}})] + } + }, + "t.alcy.cc": { + "name": "Alcy", + "url": "https://t.alcy.cc", + "api": "https://t.alcy.cc/", + "description": Translation.tr("Large images | God tier quality, no NSFW."), + "fixedTags": [ + { + "name": "ycy", + "count": "General" + }, + { + "name": "moez", + "count": "Moe" + }, + { + "name": "ysz", + "count": "Genshin Impact" + }, + { + "name": "fj", + "count": "Landscape" + }, + { + "name": "bd", + "count": "Girl on white background" + }, + { + "name": "xhl", + "count": "Shiggy" + }, + ], + "manualParseFunc": (responseText) => { + // Alcy just returns image links, each on a new line + const lines = responseText.trim().split('\n'); + return lines.map(line => { + return { + "id": Qt.md5(line), + // Alcy doesn't provide dimensions and images are often of god resolution + "width": 1000, + "height": 1000, + "aspect_ratio": 1, // Default aspect ratio + "tags": "[no tags]", + "rating": "s", + "is_nsfw": false, + "md5": Qt.md5(line), + "preview_url": line, + "sample_url": line, + "file_url": line, + "file_ext": line.split('.').pop(), + "source": "", + } + }); + }, + } + } + property var currentProvider: Persistent.states.booru.provider + + function getWorkingImageSource(url) { + if (url.includes('pximg.net')) { + return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`; + } + return url; + } + + function setProvider(provider) { + provider = provider.toLowerCase() + if (providerList.indexOf(provider) !== -1) { + Persistent.states.booru.provider = provider + root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name + + (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : "")) + } else { + root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- ")) + } + } + + function clearResponses() { + responses = [] + } + + function addSystemMessage(message) { + responses = [...responses, root.booruResponseDataComponent.createObject(null, { + "provider": "system", + "tags": [], + "page": -1, + "images": [], + "message": `${message}` + })] + } + + function constructRequestUrl(tags, nsfw=true, limit=20, page=1) { + var provider = providers[currentProvider] + var baseUrl = provider.api + var url = baseUrl + var tagString = tags.join(" ") + if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) { + if (currentProvider == "gelbooru") + tagString += " rating:general"; + else + tagString += " rating:safe"; + } + var params = [] + // Tags & limit + if (currentProvider === "zerochan") { + params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color + params.push("l=" + limit) + params.push("s=" + "fav") + params.push("t=" + 1) + params.push("p=" + page) + } + else if (currentProvider === "waifu.im") { + var tagsArray = tagString.split(" "); + tagsArray.forEach(tag => { + params.push("included_tags=" + encodeURIComponent(tag)); + }); + params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30 + params.push("is_nsfw=" + (nsfw ? "null" : "false")) // null is random + } + else if (currentProvider === "t.alcy.cc") { + url += tagString + params.push("json") + params.push("quantity=" + limit) + } + else { + params.push("tags=" + encodeURIComponent(tagString)) + params.push("limit=" + limit) + if (currentProvider == "gelbooru") { + params.push("pid=" + page) + } + else { + params.push("page=" + page) + } + } + if (baseUrl.indexOf("?") === -1) { + url += "?" + params.join("&") + } else { + url += "&" + params.join("&") + } + return url + } + + function makeRequest(tags, nsfw=false, limit=20, page=1) { + var url = constructRequestUrl(tags, nsfw, limit, page) + console.log("[Booru] Making request to " + url) + + const newResponse = root.booruResponseDataComponent.createObject(null, { + "provider": currentProvider, + "tags": tags, + "page": page, + "images": [], + "message": "" + }) + + var xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + const provider = providers[currentProvider] + let response; + if (provider.manualParseFunc) { + response = provider.manualParseFunc(xhr.responseText) + } else { + response = JSON.parse(xhr.responseText) + response = provider.mapFunc(response) + } + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + newResponse.images = response + newResponse.message = response.length > 0 ? "" : root.failMessage + + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + newResponse.message = root.failMessage + } finally { + root.runningRequests--; + root.responses = [...root.responses, newResponse] + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + else if (currentProvider == "zerochan") { + const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent + xhr.setRequestHeader("User-Agent", userAgent) + } + root.runningRequests++; + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } + + property var currentTagRequest: null + function triggerTagSearch(query) { + if (currentTagRequest) { + currentTagRequest.abort(); + } + + var provider = providers[currentProvider] + if (provider.fixedTags) { + root.tagSuggestion(query, provider.fixedTags) + return provider.fixedTags; + } else if (!provider.tagSearchTemplate) { + return + } + var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query)) + + var xhr = new XMLHttpRequest() + currentTagRequest = xhr + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + currentTagRequest = null + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + var response = JSON.parse(xhr.responseText) + response = provider.tagMapFunc(response) + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + root.tagSuggestion(query, response) + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } +} + diff --git a/configs/quickshell/ii/qs/services/BooruResponseData.qml b/configs/quickshell/ii/qs/services/BooruResponseData.qml new file mode 100644 index 0000000..2a61ff6 --- /dev/null +++ b/configs/quickshell/ii/qs/services/BooruResponseData.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import QtQuick; + +/** + * A booru response. + */ +QtObject { + property string provider + property var tags + property var page + property var images + property string message +} diff --git a/configs/quickshell/ii/qs/services/Brightness.qml b/configs/quickshell/ii/qs/services/Brightness.qml new file mode 100644 index 0000000..927a10c --- /dev/null +++ b/configs/quickshell/ii/qs/services/Brightness.qml @@ -0,0 +1,152 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://github.com/caelestia-dots/shell/ (`quickshell` branch) with modifications. +// License: GPLv3 + +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; + } + + 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({ + model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }); + } + } + } + onExited: root.ddcMonitorsChanged() + } + + Process { + id: setProc + } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? "" + property real brightness + property bool ready: false + + onBrightnessChanged: { + if (monitor.ready) { + root.brightnessChanged(); + } + } + + function initialize() { + monitor.ready = false; + 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.brightness = parseInt(current) / parseInt(max); + monitor.ready = true; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0.01, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + brightness = value; + setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"]; + setProc.startDetached(); + } + + Component.onCompleted: { + initialize(); + } + + onBusNumChanged: { + initialize(); + } + } + + Component { + id: monitorComp + + BrightnessMonitor {} + } + + 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/configs/quickshell/ii/qs/services/Cliphist.qml b/configs/quickshell/ii/qs/services/Cliphist.qml new file mode 100644 index 0000000..6e3f7a1 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Cliphist.qml @@ -0,0 +1,101 @@ +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 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 (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 refresh() { + readProc.buffer = [] + readProc.running = true + } + + function copy(entry) { + Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(entry)}' | cliphist decode | wl-copy`]); + } + + Process { + id: deleteProc + property string entry: "" + command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | cliphist delete`] + function deleteEntry(entry) { + deleteProc.entry = entry; + deleteProc.running = true; + deleteProc.entry = ""; + } + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + function deleteEntry(entry) { + deleteProc.deleteEntry(entry); + } + + 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: ["cliphist", "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) + } + } + } +} diff --git a/configs/quickshell/ii/qs/services/DateTime.qml b/configs/quickshell/ii/qs/services/DateTime.qml new file mode 100644 index 0000000..16dc6c4 --- /dev/null +++ b/configs/quickshell/ii/qs/services/DateTime.qml @@ -0,0 +1,51 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property var clock: SystemClock { + id: clock + precision: SystemClock.Minutes + } + property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm") + property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy") + 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/configs/quickshell/ii/qs/services/Emojis.qml b/configs/quickshell/ii/qs/services/Emojis.qml new file mode 100644 index 0000000..436401b --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/services/FirstRunExperience.qml b/configs/quickshell/ii/qs/services/FirstRunExperience.qml new file mode 100644 index 0000000..f23cce5 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/services/HyprlandData.qml b/configs/quickshell/ii/qs/services/HyprlandData.qml new file mode 100644 index 0000000..07c2d89 --- /dev/null +++ b/configs/quickshell/ii/qs/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: ["bash", "-c", "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: ["bash", "-c", "hyprctl monitors -j"] + stdout: StdioCollector { + id: monitorsCollector + onStreamFinished: { + root.monitors = JSON.parse(monitorsCollector.text); + } + } + } + + Process { + id: getLayers + command: ["bash", "-c", "hyprctl layers -j"] + stdout: StdioCollector { + id: layersCollector + onStreamFinished: { + root.layers = JSON.parse(layersCollector.text); + } + } + } + + Process { + id: getWorkspaces + command: ["bash", "-c", "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: ["bash", "-c", "hyprctl activeworkspace -j"] + stdout: StdioCollector { + id: activeWorkspaceCollector + onStreamFinished: { + root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text); + } + } + } +} diff --git a/configs/quickshell/ii/qs/services/HyprlandKeybinds.qml b/configs/quickshell/ii/qs/services/HyprlandKeybinds.qml new file mode 100644 index 0000000..3381926 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/services/HyprlandXkb.qml b/configs/quickshell/ii/qs/services/HyprlandXkb.qml new file mode 100644 index 0000000..cace0d2 --- /dev/null +++ b/configs/quickshell/ii/qs/services/HyprlandXkb.qml @@ -0,0 +1,108 @@ +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: key + whitespace + description + const match = line.match(/^\s*(\S+)\s+(.+)$/); + if (match && match[2] === targetDescription) { + root.cachedLayoutCodes[match[2]] = match[1]; + root.currentLayoutCode = match[1]; + return true; + } + }); + // 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.layouts.length > 1) + "): " + // + root.layouts.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.split(",")[1]; + + // Update layout for on-screen keyboard (osk) + Config.options.osk.layout = root.currentLayoutName; + } else if (event.name == "configreloaded") { + // Mark layout code list to be updated when config is reloaded + root.needsLayoutRefresh = true; + } + } + } +} diff --git a/configs/quickshell/ii/qs/services/Hyprsunset.qml b/configs/quickshell/ii/qs/services/Hyprsunset.qml new file mode 100644 index 0000000..d6def43 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Hyprsunset.qml @@ -0,0 +1,117 @@ +pragma Singleton + +import QtQuick +import qs.modules.common +import Quickshell +import Quickshell.Io + +/** + * 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 var manualActive + property string from: Config.options?.light?.night?.from ?? "19:00" // Default to 7 PM + property string to: Config.options?.light?.night?.to ?? "06:30" // Default to 6:30 AM + property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true) + property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000 // Default color temperature + 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 + + + function isNoLater(hour1, minute1, hour2, minute2) { + if (hour1 < hour2) + return true; + if (hour1 === hour2 && minute1 < minute2) + return true; + return false; + } + + + onClockMinuteChanged: reEvaluate() + onAutomaticChanged: { + root.manualActive = undefined; + root.firstEvaluation = true; + reEvaluate(); + } + function reEvaluate() { + const toHourIsNextDay = !isNoLater(fromHour, fromMinute, toHour, toMinute); + const toHourWrapped = toHourIsNextDay ? toHour + 24 : toHour; + const toMinuteWrapped = toMinute; + root.shouldBeOn = isNoLater(fromHour, fromMinute, clockHour, clockMinute) && isNoLater(clockHour, clockMinute, toHourWrapped, toMinuteWrapped); + 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"); + // console.log("[Hyprsunset] Fetched state:", output, "->", root.active); + } + } + } + + function toggle() { + if (root.manualActive === undefined) + root.manualActive = root.active; + + root.manualActive = !root.manualActive; + if (root.manualActive) { + root.enable(); + } else { + root.disable(); + } + } +} diff --git a/configs/quickshell/ii/qs/services/KeyringStorage.qml b/configs/quickshell/ii/qs/services/KeyringStorage.qml new file mode 100644 index 0000000..ce6b8eb --- /dev/null +++ b/configs/quickshell/ii/qs/services/KeyringStorage.qml @@ -0,0 +1,118 @@ +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", `echo $(secret-tool lookup 'application' 'illogical-impulse')`, + ] + stdout: SplitParser { + onRead: data => { + if(data.length === 0) 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 !== 0) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + root.loaded = true; + } + } + +} diff --git a/configs/quickshell/ii/qs/services/LatexRenderer.qml b/configs/quickshell/ii/qs/services/LatexRenderer.qml new file mode 100644 index 0000000..5baf336 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/services/MaterialThemeLoader.qml b/configs/quickshell/ii/qs/services/MaterialThemeLoader.qml new file mode 100644 index 0000000..8872c47 --- /dev/null +++ b/configs/quickshell/ii/qs/services/MaterialThemeLoader.qml @@ -0,0 +1,58 @@ +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) + } + + 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) + } + } +} diff --git a/configs/quickshell/ii/qs/services/MprisController.qml b/configs/quickshell/ii/qs/services/MprisController.qml new file mode 100644 index 0000000..60923a6 --- /dev/null +++ b/configs/quickshell/ii/qs/services/MprisController.qml @@ -0,0 +1,165 @@ +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 qs +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root; + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + + 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/configs/quickshell/ii/qs/services/Network.qml b/configs/quickshell/ii/qs/services/Network.qml new file mode 100644 index 0000000..50bfb67 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Network.qml @@ -0,0 +1,93 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Simple polled network state service. + */ +Singleton { + id: root + + property bool wifi: true + property bool ethernet: false + property int updateInterval: 1000 + property string networkName: "" + property int networkStrength + property string materialSymbol: ethernet ? "lan" : + (Network.networkName.length > 0 && Network.networkName != "lo") ? ( + Network.networkStrength > 80 ? "signal_wifi_4_bar" : + Network.networkStrength > 60 ? "network_wifi_3_bar" : + Network.networkStrength > 40 ? "network_wifi_2_bar" : + Network.networkStrength > 20 ? "network_wifi_1_bar" : + "signal_wifi_0_bar" + ) : "signal_wifi_off" + function update() { + updateConnectionType.startCheck(); + updateNetworkName.running = true; + updateNetworkStrength.running = true; + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + root.update(); + interval = root.updateInterval; + } + } + + Process { + id: updateConnectionType + property string buffer + command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"] + 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'); + let hasEthernet = false; + let hasWifi = false; + lines.forEach(line => { + if (line.includes("ethernet")) + hasEthernet = true; + else if (line.includes("wireless")) + hasWifi = true; + }); + 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); + } + } + } +} diff --git a/configs/quickshell/ii/qs/services/Notifications.qml b/configs/quickshell/ii/qs/services/Notifications.qml new file mode 100644 index 0000000..bf5d4e7 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Notifications.qml @@ -0,0 +1,289 @@ +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 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: 5000 + running: true + onTriggered: () => { + root.timeoutNotification(notificationId); + destroy() + } + } + + property bool silent: false + 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 ? 5000 : notification.expireTimeout, + }); + } + } + + root.notify(newNotifObject); + // console.log(notifToString(newNotifObject)); + notifFileView.setText(stringifyList(root.list)); + } + } + + 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 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/configs/quickshell/ii/qs/services/ResourceUsage.qml b/configs/quickshell/ii/qs/services/ResourceUsage.qml new file mode 100644 index 0000000..6505284 --- /dev/null +++ b/configs/quickshell/ii/qs/services/ResourceUsage.qml @@ -0,0 +1,62 @@ +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 { + property double memoryTotal: 1 + property double memoryFree: 1 + property double memoryUsed: memoryTotal - memoryFree + property double memoryUsedPercentage: memoryUsed / memoryTotal + property double swapTotal: 1 + property double swapFree: 1 + property double swapUsed: swapTotal - swapFree + property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 + property double cpuUsage: 0 + property var previousCpuStats + + 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 } + } + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { id: fileMeminfo; path: "/proc/meminfo" } + FileView { id: fileStat; path: "/proc/stat" } +} diff --git a/configs/quickshell/ii/qs/services/SystemInfo.qml b/configs/quickshell/ii/qs/services/SystemInfo.qml new file mode 100644 index 0000000..a8da8e1 --- /dev/null +++ b/configs/quickshell/ii/qs/services/SystemInfo.qml @@ -0,0 +1,114 @@ +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 "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; + 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/configs/quickshell/ii/qs/services/Todo.qml b/configs/quickshell/ii/qs/services/Todo.qml new file mode 100644 index 0000000..93227cb --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/services/Weather.qml b/configs/quickshell/ii/qs/services/Weather.qml new file mode 100644 index 0000000..c8d46e2 --- /dev/null +++ b/configs/quickshell/ii/qs/services/Weather.qml @@ -0,0 +1,154 @@ +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 + + 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 + }) + + 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 = ""; + 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.temp += " (" + (data?.current?.FeelsLikeF || 0) + ") "; + temp.temp += "\u{02109}"; + } 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.temp += " (" + (data?.current?.FeelsLikeC || 0) + ") "; + temp.temp += "\u{02103}"; + } + 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/configs/quickshell/ii/qs/services/Ydotool.qml b/configs/quickshell/ii/qs/services/Ydotool.qml new file mode 100644 index 0000000..f25b093 --- /dev/null +++ b/configs/quickshell/ii/qs/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/configs/quickshell/ii/qs/services/ai/AiMessageData.qml b/configs/quickshell/ii/qs/services/ai/AiMessageData.qml new file mode 100644 index 0000000..023458d --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/AiMessageData.qml @@ -0,0 +1,21 @@ +import QtQuick; + +/** + * Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure. + */ +QtObject { + property string role + property string content + property string rawContent + property string model + property bool thinking: true + property bool done: false + property var annotations: [] + property var annotationSources: [] + property list searchQueries: [] + property string functionName + property var functionCall + property string functionResponse + property bool functionPending: false + property bool visibleToUser: true +} diff --git a/configs/quickshell/ii/qs/services/ai/AiModel.qml b/configs/quickshell/ii/qs/services/ai/AiModel.qml new file mode 100644 index 0000000..7cf9852 --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/AiModel.qml @@ -0,0 +1,32 @@ +import QtQuick; + +/** + * An AI model representation. + * - name: Friendly name of the model + * - icon: Icon name of the model + * - description: Description of the model + * - endpoint: Endpoint of the model + * - model: Model code (like gpt-4.1 or gemini-2.5-flash) + * - requires_key: Whether the model requires an API key + * - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + * - key_get_link: Link to get an API key + * - key_get_description: Description of pricing and how to get an API key + * - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + * - extraParams: Extra parameters to be passed to the model. This is a JSON object. + */ + +QtObject { + property string name + property string icon + property string description + property string homepage + property string endpoint + property string model + property bool requires_key: true + property string key_id + property string key_get_link + property string key_get_description + property string api_format: "openai" + property var tools + property var extraParams: ({}) +} diff --git a/configs/quickshell/ii/qs/services/ai/ApiStrategy.qml b/configs/quickshell/ii/qs/services/ai/ApiStrategy.qml new file mode 100644 index 0000000..75736d6 --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/ApiStrategy.qml @@ -0,0 +1,10 @@ +import QtQuick + +QtObject { + function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") } + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { throw new Error("Not implemented") } + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") } + function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") } + function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling + function reset() { } // Reset any internal state if needed +} diff --git a/configs/quickshell/ii/qs/services/ai/GeminiApiStrategy.qml b/configs/quickshell/ii/qs/services/ai/GeminiApiStrategy.qml new file mode 100644 index 0000000..12c775c --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/GeminiApiStrategy.qml @@ -0,0 +1,155 @@ +import QtQuick + +ApiStrategy { + property string buffer: "" + + function buildEndpoint(model: AiModel): string { + const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}` + // console.log("[AI] Endpoint: " + result); + return result; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "contents": messages.map(message => { + const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role; + const usingSearch = tools[0]?.google_search !== undefined + if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionCall: { + "name": message.functionName, + } + }] + } + } + if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionResponse: { + "name": message.functionName, + "response": { "content": message.functionResponse } + } + }] + } + } + return { + "role": geminiApiRoleName, + "parts": [{ + text: message.rawContent, + }] + } + }), + "tools": tools, + "system_instruction": { + "parts": [{ text: systemPrompt }] + }, + "generationConfig": { + "temperature": temperature, + }, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + // Gemini doesn't use Authorization header, key is in URL + return ""; + } + + function parseResponseLine(line, message) { + if (line.startsWith("[")) { + buffer += line.slice(1).trim(); + } else if (line === "]") { + buffer += line.slice(0, -1).trim(); + return parseBuffer(message); + } else if (line.startsWith(",")) { + return parseBuffer(message); + } else { + buffer += line.trim(); + } + return {}; + } + + function parseBuffer(message) { + // console.log("[Ai] Gemini buffer: ", buffer); + let finished = false; + try { + if (buffer.length === 0) return {}; + const dataJson = JSON.parse(buffer); + if (!dataJson.candidates) return {}; + + if (dataJson.candidates[0]?.finishReason) { + finished = true; + } + + // Function call handling + if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) { + const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall; + message.functionName = functionCall.name; + message.functionCall = functionCall.name; + const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n` + message.rawContent += newContent; + message.content += newContent; + return { functionCall: { name: functionCall.name, args: functionCall.args }, finished: finished }; + } + + // Normal text response + const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text + message.rawContent += responseContent; + message.content += responseContent; + + // Handle annotations and metadata + const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => { + return { + "type": "url_citation", + "text": chunk?.web?.title, + "url": chunk?.web?.uri, + } + }) ?? []; + + const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => { + return { + "type": "url_citation", + "start_index": citation.segment?.startIndex, + "end_index": citation.segment?.endIndex, + "text": citation?.segment.text, + "url": annotationSources[citation.groundingChunkIndices[0]]?.url, + "sources": citation.groundingChunkIndices + } + }); + message.annotationSources = annotationSources; + message.annotations = annotations; + message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? []; + + // Usage metadata + if (dataJson.usageMetadata) { + return { + tokenUsage: { + input: dataJson.usageMetadata.promptTokenCount ?? -1, + output: dataJson.usageMetadata.candidatesTokenCount ?? -1, + total: dataJson.usageMetadata.totalTokenCount ?? -1 + }, + finished: finished + }; + } + + } catch (e) { + console.log("[AI] Gemini: Could not parse buffer: ", e); + message.rawContent += buffer; + message.content += buffer; + } finally { + buffer = ""; + } + return { finished: finished }; + } + + function onRequestFinished(message) { + return parseBuffer(message); + } + + function reset() { + buffer = ""; + } +} diff --git a/configs/quickshell/ii/qs/services/ai/MistralApiStrategy.qml b/configs/quickshell/ii/qs/services/ai/MistralApiStrategy.qml new file mode 100644 index 0000000..dfcb950 --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/MistralApiStrategy.qml @@ -0,0 +1,124 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0 + let messageData = { + "role": message.role, + "content": message.rawContent, + } + if (hasFunctionCall) { + if (message.functionResponse?.length > 0) { + messageData.name = message.functionName; // Does the func call also need this name? or just the func output? + messageData.role = "tool"; + messageData.content = message.functionResponse; + messageData.tool_call_id = message.functionCall.id + } + } + return messageData + }), + ], + "stream": true, + "temperature": temperature, + "tools": tools, + }; + // console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2)); + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + // Function call + if (dataJson.choices[0]?.delta?.tool_calls) { + const functionCall = dataJson.choices[0].delta.tool_calls[0]; + const functionName = functionCall.function.name; + const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string??? + const functionId = functionCall.id; + const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`; + message.rawContent += newContent; + message.content += newContent; + message.functionName = functionName; + message.functionCall = functionName; + return { functionCall: { name: functionName, args: functionArgs, id: functionId } }; + } + + // Thinking? + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n
\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + // Text + message.content += newContent; + message.rawContent += newContent; + + if (`dataJson`.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] Mistral: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/configs/quickshell/ii/qs/services/ai/OpenAiApiStrategy.qml b/configs/quickshell/ii/qs/services/ai/OpenAiApiStrategy.qml new file mode 100644 index 0000000..a5792ac --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/OpenAiApiStrategy.qml @@ -0,0 +1,97 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + return { + "role": message.role, + "content": message.rawContent, + } + }), + ], + "stream": true, + "temperature": temperature, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + message.content += newContent; + message.rawContent += newContent; + + if (dataJson.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] OpenAI: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + // OpenAI format doesn't need special finish handling + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/configs/quickshell/ii/qs/services/ai/qmldir b/configs/quickshell/ii/qs/services/ai/qmldir new file mode 100644 index 0000000..09e1a26 --- /dev/null +++ b/configs/quickshell/ii/qs/services/ai/qmldir @@ -0,0 +1,8 @@ +module qs.services.ai + +AiMessageData 1.0 AiMessageData.qml +AiModel 1.0 AiModel.qml +ApiStrategy 1.0 ApiStrategy.qml +GeminiApiStrategy 1.0 GeminiApiStrategy.qml +MistralApiStrategy 1.0 MistralApiStrategy.qml +OpenAiApiStrategy 1.0 OpenAiApiStrategy.qml diff --git a/configs/quickshell/ii/qs/services/qmldir b/configs/quickshell/ii/qs/services/qmldir new file mode 100644 index 0000000..217cd82 --- /dev/null +++ b/configs/quickshell/ii/qs/services/qmldir @@ -0,0 +1,43 @@ +module qs.services + +singleton Ai 1.0 Ai.qml +singleton AppSearch 1.0 AppSearch.qml +singleton Audio 1.0 Audio.qml +singleton Battery 1.0 Battery.qml +singleton Bluetooth 1.0 Bluetooth.qml +singleton Booru 1.0 Booru.qml +BooruResponseData 1.0 BooruResponseData.qml +singleton Brightness 1.0 Brightness.qml +singleton Cliphist 1.0 Cliphist.qml +singleton DateTime 1.0 DateTime.qml +singleton Emojis 1.0 Emojis.qml +singleton FirstRunExperience 1.0 FirstRunExperience.qml +singleton HyprlandData 1.0 HyprlandData.qml +singleton HyprlandKeybinds 1.0 HyprlandKeybinds.qml +singleton HyprlandXkb 1.0 HyprlandXkb.qml +singleton Hyprsunset 1.0 Hyprsunset.qml +singleton KeyringStorage 1.0 KeyringStorage.qml +singleton LatexRenderer 1.0 LatexRenderer.qml +singleton MaterialThemeLoader 1.0 MaterialThemeLoader.qml +singleton MprisController 1.0 MprisController.qml +singleton Network 1.0 Network.qml +singleton Notifications 1.0 Notifications.qml +singleton ResourceUsage 1.0 ResourceUsage.qml +singleton SystemInfo 1.0 SystemInfo.qml +singleton Todo 1.0 Todo.qml +singleton Weather 1.0 Weather.qml +singleton Ydotool 1.0 Ydotool.qml +singleton HyprlandKeybinds 1.0 HyprlandKeybinds.qml +singleton HyprlandXkb 1.0 HyprlandXkb.qml +singleton Hyprsunset 1.0 Hyprsunset.qml +singleton KeyringStorage 1.0 KeyringStorage.qml +singleton LatexRenderer 1.0 LatexRenderer.qml +singleton MaterialThemeLoader 1.0 MaterialThemeLoader.qml +singleton MprisController 1.0 MprisController.qml +singleton Network 1.0 Network.qml +singleton Notifications 1.0 Notifications.qml +singleton ResourceUsage 1.0 ResourceUsage.qml +singleton SystemInfo 1.0 SystemInfo.qml +singleton Todo 1.0 Todo.qml +singleton Weather 1.0 Weather.qml +singleton Ydotool 1.0 Ydotool.qml diff --git a/configs/quickshell/ii/screenshot.qml b/configs/quickshell/ii/screenshot.qml new file mode 100644 index 0000000..7cd46bc --- /dev/null +++ b/configs/quickshell/ii/screenshot.qml @@ -0,0 +1,553 @@ +//@ 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 it smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +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.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +ShellRoot { + id: root + property string screenshotDir: Directories.screenshotTemp + property color overlayColor: "#77111111" + property color genericContentColor: Qt.alpha(root.overlayColor, 0.9) + property color genericContentForeground: "#ddffffff" + property color selectionBorderColor: "#ddf1f1f1" + property color selectionFillColor: "#33ffffff" + property color windowBorderColor: "#dda0c0da" + property color windowFillColor: "#22a0c0da" + property color imageBorderColor: "#ddf1d1ff" + property color imageFillColor: "#33f1d1ff" + property color onBorderColor: "#ff000000" + property real standardRounding: 4 + readonly property var windows: HyprlandData.windowList + readonly property var layers: HyprlandData.layers + readonly property real falsePositivePreventionRatio: 0.5 + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme(); + } + + component TargetRegion: Rectangle { + id: regionRect + 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 ? 3 : 1 + radius: root.standardRounding + + Rectangle { + id: regionLabelBackground + property real verticalPadding: 5 + property real horizontalPadding: 10 + radius: 10 + color: root.genericContentColor + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + anchors { + top: parent.top + left: parent.left + topMargin: regionRect.textPadding + leftMargin: regionRect.textPadding + } + implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2 + implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2 + RowLayout { + id: regionInfoRow + anchors.centerIn: parent + spacing: 8 + + Loader { + id: regionIconLoader + active: regionRect.showIcon + visible: active + sourceComponent: IconImage { + implicitSize: Appearance.font.pixelSize.larger + source: Quickshell.iconPath(AppSearch.guessIcon(regionRect.text), "image-missing") + } + } + + StyledText { + id: regionText + text: regionRect.text + color: root.genericContentForeground + } + } + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: panelWindow + required property var modelData + readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(modelData) + 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-${modelData.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 var mouseButton: null + property var imageRegions: [] + readonly property list windowRegions: filterWindowRegionsByLayers( + root.windows.filter(w => w.workspace.id === panelWindow.activeWorkspaceId), + panelWindow.layerRegions + ).map(window => { + return { + at: [window.at[0] - panelWindow.monitorOffsetX, window.at[1] - panelWindow.monitorOffsetY], + size: [window.size[0], window.size[1]], + class: window.class, + title: window.title, + } + }) + readonly property list layerRegions: { + const layersOfThisMonitor = root.layers[panelWindow.hyprlandMonitor.name] + const topLayers = layersOfThisMonitor.levels["2"] + const nonBarTopLayers = topLayers + .filter(layer => !(layer.namespace.includes(":bar") || 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] - panelWindow.monitorOffsetX, layer.at[1] - panelWindow.monitorOffsetY], + size: layer.size, + namespace: layer.namespace, + } + }); + return offsetAdjustedLayers; + } + + property real targetedRegionX: -1 + property real targetedRegionY: -1 + property real targetedRegionWidth: 0 + property real targetedRegionHeight: 0 + + 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); + } + + function updateTargetedRegion(x, y) { + // Image regions + const clickedRegion = panelWindow.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) { + panelWindow.targetedRegionX = clickedRegion.at[0]; + panelWindow.targetedRegionY = clickedRegion.at[1]; + panelWindow.targetedRegionWidth = clickedRegion.size[0]; + panelWindow.targetedRegionHeight = clickedRegion.size[1]; + return; + } + + // Layer regions + const clickedLayer = panelWindow.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) { + panelWindow.targetedRegionX = clickedLayer.at[0]; + panelWindow.targetedRegionY = clickedLayer.at[1]; + panelWindow.targetedRegionWidth = clickedLayer.size[0]; + panelWindow.targetedRegionHeight = clickedLayer.size[1]; + return; + } + + // Window regions + const clickedWindow = panelWindow.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) { + panelWindow.targetedRegionX = clickedWindow.at[0]; + panelWindow.targetedRegionY = clickedWindow.at[1]; + panelWindow.targetedRegionWidth = clickedWindow.size[0]; + panelWindow.targetedRegionHeight = clickedWindow.size[1]; + return; + } + + panelWindow.targetedRegionX = -1; + panelWindow.targetedRegionY = -1; + panelWindow.targetedRegionWidth = 0; + panelWindow.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) + + visible: false + screen: modelData + WlrLayershell.namespace: "quickshell:screenshot" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + anchors { + left: true + right: true + top: true + bottom: true + } + + Process { + id: screenshotProcess + running: true + command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(modelData.name)}' '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}'`] + onExited: (exitCode, exitStatus) => { + panelWindow.visible = true; + imageDetectionProcess.running = true; + } + } + + Process { + id: imageDetectionProcess + command: ["bash", "-c", `${Directories.scriptPath}/images/find_regions.py ` + + `--hyprctl ` + + `--image '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}' ` + + `--max-width ${Math.round(panelWindow.screen.width * root.falsePositivePreventionRatio)} ` + + `--max-height ${Math.round(panelWindow.screen.height * root.falsePositivePreventionRatio)} `] + stdout: StdioCollector { + id: imageDimensionCollector + onStreamFinished: { + imageRegions = filterImageRegions( + JSON.parse(imageDimensionCollector.text), + panelWindow.windowRegions + ); + } + } + } + + Process { + id: snipProc + function snip() { + if (panelWindow.regionWidth <= 0 || panelWindow.regionHeight <= 0) { + console.warn("Invalid region size, skipping snip."); + Qt.quit(); + } + snipProc.startDetached(); + Qt.quit(); + } + command: ["bash", "-c", + `magick ${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)} ` + + `-crop ${panelWindow.regionWidth * panelWindow.monitorScale}x${panelWindow.regionHeight * panelWindow.monitorScale}+${panelWindow.regionX * panelWindow.monitorScale}+${panelWindow.regionY * panelWindow.monitorScale} - ` + + `| ${panelWindow.mouseButton === Qt.LeftButton ? "wl-copy" : "swappy -f -"}`] + } + + ScreencopyView { + anchors.fill: parent + live: false + captureSource: modelData + + focus: panelWindow.visible + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + Qt.quit(); + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.CrossCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + + // Controls + onPressed: mouse => { + panelWindow.dragStartX = mouse.x; + panelWindow.dragStartY = mouse.y; + panelWindow.draggingX = mouse.x; + panelWindow.draggingY = mouse.y; + panelWindow.dragging = true; + panelWindow.mouseButton = mouse.button; + } + onReleased: mouse => { + // Detect if it was a click + + // Image regions + if (panelWindow.draggingX === panelWindow.dragStartX && panelWindow.draggingY === panelWindow.dragStartY) { + if (panelWindow.targetedRegionX >= 0 && panelWindow.targetedRegionY >= 0) { + panelWindow.regionX = panelWindow.targetedRegionX; + panelWindow.regionY = panelWindow.targetedRegionY; + panelWindow.regionWidth = panelWindow.targetedRegionWidth; + panelWindow.regionHeight = panelWindow.targetedRegionHeight; + } + } + snipProc.snip(); + } + onPositionChanged: mouse => { + if (panelWindow.dragging) { + panelWindow.draggingX = mouse.x; + panelWindow.draggingY = mouse.y; + panelWindow.dragDiffX = mouse.x - panelWindow.dragStartX; + panelWindow.dragDiffY = mouse.y - panelWindow.dragStartY; + } + panelWindow.updateTargetedRegion(mouse.x, mouse.y); + } + + // Overlay to darken screen + Rectangle { // Base + id: overlayRect + z: 0 + anchors.fill: parent + color: root.overlayColor + layer.enabled: true + } + Rectangle { + // TODO: Make this mask the base instead of just overlaying a border + z: 1 + anchors { + left: parent.left + top: parent.top + leftMargin: panelWindow.regionX + topMargin: panelWindow.regionY + } + width: panelWindow.regionWidth + height: panelWindow.regionHeight + color: "transparent" + border.color: root.selectionBorderColor + border.width: 2 + radius: root.standardRounding + } + + // Instructions + Rectangle { + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: (Appearance.sizes.barHeight - implicitHeight) / 2 + } + + opacity: panelWindow.dragging ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + color: root.genericContentColor + radius: 10 + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + implicitWidth: instructionsRow.implicitWidth + 10 * 2 + implicitHeight: instructionsRow.implicitHeight + 5 * 2 + + RowLayout { + id: instructionsRow + anchors.centerIn: parent + Item { + Layout.fillHeight: true + implicitWidth: screenshotRegionIcon.implicitWidth + MaterialSymbol { + id: screenshotRegionIcon + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + text: "screenshot_region" + color: root.genericContentForeground + } + } + StyledText { + text: Translation.tr("Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit") + color: root.genericContentForeground + } + } + } + + // Window regions + Repeater { + model: ScriptModel { + values: panelWindow.windowRegions + } + delegate: TargetRegion { + z: 2 + required property var modelData + showIcon: true + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: `${modelData.class}` + radius: Appearance.rounding.windowRounding + } + } + + // Layer regions + Repeater { + model: ScriptModel { + values: panelWindow.layerRegions + } + delegate: TargetRegion { + z: 3 + required property var modelData + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: `${modelData.namespace}` + radius: Appearance.rounding.windowRounding + } + } + + // Image regions + Repeater { + model: ScriptModel { + values: Config.options.screenshotTool.showContentRegions ? panelWindow.imageRegions : [] + } + delegate: TargetRegion { + z: 4 + required property var modelData + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.imageBorderColor + fillColor: targeted ? root.imageFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: "Content region" + } + } + } + } + } + } +} diff --git a/configs/quickshell/ii/scripts/ai/show-installed-ollama-models.sh b/configs/quickshell/ii/scripts/ai/show-installed-ollama-models.sh new file mode 100755 index 0000000..e56ac76 --- /dev/null +++ b/configs/quickshell/ii/scripts/ai/show-installed-ollama-models.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Get the list, skip the header, and extract the first column (model names) +model_names=$(ollama list | tail -n +2 | awk '{print $1}') + +# Build a JSON array +json_array="[" +for name in $model_names; do + json_array+="\"$name\"," +done + +# Remove trailing comma and close the array +json_array="${json_array%,}]" + +# Output the JSON array +echo "$json_array" diff --git a/configs/quickshell/ii/scripts/cava/raw_output_config.txt b/configs/quickshell/ii/scripts/cava/raw_output_config.txt new file mode 100644 index 0000000..7760e4e --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/scripts/colors/applycolor.sh b/configs/quickshell/ii/scripts/colors/applycolor.sh new file mode 100755 index 0000000..ddb93bd --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/applycolor.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +term_alpha=100 #Set this to < 100 make all your terminals transparent +# sleep 0 # idk i wanted some delay or colors dont get applied properly +if [ ! -d "$STATE_DIR"/user/generated ]; then + mkdir -p "$STATE_DIR"/user/generated +fi +cd "$CONFIG_DIR" || exit + +colornames='' +colorstrings='' +colorlist=() +colorvalues=() + +colornames=$(cat $STATE_DIR/user/generated/material_colors.scss | cut -d: -f1) +colorstrings=$(cat $STATE_DIR/user/generated/material_colors.scss | cut -d: -f2 | cut -d ' ' -f2 | cut -d ";" -f1) +IFS=$'\n' +colorlist=($colornames) # Array of color names +colorvalues=($colorstrings) # Array of color values + +apply_term() { + # Check if terminal escape sequence template exists + if [ ! -f "$SCRIPT_DIR/terminal/sequences.txt" ]; then + echo "Template file not found for Terminal. Skipping that." + return + fi + # Copy template + mkdir -p "$STATE_DIR"/user/generated/terminal + cp "$SCRIPT_DIR/terminal/sequences.txt" "$STATE_DIR"/user/generated/terminal/sequences.txt + # Apply colors + for i in "${!colorlist[@]}"; do + sed -i "s/${colorlist[$i]} #/${colorvalues[$i]#\#}/g" "$STATE_DIR"/user/generated/terminal/sequences.txt + done + + sed -i "s/\$alpha/$term_alpha/g" "$STATE_DIR/user/generated/terminal/sequences.txt" + + for file in /dev/pts/*; do + if [[ $file =~ ^/dev/pts/[0-9]+$ ]]; then + { + cat "$STATE_DIR"/user/generated/terminal/sequences.txt >"$file" + } & disown || true + fi + done +} + +apply_qt() { + sh "$CONFIG_DIR/scripts/kvantum/materialQT.sh" # generate kvantum theme + python "$CONFIG_DIR/scripts/kvantum/changeAdwColors.py" # apply config colors +} + +# Check if terminal theming is enabled in config +CONFIG_FILE="$XDG_CONFIG_HOME/illogical-impulse/config.json" +if [ -f "$CONFIG_FILE" ]; then + enable_terminal=$(jq -r '.appearance.wallpaperTheming.enableTerminal' "$CONFIG_FILE") + if [ "$enable_terminal" = "true" ]; then + apply_term & + fi +else + echo "Config file not found at $CONFIG_FILE. Applying terminal theming by default." + apply_term & +fi + +# apply_qt & # Qt theming is already handled by kde-material-colors diff --git a/configs/quickshell/ii/scripts/colors/generate_colors_material.py b/configs/quickshell/ii/scripts/colors/generate_colors_material.py new file mode 100755 index 0000000..fbfd4d5 --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/generate_colors_material.py @@ -0,0 +1,187 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" +import argparse +import math +import json +import os +from PIL import Image +from materialyoucolor.quantize import QuantizeCelebi +from materialyoucolor.score.score import Score +from materialyoucolor.hct import Hct +from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors +from materialyoucolor.utils.color_utils import (rgba_from_argb, argb_from_rgb, argb_from_rgba) +from materialyoucolor.utils.math_utils import (sanitize_degrees_double, difference_degrees, rotation_direction) + +parser = argparse.ArgumentParser(description='Color generation script') +parser.add_argument('--path', type=str, default=None, help='generate colorscheme from image') +parser.add_argument('--size', type=int , default=128 , help='bitmap image size') +parser.add_argument('--color', type=str, default=None, help='generate colorscheme from color') +parser.add_argument('--mode', type=str, choices=['dark', 'light'], default='dark', help='dark or light mode') +parser.add_argument('--scheme', type=str, default='vibrant', help='material scheme to use') +parser.add_argument('--smart', action='store_true', default=False, help='decide scheme type based on image color') +parser.add_argument('--transparency', type=str, choices=['opaque', 'transparent'], default='opaque', help='enable transparency') +parser.add_argument('--termscheme', type=str, default=None, help='JSON file containg the terminal scheme for generating term colors') +parser.add_argument('--harmony', type=float , default=0.8, help='(0-1) Color hue shift towards accent') +parser.add_argument('--harmonize_threshold', type=float , default=100, help='(0-180) Max threshold angle to limit color hue shift') +parser.add_argument('--term_fg_boost', type=float , default=0.35, help='Make terminal foreground more different from the background') +parser.add_argument('--blend_bg_fg', action='store_true', default=False, help='Shift terminal background or foreground towards accent') +parser.add_argument('--cache', type=str, default=None, help='file path to store the generated color') +parser.add_argument('--debug', action='store_true', default=False, help='debug mode') +args = parser.parse_args() + +rgba_to_hex = lambda rgba: "#{:02X}{:02X}{:02X}".format(rgba[0], rgba[1], rgba[2]) +argb_to_hex = lambda argb: "#{:02X}{:02X}{:02X}".format(*map(round, rgba_from_argb(argb))) +hex_to_argb = lambda hex_code: argb_from_rgb(int(hex_code[1:3], 16), int(hex_code[3:5], 16), int(hex_code[5:], 16)) +display_color = lambda rgba : "\x1B[38;2;{};{};{}m{}\x1B[0m".format(rgba[0], rgba[1], rgba[2], "\x1b[7m \x1b[7m") + +def calculate_optimal_size (width: int, height: int, bitmap_size: int) -> (int, int): + image_area = width * height; + bitmap_area = bitmap_size ** 2 + scale = math.sqrt(bitmap_area/image_area) if image_area > bitmap_area else 1 + new_width = round(width * scale) + new_height = round(height * scale) + if new_width == 0: + new_width = 1 + if new_height == 0: + new_height = 1 + return new_width, new_height + +def harmonize (design_color: int, source_color: int, threshold: float = 35, harmony: float = 0.5) -> int: + from_hct = Hct.from_int(design_color) + to_hct = Hct.from_int(source_color) + difference_degrees_ = difference_degrees(from_hct.hue, to_hct.hue) + rotation_degrees = min(difference_degrees_ * harmony, threshold) + output_hue = sanitize_degrees_double( + from_hct.hue + rotation_degrees * rotation_direction(from_hct.hue, to_hct.hue) + ) + return Hct.from_hct(output_hue, from_hct.chroma, from_hct.tone).to_int() + +def boost_chroma_tone (argb: int, chroma: float = 1, tone: float = 1) -> int: + hct = Hct.from_int(argb) + return Hct.from_hct(hct.hue, hct.chroma * chroma, hct.tone * tone).to_int() + +darkmode = (args.mode == 'dark') +transparent = (args.transparency == 'transparent') + +if args.path is not None: + image = Image.open(args.path) + + if image.format == "GIF": + image.seek(1) + + if image.mode in ["L", "P"]: + image = image.convert('RGB') + wsize, hsize = image.size + wsize_new, hsize_new = calculate_optimal_size(wsize, hsize, args.size) + if wsize_new < wsize or hsize_new < hsize: + image = image.resize((wsize_new, hsize_new), Image.Resampling.BICUBIC) + colors = QuantizeCelebi(list(image.getdata()), 128) + argb = Score.score(colors)[0] + + if args.cache is not None: + with open(args.cache, 'w') as file: + file.write(argb_to_hex(argb)) + hct = Hct.from_int(argb) + if(args.smart): + if(hct.chroma < 20): + args.scheme = 'neutral' +elif args.color is not None: + argb = hex_to_argb(args.color) + hct = Hct.from_int(argb) +elif args.cache is not None and os.path.exists(args.cache): + with open(args.cache, 'r') as file: + cached_color = file.read().strip() + argb = hex_to_argb(cached_color) + hct = Hct.from_int(argb) + +if args.scheme == 'scheme-fruit-salad': + from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad as Scheme +elif args.scheme == 'scheme-expressive': + from materialyoucolor.scheme.scheme_expressive import SchemeExpressive as Scheme +elif args.scheme == 'scheme-monochrome': + from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome as Scheme +elif args.scheme == 'scheme-rainbow': + from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow as Scheme +elif args.scheme == 'scheme-tonal-spot': + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme +elif args.scheme == 'scheme-neutral': + from materialyoucolor.scheme.scheme_neutral import SchemeNeutral as Scheme +elif args.scheme == 'scheme-fidelity': + from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity as Scheme +elif args.scheme == 'scheme-content': + from materialyoucolor.scheme.scheme_content import SchemeContent as Scheme +elif args.scheme == 'scheme-vibrant': + from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant as Scheme +else: + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme +# Generate +scheme = Scheme(hct, darkmode, 0.0) + +material_colors = {} +term_colors = {} + +for color in vars(MaterialDynamicColors).keys(): + color_name = getattr(MaterialDynamicColors, color) + if hasattr(color_name, "get_hct"): + rgba = color_name.get_hct(scheme).to_rgba() + material_colors[color] = rgba_to_hex(rgba) + +# Extended material +if darkmode == True: + material_colors['success'] = '#B5CCBA' + material_colors['onSuccess'] = '#213528' + material_colors['successContainer'] = '#374B3E' + material_colors['onSuccessContainer'] = '#D1E9D6' +else: + material_colors['success'] = '#4F6354' + material_colors['onSuccess'] = '#FFFFFF' + material_colors['successContainer'] = '#D1E8D5' + material_colors['onSuccessContainer'] = '#0C1F13' + +# Terminal Colors +if args.termscheme is not None: + with open(args.termscheme, 'r') as f: + json_termscheme = f.read() + term_source_colors = json.loads(json_termscheme)['dark' if darkmode else 'light'] + + primary_color_argb = hex_to_argb(material_colors['primary_paletteKeyColor']) + for color, val in term_source_colors.items(): + if(args.scheme == 'monochrome') : + term_colors[color] = val + continue + if args.blend_bg_fg and color == "term0": + harmonized = boost_chroma_tone(hex_to_argb(material_colors['surfaceContainerLow']), 1.2, 0.95) + elif args.blend_bg_fg and color == "term15": + harmonized = boost_chroma_tone(hex_to_argb(material_colors['onSurface']), 3, 1) + else: + harmonized = harmonize(hex_to_argb(val), primary_color_argb, args.harmonize_threshold, args.harmony) + harmonized = boost_chroma_tone(harmonized, 1, 1 + (args.term_fg_boost * (1 if darkmode else -1))) + term_colors[color] = argb_to_hex(harmonized) + +if args.debug == False: + print(f"$darkmode: {darkmode};") + print(f"$transparent: {transparent};") + for color, code in material_colors.items(): + print(f"${color}: {code};") + for color, code in term_colors.items(): + print(f"${color}: {code};") +else: + if args.path is not None: + print('\n--------------Image properties-----------------') + print(f"Image size: {wsize} x {hsize}") + print(f"Resized image: {wsize_new} x {hsize_new}") + print('\n---------------Selected color------------------') + print(f"Dark mode: {darkmode}") + print(f"Scheme: {args.scheme}") + print(f"Accent color: {display_color(rgba_from_argb(argb))} {argb_to_hex(argb)}") + print(f"HCT: {hct.hue:.2f} {hct.chroma:.2f} {hct.tone:.2f}") + print('\n---------------Material colors-----------------') + for color, code in material_colors.items(): + rgba = rgba_from_argb(hex_to_argb(code)) + print(f"{color.ljust(32)} : {display_color(rgba)} {code}") + print('\n----------Harmonize terminal colors------------') + for color, code in term_colors.items(): + rgba = rgba_from_argb(hex_to_argb(code)) + code_source = term_source_colors[color] + rgba_source = rgba_from_argb(hex_to_argb(code_source)) + print(f"{color.ljust(6)} : {display_color(rgba_source)} {code_source} --> {display_color(rgba)} {code}") + print('-----------------------------------------------') diff --git a/configs/quickshell/ii/scripts/colors/random_konachan_wall.sh b/configs/quickshell/ii/scripts/colors/random_konachan_wall.sh new file mode 100755 index 0000000..a4685da --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/random_konachan_wall.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +get_pictures_dir() { + if command -v xdg-user-dir &> /dev/null; then + xdg-user-dir PICTURES + return + fi + + local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" + if [ -f "$config_file" ]; then + local pictures_path + pictures_path=$(source "$config_file" >/dev/null 2>&1; echo "$XDG_PICTURES_DIR") + echo "${pictures_path/#\$HOME/$HOME}" + return + fi + + echo "$HOME/Pictures" +} + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +PICTURES_DIR=$(get_pictures_dir) +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mkdir -p "$PICTURES_DIR/Wallpapers" +page=$((1 + RANDOM % 1000)); +response=$(curl "https://konachan.net/post.json?tags=rating%3Asafe&limit=1&page=$page") +link=$(echo "$response" | jq '.[0].file_url' -r); +ext=$(echo "$link" | awk -F. '{print $NF}') +downloadPath="$PICTURES_DIR/Wallpapers/konachan_random_image.$ext" +illogicalImpulseConfigPath="$HOME/.config/illogical-impulse/config.json" +currentWallpaperPath=$(jq -r '.background.wallpaperPath' $illogicalImpulseConfigPath) +if [ "$downloadPath" == "$currentWallpaperPath" ]; then + downloadPath="$PICTURES_DIR/Wallpapers/konachan_random_image-1.$ext" +fi +curl "$link" -o "$downloadPath" +"$SCRIPT_DIR/switchwall.sh" --image "$downloadPath" diff --git a/configs/quickshell/ii/scripts/colors/scheme_for_image.py b/configs/quickshell/ii/scripts/colors/scheme_for_image.py new file mode 100755 index 0000000..8aa0ccb --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/scheme_for_image.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import sys +import cv2 +import numpy as np + +# Allowed scheme types +SCHEMES = [ + "scheme-content", + "scheme-expressive", + "scheme-fidelity", + "scheme-fruit-salad", + "scheme-monochrome", + "scheme-neutral", + "scheme-rainbow", + "scheme-tonal-spot" +] + +def image_colorfulness(image): + # Based on Hasler and Sรผsstrunk's colorfulness metric + (B, G, R) = cv2.split(image.astype("float")) + rg = np.absolute(R - G) + yb = np.absolute(0.5 * (R + G) - B) + std_rg = np.std(rg) + std_yb = np.std(yb) + mean_rg = np.mean(rg) + mean_yb = np.mean(yb) + colorfulness = np.sqrt(std_rg ** 2 + std_yb ** 2) + (0.3 * np.sqrt(mean_rg ** 2 + mean_yb ** 2)) + return colorfulness + +# scheme-content respects the image's colors very well, but it might +# look too saturated, so we only use it for not very colorful images to be safe +def pick_scheme(colorfulness): + if colorfulness < 10: + # return "scheme-monochrome" + return "scheme-content" + elif colorfulness < 20: + return "scheme-content" + elif colorfulness < 50: + return "scheme-neutral" + else: + return "scheme-tonal-spot" + +def main(): + colorfulness_mode = False + args = sys.argv[1:] + if '--colorfulness' in args: + colorfulness_mode = True + args.remove('--colorfulness') + if len(args) < 1: + print("scheme-tonal-spot") + sys.exit(1) + img_path = args[0] + img = cv2.imread(img_path) + if img is None: + print("scheme-tonal-spot") + sys.exit(1) + colorfulness = image_colorfulness(img) + if colorfulness_mode: + print(f"{colorfulness}") + else: + scheme = pick_scheme(colorfulness) + print(scheme) + +if __name__ == "__main__": + main() diff --git a/configs/quickshell/ii/scripts/colors/switchwall-wrapper.sh b/configs/quickshell/ii/scripts/colors/switchwall-wrapper.sh new file mode 100755 index 0000000..7459942 --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/switchwall-wrapper.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Wrapper to set up environment for switchwall.sh + +# Source environment config +[ -f "$HOME/.config/quickshell/env.sh" ] && source "$HOME/.config/quickshell/env.sh" + +export ILLOGICAL_IMPULSE_VIRTUAL_ENV="${ILLOGICAL_IMPULSE_VIRTUAL_ENV:-$HOME/.local/state/quickshell/.venv}" + +echo "[wrapper] Called with args: $@" >> /tmp/switchwall-wrapper.log +echo "[wrapper] LD_LIBRARY_PATH: $LD_LIBRARY_PATH" >> /tmp/switchwall-wrapper.log + +# Run switchwall.sh with all arguments +exec "$(dirname "$0")/switchwall.sh" "$@" diff --git a/configs/quickshell/ii/scripts/colors/switchwall.sh b/configs/quickshell/ii/scripts/colors/switchwall.sh new file mode 100755 index 0000000..468d285 --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/switchwall.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash + +# Log execution +LOG="/tmp/switchwall.log" +echo "[$(date)] switchwall.sh started" >> "$LOG" +# Set default venv path if not already set +ILLOGICAL_IMPULSE_VIRTUAL_ENV="${ILLOGICAL_IMPULSE_VIRTUAL_ENV:-$HOME/.local/state/quickshell/.venv}" +echo "ILLOGICAL_IMPULSE_VIRTUAL_ENV=$ILLOGICAL_IMPULSE_VIRTUAL_ENV" >> "$LOG" + +# Ensure LD_LIBRARY_PATH includes libstdc++ for Python native modules +if [ -z "$LD_LIBRARY_PATH" ] || ! echo "$LD_LIBRARY_PATH" | grep -q "nix-ld"; then + NIX_LD_LIBS="/run/current-system/sw/share/nix-ld/lib" + if [ -d "$NIX_LD_LIBS" ]; then + export LD_LIBRARY_PATH="${NIX_LD_LIBS}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + else + export LD_LIBRARY_PATH="/run/current-system/sw/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + echo "Set LD_LIBRARY_PATH=$LD_LIBRARY_PATH" >> "$LOG" +fi + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHELL_CONFIG_FILE="$XDG_CONFIG_HOME/illogical-impulse/config.json" +MATUGEN_DIR="$XDG_CONFIG_HOME/matugen" +terminalscheme="$SCRIPT_DIR/terminal/scheme-base.json" + +handle_kde_material_you_colors() { + # Check if Qt app theming is enabled in config + if [ -f "$SHELL_CONFIG_FILE" ]; then + enable_qt_apps=$(jq -r '.appearance.wallpaperTheming.enableQtApps' "$SHELL_CONFIG_FILE") + if [ "$enable_qt_apps" == "false" ]; then + return + fi + fi + + # Map $type_flag to allowed scheme variants for kde-material-you-colors-wrapper.sh + local kde_scheme_variant="" + case "$type_flag" in + scheme-content|scheme-expressive|scheme-fidelity|scheme-fruit-salad|scheme-monochrome|scheme-neutral|scheme-rainbow|scheme-tonal-spot) + kde_scheme_variant="$type_flag" + ;; + *) + kde_scheme_variant="scheme-tonal-spot" # default + ;; + esac + "$XDG_CONFIG_HOME"/matugen/templates/kde/kde-material-you-colors-wrapper.sh --scheme-variant "$kde_scheme_variant" +} + +pre_process() { + local mode_flag="$1" + # Set GNOME color-scheme if mode_flag is dark or light + if [[ "$mode_flag" == "dark" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3-dark' + elif [[ "$mode_flag" == "light" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-light' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3' + fi + + if [ ! -d "$CACHE_DIR"/user/generated ]; then + mkdir -p "$CACHE_DIR"/user/generated + fi +} + +post_process() { + local screen_width="$1" + local screen_height="$2" + local wallpaper_path="$3" + + + handle_kde_material_you_colors & + + # Determine the largest region on the wallpaper that's sufficiently un-busy to put widgets in + # if [ ! -f "$MATUGEN_DIR/scripts/least_busy_region.py" ]; then + # echo "Error: least_busy_region.py script not found in $MATUGEN_DIR/scripts/" + # else + # "$MATUGEN_DIR/scripts/least_busy_region.py" \ + # --screen-width "$screen_width" --screen-height "$screen_height" \ + # --width 300 --height 200 \ + # "$wallpaper_path" > "$STATE_DIR"/user/generated/wallpaper/least_busy_region.json + # fi +} + +check_and_prompt_upscale() { + local img="$1" + min_width_desired="$(hyprctl monitors -j | jq '([.[].width] | max)' | xargs)" # max monitor width + min_height_desired="$(hyprctl monitors -j | jq '([.[].height] | max)' | xargs)" # max monitor height + + if command -v identify &>/dev/null && [ -f "$img" ]; then + local img_width img_height + if is_video "$img"; then # Not check resolution for videos, just let em pass + img_width=$min_width_desired + img_height=$min_height_desired + else + img_width=$(identify -format "%w" "$img" 2>/dev/null) + img_height=$(identify -format "%h" "$img" 2>/dev/null) + fi + if [[ "$img_width" -lt "$min_width_desired" || "$img_height" -lt "$min_height_desired" ]]; then + action=$(timeout 5 notify-send "Upscale?" \ + "Image resolution (${img_width}x${img_height}) is lower than screen resolution (${min_width_desired}x${min_height_desired})" \ + -A "open_upscayl=Open Upscayl"\ + -t 5000 \ + -a "Wallpaper switcher") + if [[ "$action" == "open_upscayl" ]]; then + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + else + action2=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_upscayl=Install Upscayl (Arch)" \ + "Install Upscayl?" \ + "yay -S upscayl-bin") + if [[ "$action2" == "install_upscayl" ]]; then + kitty -1 yay -S upscayl-bin + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + fi + fi + fi + fi + fi + fi +} + +CUSTOM_DIR="$XDG_CONFIG_HOME/hypr/custom" +RESTORE_SCRIPT_DIR="$CUSTOM_DIR/scripts" +RESTORE_SCRIPT="$RESTORE_SCRIPT_DIR/__restore_video_wallpaper.sh" +THUMBNAIL_DIR="$RESTORE_SCRIPT_DIR/mpvpaper_thumbnails" +VIDEO_OPTS="no-audio loop hwdec=auto scale=bilinear interpolation=no video-sync=display-resample panscan=1.0 video-scale-x=1.0 video-scale-y=1.0 video-align-x=0.5 video-align-y=0.5 load-scripts=no" + +is_video() { + local extension="${1##*.}" + [[ "$extension" == "mp4" || "$extension" == "webm" || "$extension" == "mkv" || "$extension" == "avi" || "$extension" == "mov" ]] && return 0 || return 1 +} + +kill_existing_mpvpaper() { + pkill -f -9 mpvpaper || true +} + +create_restore_script() { + local video_path=$1 + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# Generated by switchwall.sh - Don't modify it by yourself. +# Time: $(date) + +pkill -f -9 mpvpaper + +for monitor in \$(hyprctl monitors -j | jq -r '.[] | .name'); do + mpvpaper -o "$VIDEO_OPTS" "\$monitor" "$video_path" & + sleep 0.1 +done +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" + chmod +x "$RESTORE_SCRIPT" +} + +remove_restore() { + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# The content of this script will be generated by switchwall.sh - Don't modify it by yourself. +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" +} + +set_wallpaper_path() { + local path="$1" + if [ -f "$SHELL_CONFIG_FILE" ]; then + jq --arg path "$path" '.background.wallpaperPath = $path' "$SHELL_CONFIG_FILE" > "$SHELL_CONFIG_FILE.tmp" && mv "$SHELL_CONFIG_FILE.tmp" "$SHELL_CONFIG_FILE" + fi + # Apply wallpaper with swww + if [ -f "$path" ]; then + swww img "$path" 2>/dev/null + fi +} + +set_thumbnail_path() { + local path="$1" + if [ -f "$SHELL_CONFIG_FILE" ]; then + jq --arg path "$path" '.background.thumbnailPath = $path' "$SHELL_CONFIG_FILE" > "$SHELL_CONFIG_FILE.tmp" && mv "$SHELL_CONFIG_FILE.tmp" "$SHELL_CONFIG_FILE" + fi +} + +switch() { + imgpath="$1" + mode_flag="$2" + type_flag="$3" + color_flag="$4" + color="$5" + read scale screenx screeny screensizey < <(hyprctl monitors -j | jq '.[] | select(.focused) | .scale, .x, .y, .height' | xargs) + cursorposx=$(hyprctl cursorpos -j | jq '.x' 2>/dev/null) || cursorposx=960 + cursorposx=$(bc <<< "scale=0; ($cursorposx - $screenx) * $scale / 1") + cursorposy=$(hyprctl cursorpos -j | jq '.y' 2>/dev/null) || cursorposy=540 + cursorposy=$(bc <<< "scale=0; ($cursorposy - $screeny) * $scale / 1") + cursorposy_inverted=$((screensizey - cursorposy)) + + if [[ "$color_flag" == "1" ]]; then + matugen_args=(color hex "$color") + generate_colors_material_args=(--color "$color") + else + if [[ -z "$imgpath" ]]; then + echo 'Aborted' + exit 0 + fi + + # Only check upscale if not using --noswitch + if [[ -z "$noswitch_flag" ]]; then + check_and_prompt_upscale "$imgpath" & + fi + kill_existing_mpvpaper + + if is_video "$imgpath"; then + mkdir -p "$THUMBNAIL_DIR" + + missing_deps=() + if ! command -v mpvpaper &> /dev/null; then + missing_deps+=("mpvpaper") + fi + if ! command -v ffmpeg &> /dev/null; then + missing_deps+=("ffmpeg") + fi + if [ ${#missing_deps[@]} -gt 0 ]; then + echo "Missing deps: ${missing_deps[*]}" + echo "Arch: sudo pacman -S ${missing_deps[*]}" + action=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_arch=Install (Arch)" \ + "Can't switch to video wallpaper" \ + "Missing dependencies: ${missing_deps[*]}") + if [[ "$action" == "install_arch" ]]; then + kitty -1 sudo pacman -S "${missing_deps[*]}" + if command -v mpvpaper &>/dev/null && command -v ffmpeg &>/dev/null; then + notify-send 'Wallpaper switcher' 'Alright, try again!' -a "Wallpaper switcher" + fi + fi + exit 0 + fi + + # Set wallpaper path + set_wallpaper_path "$imgpath" + + # Set video wallpaper + local video_path="$imgpath" + monitors=$(hyprctl monitors -j | jq -r '.[] | .name') + for monitor in $monitors; do + mpvpaper -o "$VIDEO_OPTS" "$monitor" "$video_path" & + sleep 0.1 + done + + # Extract first frame for color generation + thumbnail="$THUMBNAIL_DIR/$(basename "$imgpath").jpg" + ffmpeg -y -i "$imgpath" -vframes 1 "$thumbnail" 2>/dev/null + + # Set thumbnail path + set_thumbnail_path "$thumbnail" + + if [ -f "$thumbnail" ]; then + matugen_args=(image "$thumbnail") + generate_colors_material_args=(--path "$thumbnail") + create_restore_script "$video_path" + else + echo "Cannot create image to colorgen" + remove_restore + exit 1 + fi + else + matugen_args=(image "$imgpath") + generate_colors_material_args=(--path "$imgpath") + # Update wallpaper path in config + set_wallpaper_path "$imgpath" + remove_restore + fi + fi + + # Determine mode if not set + if [[ -z "$mode_flag" ]]; then + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + mode_flag="dark" + else + mode_flag="light" + fi + fi + + [[ -n "$mode_flag" ]] && matugen_args+=(--mode "$mode_flag") && generate_colors_material_args+=(--mode "$mode_flag") + [[ -n "$type_flag" ]] && matugen_args+=(--type "$type_flag") && generate_colors_material_args+=(--scheme "$type_flag") + generate_colors_material_args+=(--termscheme "$terminalscheme" --blend_bg_fg) + generate_colors_material_args+=(--cache "$STATE_DIR/user/generated/color.txt") + + pre_process "$mode_flag" + + # Check if app and shell theming is enabled in config + if [ -f "$SHELL_CONFIG_FILE" ]; then + enable_apps_shell=$(jq -r '.appearance.wallpaperTheming.enableAppsAndShell' "$SHELL_CONFIG_FILE") + if [ "$enable_apps_shell" == "false" ]; then + echo "App and shell theming disabled, skipping matugen and color generation" + return + fi + fi + + matugen "${matugen_args[@]}" + echo "[$(date)] Running python script" >> "$LOG" + "$(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/python3" "$SCRIPT_DIR/generate_colors_material.py" "${generate_colors_material_args[@]}" \ + > "$STATE_DIR"/user/generated/material_colors.scss 2>> "$LOG" + echo "[$(date)] Python done, scss size: $(wc -l < "$STATE_DIR"/user/generated/material_colors.scss)" >> "$LOG" + + # Only convert to JSON if SCSS was generated successfully + if [ -s "$STATE_DIR"/user/generated/material_colors.scss ]; then + # Convert SCSS to JSON for quickshell MaterialThemeLoader + echo "[$(date)] Converting SCSS to JSON" >> "$LOG" + awk -F': ' '/^\$/ {gsub(/\$|;/, "", $0); print "\"" $1 "\": \"" $2 "\","}' \ + "$STATE_DIR"/user/generated/material_colors.scss | \ + sed '$ s/,$//' | \ + (echo "{"; cat; echo "}") > "$STATE_DIR"/user/generated/colors.json.tmp + mv "$STATE_DIR"/user/generated/colors.json.tmp "$STATE_DIR"/user/generated/colors.json + sync "$STATE_DIR"/user/generated/colors.json + echo "[$(date)] JSON created, size: $(wc -l < "$STATE_DIR"/user/generated/colors.json)" >> "$LOG" + else + echo "[$(date)] SCSS generation failed, skipping JSON creation" >> "$LOG" + fi + + "$XDG_CONFIG_HOME/quickshell/scripts/colors/applycolor.sh" + + # Wait for all file operations to complete + wait + sleep 1 + + # Trigger quickshell to reload theme via IPC (doesn't restart the process) + quickshell ipc -c ii call materialTheme reload 2>/dev/null || true + + # Pass screen width, height, and wallpaper path to post_process + max_width_desired="$(hyprctl monitors -j | jq '([.[].width] | min)' | xargs)" + max_height_desired="$(hyprctl monitors -j | jq '([.[].height] | min)' | xargs)" + post_process "$max_width_desired" "$max_height_desired" "$imgpath" +} + +main() { + imgpath="" + mode_flag="" + type_flag="" + color_flag="" + color="" + noswitch_flag="" + choose_flag="" + + get_type_from_config() { + jq -r '.appearance.palette.type' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "auto" + } + + detect_scheme_type_from_image() { + local img="$1" + "$SCRIPT_DIR"/scheme_for_image.py "$img" 2>/dev/null | tr -d '\n' + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + mode_flag="$2" + shift 2 + ;; + --type) + type_flag="$2" + shift 2 + ;; + --color) + color_flag="1" + if [[ "$2" =~ ^#?[A-Fa-f0-9]{6}$ ]]; then + color="$2" + shift 2 + else + color=$(hyprpicker --no-fancy) + shift + fi + ;; + --image) + imgpath="$2" + shift 2 + ;; + --noswitch) + noswitch_flag="1" + imgpath=$(jq -r '.background.wallpaperPath' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "") + shift + ;; + --choose) + choose_flag="1" + shift + ;; + *) + if [[ -z "$imgpath" ]]; then + imgpath="$1" + fi + shift + ;; + esac + done + + # If type_flag is not set, get it from config + if [[ -z "$type_flag" ]]; then + type_flag="$(get_type_from_config)" + fi + + # Validate type_flag (allow 'auto' as well) + allowed_types=(scheme-content scheme-expressive scheme-fidelity scheme-fruit-salad scheme-monochrome scheme-neutral scheme-rainbow scheme-tonal-spot auto) + valid_type=0 + for t in "${allowed_types[@]}"; do + if [[ "$type_flag" == "$t" ]]; then + valid_type=1 + break + fi + done + if [[ $valid_type -eq 0 ]]; then + echo "[switchwall.sh] Warning: Invalid type '$type_flag', defaulting to 'auto'" >&2 + type_flag="auto" + fi + + # Only prompt for wallpaper if not using --color and not using --noswitch and no imgpath set + if [[ -z "$imgpath" && -z "$color_flag" && -z "$noswitch_flag" ]]; then + # Try to pick a random wallpaper from Wallpapers directory + WALLPAPER_DIR="$(xdg-user-dir PICTURES)/Backgrounds" + if [[ -d "$WALLPAPER_DIR" ]] && [[ -z "$choose_flag" ]]; then + imgpath=$(find "$WALLPAPER_DIR" -type f \( -name "*.jpg" -o -name "*.png" \) 2>/dev/null | shuf -n 1) + fi + + # If --choose flag is set or still no wallpaper, prompt with kdialog + if [[ -n "$choose_flag" ]] || [[ -z "$imgpath" ]]; then + cd "$(xdg-user-dir PICTURES)/Wallpapers/showcase" 2>/dev/null || cd "$(xdg-user-dir PICTURES)/Wallpapers" 2>/dev/null || cd "$(xdg-user-dir PICTURES)" || return 1 + imgpath="$(kdialog --getopenfilename . --title 'Choose wallpaper')" + fi + fi + + # If type_flag is 'auto', detect scheme type from image (after imgpath is set) + if [[ "$type_flag" == "auto" ]]; then + if [[ -n "$imgpath" && -f "$imgpath" ]]; then + detected_type="$(detect_scheme_type_from_image "$imgpath")" + # Only use detected_type if it's valid + valid_detected=0 + for t in "${allowed_types[@]}"; do + if [[ "$detected_type" == "$t" && "$detected_type" != "auto" ]]; then + valid_detected=1 + break + fi + done + if [[ $valid_detected -eq 1 ]]; then + type_flag="$detected_type" + else + echo "[switchwall] Warning: Could not auto-detect a valid scheme, defaulting to 'scheme-tonal-spot'" >&2 + type_flag="scheme-tonal-spot" + fi + else + echo "[switchwall] Warning: No image to auto-detect scheme from, defaulting to 'scheme-tonal-spot'" >&2 + type_flag="scheme-tonal-spot" + fi + fi + + switch "$imgpath" "$mode_flag" "$type_flag" "$color_flag" "$color" +} + +main "$@" diff --git a/configs/quickshell/ii/scripts/colors/terminal/scheme-base.json b/configs/quickshell/ii/scripts/colors/terminal/scheme-base.json new file mode 100644 index 0000000..e4b78e7 --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/terminal/scheme-base.json @@ -0,0 +1,38 @@ +{ + "dark": { + "term0" : "#282828", + "term1" : "#CC241D", + "term2" : "#98971A", + "term3" : "#D79921", + "term4" : "#458588", + "term5" : "#B16286", + "term6" : "#689D6A", + "term7" : "#A89984", + "term8" : "#928374", + "term9" : "#FB4934", + "term10" : "#B8BB26", + "term11" : "#FABD2F", + "term12" : "#83A598", + "term13" : "#D3869B", + "term14" : "#8EC07C", + "term15" : "#EBDBB2" + }, + "light": { + "term0" : "#FDF9F3", + "term1" : "#FF6188", + "term2" : "#A9DC76", + "term3" : "#FC9867", + "term4" : "#FFD866", + "term5" : "#F47FD4", + "term6" : "#78DCE8", + "term7" : "#333034", + "term8" : "#121212", + "term9" : "#FF6188", + "term10" : "#A9DC76", + "term11" : "#FC9867", + "term12" : "#FFD866", + "term13" : "#F47FD4", + "term14" : "#78DCE8", + "term15" : "#333034" + } +} diff --git a/configs/quickshell/ii/scripts/colors/terminal/sequences.txt b/configs/quickshell/ii/scripts/colors/terminal/sequences.txt new file mode 100644 index 0000000..9745958 --- /dev/null +++ b/configs/quickshell/ii/scripts/colors/terminal/sequences.txt @@ -0,0 +1 @@ +]4;0;#$term0 #\]1;0;#$term0 #\]4;1;#$term1 #\]4;2;#$term2 #\]4;3;#$term3 #\]4;4;#$term4 #\]4;5;#$term5 #\]4;6;#$term6 #\]4;7;#$term7 #\]4;8;#$term8 #\]4;9;#$term9 #\]4;10;#$term10 #\]4;11;#$term11 #\]4;12;#$term12 #\]4;13;#$term13 #\]4;14;#$term14 #\]4;15;#$term15 #\]10;#$term7 #\]11;[100]#$term0 #\]12;#$term7 #\]13;#$term7 #\]17;#$term7 #\]19;#$term0 #\]4;232;#$term7 #\]4;256;#$term7 #\]708;[100]#$term0 #\]11;#$term0 #\ \ No newline at end of file diff --git a/configs/quickshell/ii/scripts/hyprland/get_keybinds.py b/configs/quickshell/ii/scripts/hyprland/get_keybinds.py new file mode 100755 index 0000000..559ba8a --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/scripts/images/find_regions.py b/configs/quickshell/ii/scripts/images/find_regions.py new file mode 100755 index 0000000..fe68a4d --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/scripts/images/least_busy_region.py b/configs/quickshell/ii/scripts/images/least_busy_region.py new file mode 100755 index 0000000..2b1d104 --- /dev/null +++ b/configs/quickshell/ii/scripts/images/least_busy_region.py @@ -0,0 +1,341 @@ +#!/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): + 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 + # 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_var = None + min_coords = (0, 0) + 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 + for y in range(y_start, max(y_end, y_start+1), stride): + for x in range(x_start, max(x_end, x_start+1), stride): + x1, y1 = x, y + x2, y2 = x + region_width - 1, y + region_height - 1 + 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) + 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 + 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 + # 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 + max_size = min(h, int(w / aspect_ratio)) if aspect_ratio >= 1.0 else min(int(h * aspect_ratio), w) + best = None + best_size = min_size + while min_size <= max_size: + mid = (min_size + max_size) // 2 + if aspect_ratio >= 1.0: + region_h = mid + region_w = int(mid * aspect_ratio) + else: + region_w = mid + region_h = int(mid / aspect_ratio) + if region_w > w or region_h > h: + max_size = mid - 1 + continue + found = False + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_w - horizontal_padding + 1 + y_end = h - region_h - vertical_padding + 1 + for y in range(y_start, max(y_end, y_start+1), stride): + for x in range(x_start, max(x_end, x_start+1), stride): + x1, y1 = x, y + x2, y2 = x + region_w - 1, y + region_h - 1 + 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: + best_size = mid + 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(f"Saved output image with rectangle at {output_path}") + +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(f"Saved output image with largest region at {output_path}") + +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)] + return [int(x) for x in 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") + 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 + ) + 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/configs/quickshell/ii/scripts/kvantum/adwsvg.py b/configs/quickshell/ii/scripts/kvantum/adwsvg.py new file mode 100644 index 0000000..10ce1d1 --- /dev/null +++ b/configs/quickshell/ii/scripts/kvantum/adwsvg.py @@ -0,0 +1,79 @@ +import re +import os + +def read_scss(file_path): + """Reads an SCSS file and returns a dictionary of color variables.""" + colors = {} + with open(file_path, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line.strip()) + if match: + variable_name, color = match.groups() + colors[variable_name] = color + return colors + +def update_svg_colors(svg_path, old_to_new_colors, output_path): + """ + Updates the colors in an SVG file based on the provided color map. + + :param svg_path: Path to the SVG file. + :param old_to_new_colors: Dictionary mapping old colors to new colors. + :param output_path: Path to save the updated SVG file. + """ + # Read the SVG content + with open(svg_path, 'r') as file: + svg_content = file.read() + + # Replace old colors with new colors + for old_color, new_color in old_to_new_colors.items(): + svg_content = re.sub(old_color, new_color, svg_content, flags=re.IGNORECASE) + + # Write the updated SVG content to the output file + with open(output_path, 'w') as file: + file.write(svg_content) + + print(f"SVG colors have been updated and saved to {output_path}!") + +def main(): + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "Colloid.svg") + output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") + + # Read colors from the SCSS file + color_data = read_scss(scss_file) + + # Specify the old colors and map them to new colors from the SCSS file + old_to_new_colors = { + #'#cccccc': color_data['surfaceDim'], # Map old SVG color to new SCSS color + #'#666666': color_data['surfaceDim'], + '#3c84f7': color_data['primary'], + #'#5a5a5a': color_data['neutral_paletteKeyColor'], + '#000000': color_data['shadow'], + '#f04a50': color_data['error'], + '#4285f4': color_data['primaryFixedDim'], + '#f2f2f2': color_data['background'], + #'#dfdfdf': color_data['surfaceContainerLow'], + '#ffffff': color_data['background'], + '#1e1e1e': color_data['onPrimaryFixed'], + #'#b6b6b6': color_data['surfaceContainer'], + '#333': color_data['inverseSurface'], + '#212121': color_data['onSecondaryFixed'], + '#5b9bf8': color_data['secondaryContainer'], + '#26272a': color_data['term7'], + #'#b3b3b3': color_data['surfaceBright'], + #'#b74aff': color_data['tertiary'], + #'#989898': color_data['surfaceContainerHighest'], + #'#c1c1c1': color_data['surfaceContainerHigh'], + '#444444': color_data['onBackground'], + '#333333': color_data['onPrimaryFixed'], + } + + # Update the SVG colors + update_svg_colors(svg_path, old_to_new_colors, output_path) + +if __name__ == "__main__": + main() + diff --git a/configs/quickshell/ii/scripts/kvantum/adwsvgDark.py b/configs/quickshell/ii/scripts/kvantum/adwsvgDark.py new file mode 100644 index 0000000..9fb0977 --- /dev/null +++ b/configs/quickshell/ii/scripts/kvantum/adwsvgDark.py @@ -0,0 +1,87 @@ +import re +import os + +def read_scss(file_path): + """Reads an SCSS file and returns a dictionary of color variables.""" + colors = {} + with open(file_path, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line.strip()) + if match: + variable_name, color = match.groups() + colors[variable_name] = color + return colors + +def update_svg_colors(svg_path, old_to_new_colors, output_path): + """ + Updates the colors in an SVG file based on the provided color map. + + :param svg_path: Path to the SVG file. + :param old_to_new_colors: Dictionary mapping old colors to new colors. + :param output_path: Path to save the updated SVG file. + """ + # Read the SVG content + with open(svg_path, 'r') as file: + svg_content = file.read() + + # Replace old colors with new colors + for old_color, new_color in old_to_new_colors.items(): + svg_content = re.sub(old_color, new_color, svg_content, flags=re.IGNORECASE) + + # Write the updated SVG content to the output file + with open(output_path, 'w') as file: + file.write(svg_content) + + print(f"SVG colors have been updated and saved to {output_path}!") + +def main(): + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "ColloidDark.svg") + output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") + + # Read colors from the SCSS file + color_data = read_scss(scss_file) + + # Specify the old colors and map them to new colors from the SCSS file + old_to_new_colors = { + #'#525252': color_data['surfaceDim'], # Map old SVG color to new SCSS color + #'#666666': color_data['surfaceDim'], + '#31363b': color_data['background'], + #'#eff0f1': color_data['neutral_paletteKeyColor'], + '#000000': color_data['shadow'], + '#5b9bf8': color_data['primary'], + '#93cee9': color_data['onSecondaryContainer'], + '#3daee9': color_data['secondary'], + #'#fff': color_data['term10'], + #'#5a5a5a': color_data['surfaceVariant'], + #'#acb1bc': color_data['onPrimaryFixed'], + '#ffffff': color_data['term11'], + '#5a616e': color_data['surfaceVariant'], + '#f04a50': color_data['error'], + '#4285f4': color_data['secondary'], + '#242424': color_data['background'], + '#2c2c2c': color_data['background'], + #'#dfdfdf': color_data['onSurfaceVariant'], + #'#646464': color_data['surfaceContainerHighest'], + #'#989898': color_data['surfaceContainerHigh'], + #'#c1c1c1': color_data['primaryFixedDim'], + '#1e1e1e': color_data['background'], + '#3c3c3c': color_data['background'], + '#26272a': color_data['surfaceBright'], + '#000000': color_data['shadow'], + '#b74aff': color_data['tertiary'], + #'#b6b6b6': color_data['onSurfaceVariant'], + '#1a1a1a': color_data['background'], + '#333': color_data['term0'], + '#212121': color_data['background'], + } + + # Update the SVG colors + update_svg_colors(svg_path, old_to_new_colors, output_path) + +if __name__ == "__main__": + main() + diff --git a/configs/quickshell/ii/scripts/kvantum/changeAdwColors.py b/configs/quickshell/ii/scripts/kvantum/changeAdwColors.py new file mode 100644 index 0000000..26d067a --- /dev/null +++ b/configs/quickshell/ii/scripts/kvantum/changeAdwColors.py @@ -0,0 +1,71 @@ +import re +import os + +def get_colors_from_scss(scss_file): + colors = {} + with open(scss_file, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line) + if match: + colors[match.group(1)] = match.group(2) + return colors + +def update_config_colors(config_file, colors, mappings): + with open(config_file, 'r') as file: + config_content = file.read() + + for key, variable in mappings.items(): + if variable in colors: + color = colors[variable] + pattern = rf'({key}=)#?\w+\b' + new_line = f'\\1{color}' + if re.search(pattern, config_content): + config_content = re.sub(pattern, new_line, config_content) + else: + config_content += f"\n{key}={color}" + + with open(config_file, 'w') as file: + file.write(config_content) + +if __name__ == "__main__": + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + config_file = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.kvconfig") + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + + # Define your mappings here + mappings = { + 'window.color': 'background', + 'base.color': 'background', + 'alt.base.color': 'background', + 'button.color': 'surfaceContainer', + 'light.color': 'surfaceContainerLow', + 'mid.light.color': 'surfaceContainer', + 'dark.color': 'surfaceContainerHighest', + 'mid.color': 'surfaceContainerHigh', + 'highlight.color': 'primary', + 'inactive.highlight.color': 'primary', + 'text.color': 'onBackground', + 'window.text.color': 'onBackground', + 'button.text.color': 'onBackground', + 'disabled.text.color': 'onBackground', + 'tooltip.text.color': 'onBackground', + 'highlight.text.color': 'onSurface', + 'link.color': 'tertiary', + 'link.visited.color': 'tertiaryFixed', + 'progress.indicator.text.color': 'onBackground', + 'text.normal.color': 'onBackground', + 'text.focus.color': 'onBackground', + 'text.press.color': 'onsecondarycontainer', + 'text.toggle.color': 'onsecondarycontainer', + 'text.disabled.color': 'surfaceDim', + + + # Add more mappings as needed + } + + colors = get_colors_from_scss(scss_file) + update_config_colors(config_file, colors, mappings) + print("Config colors updated successfully!") + diff --git a/configs/quickshell/ii/scripts/kvantum/materialQT.sh b/configs/quickshell/ii/scripts/kvantum/materialQT.sh new file mode 100755 index 0000000..a049c55 --- /dev/null +++ b/configs/quickshell/ii/scripts/kvantum/materialQT.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +get_light_dark() { + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + echo "dark" + else + echo "light" + fi +} + +apply_qt() { + # Check if the theme exists + FOLDER_PATH="$XDG_CONFIG_HOME/Kvantum/Colloid/" + + if [ ! -d "$FOLDER_PATH" ]; then + # Send a notification + notify-send "Colloid-kde theme required" " The folder '$FOLDER_PATH' does not exist." + exit 1 # Exit the function if the folder does not exist + fi + + lightdark=$(get_light_dark) + if [ "$lightdark" = "light" ]; then + # apply ligght colors + cp "$XDG_CONFIG_HOME/Kvantum/Colloid/Colloid.kvconfig" "$XDG_CONFIG_HOME/Kvantum/MaterialAdw/MaterialAdw.kvconfig" + python "$CONFIG_DIR/scripts/kvantum/adwsvg.py" + + else + #apply dark colors + cp "$XDG_CONFIG_HOME/Kvantum/Colloid/ColloidDark.kvconfig" "$XDG_CONFIG_HOME/Kvantum/MaterialAdw/MaterialAdw.kvconfig" + python "$CONFIG_DIR/scripts/kvantum/adwsvgDark.py" + fi +} + +apply_qt diff --git a/configs/quickshell/ii/scripts/wayland-idle-inhibitor.py b/configs/quickshell/ii/scripts/wayland-idle-inhibitor.py new file mode 100755 index 0000000..9bdaabb --- /dev/null +++ b/configs/quickshell/ii/scripts/wayland-idle-inhibitor.py @@ -0,0 +1,86 @@ +#!/usr/bin/env -S\_/bin/sh\_-xc\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" + +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + +import sys +from dataclasses import dataclass +from signal import SIGINT, SIGTERM, signal +from threading import Event +import setproctitle + +from pywayland.client.display import Display +from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( + ZwpIdleInhibitManagerV1, +) +from pywayland.protocol.wayland.wl_compositor import WlCompositor +from pywayland.protocol.wayland.wl_registry import WlRegistryProxy +from pywayland.protocol.wayland.wl_surface import WlSurface + + +@dataclass +class GlobalRegistry: + surface: WlSurface | None = None + inhibit_manager: ZwpIdleInhibitManagerV1 | None = None + + +def handle_registry_global( + wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int +) -> None: + global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry() + + if iface_name == "wl_compositor": + compositor = wl_registry.bind(id_num, WlCompositor, version) + global_registry.surface = compositor.create_surface() # type: ignore + elif iface_name == "zwp_idle_inhibit_manager_v1": + global_registry.inhibit_manager = wl_registry.bind( + id_num, ZwpIdleInhibitManagerV1, version + ) + + +def main() -> None: + done = Event() + signal(SIGINT, lambda _, __: done.set()) + signal(SIGTERM, lambda _, __: done.set()) + + global_registry = GlobalRegistry() + + display = Display() + display.connect() + + registry = display.get_registry() # type: ignore + registry.user_data = global_registry + registry.dispatcher["global"] = handle_registry_global + + def shutdown() -> None: + display.dispatch() + display.roundtrip() + display.disconnect() + + display.dispatch() + display.roundtrip() + + if global_registry.surface is None or global_registry.inhibit_manager is None: + print("Wayland seems not to support idle_inhibit_unstable_v1 protocol.") + shutdown() + sys.exit(1) + + inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore + global_registry.surface + ) + + display.dispatch() + display.roundtrip() + + print("Inhibiting idle...") + done.wait() + print("Shutting down...") + + inhibitor.destroy() + + shutdown() + + +if __name__ == "__main__": + setproctitle.setproctitle("wayland-idle-inhibitor.py") + main() diff --git a/configs/quickshell/ii/services/Ai.qml b/configs/quickshell/ii/services/Ai.qml new file mode 100644 index 0000000..f265d95 --- /dev/null +++ b/configs/quickshell/ii/services/Ai.qml @@ -0,0 +1,892 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions as CF +import qs.modules.common +import qs +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import "./ai/" + +/** + * Basic service to handle LLM chats. Supports Google's and OpenAI's API formats. + * Supports Gemini and OpenAI models. + * Limitations: + * - For now functions only work with Gemini API format + */ +Singleton { + id: root + + property Component aiMessageComponent: AiMessageData {} + property Component aiModelComponent: AiModel {} + property Component geminiApiStrategy: GeminiApiStrategy {} + property Component openaiApiStrategy: OpenAiApiStrategy {} + property Component mistralApiStrategy: MistralApiStrategy {} + readonly property string interfaceRole: "interface" + readonly property string apiKeyEnvVarName: "API_KEY" + + property string systemPrompt: { + let prompt = Config.options?.ai?.systemPrompt ?? ""; + for (let key in root.promptSubstitutions) { + // prompt = prompt.replaceAll(key, root.promptSubstitutions[key]); + // QML/JS doesn't support replaceAll, so use split/join + prompt = prompt.split(key).join(root.promptSubstitutions[key]); + } + return prompt; + } + // property var messages: [] + property var messageIDs: [] + property var messageByID: ({}) + readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} + readonly property var apiKeysLoaded: KeyringStorage.loaded + readonly property bool currentModelHasApiKey: { + const model = models[currentModelId]; + if (!model || !model.requires_key) return true; + if (!apiKeysLoaded) return false; + const key = apiKeys[model.key_id]; + return (key?.length > 0); + } + property var postResponseHook + property real temperature: Persistent.states?.ai?.temperature ?? 0.5 + property QtObject tokenCount: QtObject { + property int input: -1 + property int output: -1 + property int total: -1 + } + + function idForMessage(message) { + // Generate a unique ID using timestamp and random value + return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); + } + + function safeModelName(modelName) { + return modelName.replace(/:/g, "_").replace(/ /g, "-").replace(/\//g, "-") + } + + property list defaultPrompts: [] + property list userPrompts: [] + property list promptFiles: [...defaultPrompts, ...userPrompts] + property list savedChats: [] + + property var promptSubstitutions: { + "{DISTRO}": SystemInfo.distroName, + "{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`, + "{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown", + "{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})` + } + + // Gemini: https://ai.google.dev/gemini-api/docs/function-calling + // OpenAI: https://platform.openai.com/docs/guides/function-calling + property string currentTool: Config?.options.ai.tool ?? "search" + property var tools: { + "gemini": { + "functions": [{"functionDeclarations": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ]}], + "search": [{ + "google_search": {} + }], + "none": [] + }, + "openai": { + "functions": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ], + "search": [], + "none": [], + }, + "mistral": { + "functions": [ + { + "type": "function", + "function": { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + "parameters": {} + }, + }, + { + "type": "function", + "function": { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + }, + ], + "search": [], + "none": [], + } + } + property list availableTools: Object.keys(root.tools[models[currentModelId]?.api_format]) + property var toolDescriptions: { + "functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"), + "search": Translation.tr("Gives the model search capabilities (immediately)"), + "none": Translation.tr("Disable tools") + } + + // Model properties: + // - name: Name of the model + // - icon: Icon name of the model + // - description: Description of the model + // - endpoint: Endpoint of the model + // - model: Model name of the model + // - requires_key: Whether the model requires an API key + // - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + // - key_get_link: Link to get an API key + // - key_get_description: Description of pricing and how to get an API key + // - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + // - extraParams: Extra parameters to be passed to the model. This is a JSON object. + property var models: { + "gemini-2.0-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.0 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nFast, can perform searches for up-to-date information"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent", + "model": "gemini-2.5-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-pro": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Pro", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent", + "model": "gemini-2.5-pro", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-lite": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash-Lite", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent", + "model": "gemini-2.5-flash-lite", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "mistral-medium-3": aiModelComponent.createObject(this, { + "name": "Mistral Medium 3", + "icon": "mistral-symbolic", + "description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"), + "homepage": "https://mistral.ai/news/mistral-medium-3", + "endpoint": "https://api.mistral.ai/v1/chat/completions", + "model": "mistral-medium-2505", + "requires_key": true, + "key_id": "mistral", + "key_get_link": "https://console.mistral.ai/api-keys", + "key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"), + "api_format": "mistral", + }), + "openrouter-deepseek-r1": aiModelComponent.createObject(this, { + "name": "DeepSeek R1", + "icon": "deepseek-symbolic", + "description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"), + "homepage": "https://openrouter.ai/deepseek/deepseek-r1:free", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "deepseek/deepseek-r1:free", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }), + } + property var modelList: Object.keys(root.models) + property var currentModelId: Persistent.states?.ai?.model || modelList[0] + + property var apiStrategies: { + "openai": openaiApiStrategy.createObject(this), + "gemini": geminiApiStrategy.createObject(this), + "mistral": mistralApiStrategy.createObject(this), + } + property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"] + + Connections { + target: Config + function onReadyChanged() { + if (!Config.ready) return; + (Config?.options.ai?.extraModels ?? []).forEach(model => { + const safeModelName = root.safeModelName(model["model"]); + root.addModel(safeModelName, model) + }); + } + } + + Component.onCompleted: { + setModel(currentModelId, false, false); // Do necessary setup for model + } + + function guessModelLogo(model) { + if (model.includes("llama")) return "ollama-symbolic"; + if (model.includes("gemma")) return "google-gemini-symbolic"; + if (model.includes("deepseek")) return "deepseek-symbolic"; + if (/^phi\d*:/i.test(model)) return "microsoft-symbolic"; + return "ollama-symbolic"; + } + + function guessModelName(model) { + const replaced = model.replace(/-/g, ' ').replace(/:/g, ' '); + let words = replaced.split(' '); + words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`) + words = words.map((word) => { + return (word.charAt(0).toUpperCase() + word.slice(1)) + }); + if (words[words.length - 1] === "Latest") words.pop(); + else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets + const result = words.join(' '); + return result; + } + + function addModel(modelName, data) { + root.models[modelName] = aiModelComponent.createObject(this, data); + } + + Process { + id: getOllamaModels + running: true + command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")] + stdout: SplitParser { + onRead: data => { + try { + if (data.length === 0) return; + const dataJson = JSON.parse(data); + root.modelList = [...root.modelList, ...dataJson]; + dataJson.forEach(model => { + const safeModelName = root.safeModelName(model); + root.addModel(safeModelName, { + "name": guessModelName(model), + "icon": guessModelLogo(model), + "description": Translation.tr("Local Ollama model | %1").arg(model), + "homepage": `https://ollama.com/library/${model}`, + "endpoint": "http://localhost:11434/v1/chat/completions", + "model": model, + "requires_key": false, + }) + }); + + root.modelList = Object.keys(root.models); + + } catch (e) { + console.log("Could not fetch Ollama models:", e); + } + } + } + } + + Process { + id: getDefaultPrompts + running: true + command: ["ls", "-1", Directories.defaultAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.defaultPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.defaultAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getUserPrompts + running: true + command: ["ls", "-1", Directories.userAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.userPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.userAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getSavedChats + running: true + command: ["ls", "-1", Directories.aiChats] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.savedChats = text.split("\n") + .filter(fileName => fileName.endsWith(".json")) + .map(fileName => `${Directories.aiChats}/${fileName}`) + } + } + } + + FileView { + id: promptLoader + watchChanges: false; + onLoadedChanged: { + if (!promptLoader.loaded) return; + Config.options.ai.systemPrompt = promptLoader.text(); + root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + } + + function printPrompt() { + root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + + function loadPrompt(filePath) { + promptLoader.path = "" // Unload + promptLoader.path = filePath; // Load + promptLoader.reload(); + } + + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "rawContent": message, + "thinking": false, + "done": true, + }); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function removeMessage(index) { + if (index < 0 || index >= messageIDs.length) return; + const id = root.messageIDs[index]; + root.messageIDs.splice(index, 1); + root.messageIDs = [...root.messageIDs]; + delete root.messageByID[id]; + } + + function addApiKeyAdvice(model) { + root.addMessage( + Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3') + .arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("No further instruction provided")).arg("/key"), + Ai.interfaceRole + ); + } + + function getModel() { + return models[currentModelId]; + } + + function setModel(modelId, feedback = true, setPersistentState = true) { + if (!modelId) modelId = "" + modelId = modelId.toLowerCase() + if (modelList.indexOf(modelId) !== -1) { + const model = models[modelId] + // Fetch API keys if needed + if (model?.requires_key) KeyringStorage.fetchKeyringData(); + // See if policy prevents online models + if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) { + root.addMessage( + Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"), + root.interfaceRole + ); + return; + } + if (setPersistentState) Persistent.states.ai.model = modelId; + if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole); + if (model.requires_key) { + // If key not there show advice + if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) { + root.addApiKeyAdvice(model) + } + } + } else { + if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```" + } + } + + function setTool(tool) { + if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) { + root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole); + return false; + } + Config.options.ai.tool = tool; + return true; + } + + function getTemperature() { + return root.temperature; + } + + function setTemperature(value) { + if (value == NaN || value < 0 || value > 2) { + root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole); + return; + } + Persistent.states.ai.temperature = value; + root.temperature = value; + root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole); + } + + function setApiKey(key) { + const model = models[currentModelId]; + if (!model.requires_key) { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + return; + } + if (!key || key.length === 0) { + const model = models[currentModelId]; + root.addApiKeyAdvice(model) + return; + } + KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim()); + root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole); + } + + function printApiKey() { + const model = models[currentModelId]; + if (model.requires_key) { + const key = root.apiKeys[model.key_id]; + if (key) { + root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole); + } else { + root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole); + } + } else { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + } + } + + function printTemperature() { + root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole); + } + + function clearMessages() { + root.messageIDs = []; + root.messageByID = ({}); + root.tokenCount.input = -1; + root.tokenCount.output = -1; + root.tokenCount.total = -1; + } + + Process { + id: requester + property list baseCommand: ["bash", "-c"] + property AiMessageData message + property ApiStrategy currentStrategy + + function markDone() { + requester.message.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; // Reset hook after use + } + root.saveChat("lastSession") + } + + function makeRequest() { + const model = models[currentModelId]; + requester.currentStrategy = root.currentApiStrategy; + requester.currentStrategy.reset(); // Reset strategy state + + /* Put API key in environment variable */ + if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "" + + /* Build endpoint, request data */ + const endpoint = root.currentApiStrategy.buildEndpoint(model); + const messageArray = root.messageIDs.map(id => root.messageByID[id]); + const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole); + const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool]); + // console.log("[Ai] Request data: ", JSON.stringify(data, null, 2)); + + let requestHeaders = { + "Content-Type": "application/json", + } + + /* Create local message object */ + requester.message = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": currentModelId, + "content": "", + "rawContent": "", + "thinking": true, + "done": false, + }); + const id = idForMessage(requester.message); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = requester.message; + + /* Build header string for curl */ + let headerString = Object.entries(requestHeaders) + .filter(([k, v]) => v && v.length > 0) + .map(([k, v]) => `-H '${k}: ${v}'`) + .join(' '); + + // console.log("Request headers: ", JSON.stringify(requestHeaders)); + // console.log("Header string: ", headerString); + + /* Get authorization header from strategy */ + const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName); + + /* Create command string */ + const requestCommandString = `curl --no-buffer "${endpoint}"` + + ` ${headerString}` + + (authHeader ? ` ${authHeader}` : "") + + ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + + /* Send the request */ + requester.command = baseCommand.concat([requestCommandString]); + requester.running = true + } + + stdout: SplitParser { + onRead: data => { + if (data.length === 0) return; + if (requester.message.thinking) requester.message.thinking = false; + // console.log("[Ai] Raw response line: ", data); + + // Handle response line + try { + const result = requester.currentStrategy.parseResponseLine(data, requester.message); + // console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2)); + + if (result.functionCall) { + requester.message.functionCall = result.functionCall; + root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message); + } + if (result.tokenUsage) { + root.tokenCount.input = result.tokenUsage.input; + root.tokenCount.output = result.tokenUsage.output; + root.tokenCount.total = result.tokenUsage.total; + } + if (result.finished) { + requester.markDone(); + } + + } catch (e) { + console.log("[AI] Could not parse response: ", e); + requester.message.rawContent += data; + requester.message.content += data; + } + } + } + + onExited: (exitCode, exitStatus) => { + const result = requester.currentStrategy.onRequestFinished(requester.message); + + if (result.finished) { + requester.markDone(); + } else if (!requester.message.done) { + requester.markDone(); + } + + // Handle error responses + if (requester.message.content.includes("API key not valid")) { + root.addApiKeyAdvice(models[requester.message.model]); + } + } + } + + function sendUserMessage(message) { + if (message.length === 0) return; + root.addMessage(message, "user"); + requester.makeRequest(); + } + + function createFunctionOutputMessage(name, output, includeOutputInChat = true) { + return aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + // "visibleToUser": false, + }); + } + + function addFunctionOutputMessage(name, output) { + const aiMessage = createFunctionOutputMessage(name, output); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function rejectCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user")) + } + + function approveCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + + const responseMessage = createFunctionOutputMessage(message.functionName, "", false); + const id = idForMessage(responseMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = responseMessage; + + commandExecutionProc.message = responseMessage; + commandExecutionProc.baseMessageContent = responseMessage.content; + commandExecutionProc.shellCommand = message.functionCall.args.command; + commandExecutionProc.running = true; // Start the command execution + } + + Process { + id: commandExecutionProc + property string shellCommand: "" + property AiMessageData message + property string baseMessageContent: "" + command: ["bash", "-c", shellCommand] + stdout: SplitParser { + onRead: (output) => { + commandExecutionProc.message.functionResponse += output + "\n\n"; + const updatedContent = commandExecutionProc.baseMessageContent + `\n\n\n${commandExecutionProc.message.functionResponse}\n`; + commandExecutionProc.message.rawContent = updatedContent; + commandExecutionProc.message.content = updatedContent; + } + } + onExited: (exitCode, exitStatus) => { + commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`; + requester.makeRequest(); // Continue + } + } + + function handleFunctionCall(name, args: var, message: AiMessageData) { + if (name === "switch_to_search_mode") { + const modelId = root.currentModelId; + root.currentTool = "search" + root.postResponseHook = () => { root.currentTool = "functions" } + addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request.")) + requester.makeRequest(); + } else if (name === "get_shell_config") { + const configJson = CF.ObjectUtils.toPlainObject(Config.options) + addFunctionOutputMessage(name, JSON.stringify(configJson)); + requester.makeRequest(); + } else if (name === "set_shell_config") { + if (!args.key || !args.value) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`.")); + return; + } + const key = args.key; + const value = args.value; + Config.setNestedValue(key, value); + } else if (name === "run_shell_command") { + if (!args.command || args.command.length === 0) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`.")); + return; + } + const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``; + message.rawContent += contentToAppend; + message.content += contentToAppend; + message.functionPending = true; // Use thinking to indicate the command is waiting for approval + } + else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant"); + } + + function chatToJson() { + return root.messageIDs.map(id => { + const message = root.messageByID[id] + return ({ + "role": message.role, + "rawContent": message.rawContent, + "model": message.model, + "thinking": false, + "done": true, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }) + }) + } + + FileView { + id: chatSaveFile + property string chatName: "chat" + path: `${Directories.aiChats}/${chatName}.json` + blockLoading: true + } + + /** + * Saves chat to a JSON list of message objects. + * @param chatName name of the chat + */ + function saveChat(chatName) { + chatSaveFile.chatName = chatName.trim() + const saveContent = JSON.stringify(root.chatToJson()) + chatSaveFile.setText(saveContent) + getSavedChats.running = true; + } + + /** + * Loads chat from a JSON list of message objects. + * @param chatName name of the chat + */ + function loadChat(chatName) { + try { + chatSaveFile.chatName = chatName.trim() + chatSaveFile.reload() + const saveContent = chatSaveFile.text() + // console.log(saveContent) + const saveData = JSON.parse(saveContent) + root.clearMessages() + root.messageIDs = saveData.map((_, i) => { + return i + }) + // console.log(JSON.stringify(messageIDs)) + for (let i = 0; i < saveData.length; i++) { + const message = saveData[i]; + root.messageByID[i] = root.aiMessageComponent.createObject(root, { + "role": message.role, + "rawContent": message.rawContent, + "content": message.rawContent, + "model": message.model, + "thinking": message.thinking, + "done": message.done, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }); + } + } catch (e) { + console.log("[AI] Could not load chat: ", e); + } finally { + getSavedChats.running = true; + } + } +} diff --git a/configs/quickshell/ii/services/AppSearch.qml b/configs/quickshell/ii/services/AppSearch.qml new file mode 100644 index 0000000..44d0912 --- /dev/null +++ b/configs/quickshell/ii/services/AppSearch.qml @@ -0,0 +1,148 @@ +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", + "zen": "zen-browser", + "brave-browser": "brave-desktop" + }) + 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" + } + ] + + readonly property list list: Array.from(DesktopEntries.applications.values) + .sort((a, b) => a.name.localeCompare(b.name)) + + 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 guessIcon(str) { + if (!str || str.length == 0) return "image-missing"; + + // 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; + + + // 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; + } + + + // Give up + return str; + } +} diff --git a/configs/quickshell/ii/services/Audio.qml b/configs/quickshell/ii/services/Audio.qml new file mode 100644 index 0000000..0651ebc --- /dev/null +++ b/configs/quickshell/ii/services/Audio.qml @@ -0,0 +1,54 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for default Pipewire audio sink and source. + */ +Singleton { + id: root + + property bool ready: Pipewire.defaultAudioSink?.ready ?? false + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + + signal sinkProtectionTriggered(string reason); + + 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; + if (!lastReady) { + lastVolume = sink.audio.volume; + lastReady = true; + return; + } + const newVolume = sink.audio.volume; + 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("Illegal increment"); + } else if (newVolume > maxAllowed) { + root.sinkProtectionTriggered("Exceeded max allowed"); + sink.audio.volume = Math.min(lastVolume, maxAllowed); + } + if (sink.ready && (isNaN(sink.audio.volume) || sink.audio.volume === undefined || sink.audio.volume === null)) { + sink.audio.volume = 0; + } + lastVolume = sink.audio.volume; + } + + } + +} diff --git a/configs/quickshell/ii/services/Battery.qml b/configs/quickshell/ii/services/Battery.qml new file mode 100644 index 0000000..8a3cf31 --- /dev/null +++ b/configs/quickshell/ii/services/Battery.qml @@ -0,0 +1,50 @@ +pragma Singleton + +import qs +import qs.modules.common +import Quickshell +import Quickshell.Services.UPower + +Singleton { + 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 + readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend + + property bool isLow: percentage <= Config.options.battery.low / 100 + property bool isCritical: percentage <= Config.options.battery.critical / 100 + property bool isSuspending: percentage <= Config.options.battery.suspend / 100 + + property bool isLowAndNotCharging: isLow && !isCharging + property bool isCriticalAndNotCharging: isCritical && !isCharging + property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging + + onIsLowAndNotChargingChanged: { + if (available && isLowAndNotCharging) Quickshell.execDetached([ + "notify-send", + Translation.tr("Low battery"), + Translation.tr("Consider plugging in your device"), + "-u", "critical", + "-a", "Shell" + ]) + } + + onIsCriticalAndNotChargingChanged: { + if (available && isCriticalAndNotCharging) 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" + ]); + + } + + onIsSuspendingAndNotChargingChanged: { + if (available && isSuspendingAndNotCharging) { + Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]); + } + } +} diff --git a/configs/quickshell/ii/services/Bluetooth.qml b/configs/quickshell/ii/services/Bluetooth.qml new file mode 100644 index 0000000..817bbc9 --- /dev/null +++ b/configs/quickshell/ii/services/Bluetooth.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * Basic polled Bluetooth state. + */ +Singleton { + id: root + + property int updateInterval: 1000 + property string bluetoothDeviceName: "" + property string bluetoothDeviceAddress: "" + property bool bluetoothEnabled: false + property bool bluetoothConnected: false + + function update() { + updateBluetoothDevice.running = true + updateBluetoothStatus.running = true + updateBluetoothEnabled.running = true + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + update() + interval = root.updateInterval + } + } + + // Check if Bluetooth is enabled (controller powered on) + Process { + id: updateBluetoothEnabled + command: ["sh", "-c", "bluetoothctl show | grep -q 'Powered: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothEnabled = (parseInt(data) === 1) + } + } + } + + // Get the name and address of the first connected Bluetooth device + Process { + id: updateBluetoothDevice + command: ["sh", "-c", "bluetoothctl info | awk -F': ' '/Name: /{name=$2} /Device /{addr=$2} END{print name \":\" addr}'"] + running: true + stdout: SplitParser { + onRead: data => { + let parts = data.split(":") + root.bluetoothDeviceName = parts[0] || "" + root.bluetoothDeviceAddress = parts[1] || "" + } + } + } + + // Check if any device is connected + Process { + id: updateBluetoothStatus + command: ["sh", "-c", "bluetoothctl info | grep -q 'Connected: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothConnected = (parseInt(data) === 1) + } + } + } +} diff --git a/configs/quickshell/ii/services/Booru.qml b/configs/quickshell/ii/services/Booru.qml new file mode 100644 index 0000000..e2a9d19 --- /dev/null +++ b/configs/quickshell/ii/services/Booru.qml @@ -0,0 +1,467 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs +import Quickshell; +import QtQuick; + +/** + * A service for interacting with various booru APIs. + */ +Singleton { + id: root + property Component booruResponseDataComponent: BooruResponseData {} + + signal tagSuggestion(string query, var suggestions) + + property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number") + property var responses: [] + property int runningRequests: 0 + property var defaultUserAgent: Config.options?.networking?.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 var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api) + property var providers: { + "system": { "name": Translation.tr("System") }, + "yandere": { + "name": "yande.re", + "url": "https://yande.re", + "api": "https://yande.re/post.json", + "description": Translation.tr("All-rounder | Good quality, decent quantity"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://yande.re/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "konachan": { + "name": "Konachan", + "url": "https://konachan.net", + "api": "https://konachan.net/post.json", + "description": Translation.tr("For desktop wallpapers | Good quality"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://konachan.net/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "zerochan": { + "name": "Zerochan", + "url": "https://www.zerochan.net", + "api": "https://www.zerochan.net/?json", + "description": Translation.tr("Clean stuff | Excellent quality, no NSFW"), + "mapFunc": (response) => { + response = response.items + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.join(" "), + "rating": "safe", // Zerochan doesn't have nsfw + "is_nsfw": false, + "md5": item.md5, + "preview_url": item.thumbnail, + "sample_url": item.thumbnail, + "file_url": item.thumbnail, + "file_ext": "avif", + "source": getWorkingImageSource(item.source) ?? item.thumbnail, + "character": item.tag + } + }) + } + }, + "danbooru": { + "name": "Danbooru", + "url": "https://danbooru.donmai.us", + "api": "https://danbooru.donmai.us/posts.json", + "description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.image_width, + "height": item.image_height, + "aspect_ratio": item.image_width / item.image_height, + "tags": item.tag_string, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_file_url, + "sample_url": item.file_url ?? item.large_file_url, + "file_url": item.large_file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://danbooru.donmai.us/tags.json?search[name_matches]={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.post_count + } + }) + } + + }, + "gelbooru": { + "name": "Gelbooru", + "url": "https://gelbooru.com", + "api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1", + "description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"), + "mapFunc": (response) => { + response = response.post + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating.replace('general', 's').charAt(0), + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_url.split('.').pop(), + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&name_pattern={{query}}%", + "tagMapFunc": (response) => { + return response.tag.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "waifu.im": { + "name": "waifu.im", + "url": "https://waifu.im", + "api": "https://api.waifu.im/search", + "description": Translation.tr("Waifus only | Excellent quality, limited quantity"), + "mapFunc": (response) => { + response = response.images + return response.map(item => { + return { + "id": item.image_id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.map(tag => {return tag.name}).join(" "), + "rating": item.is_nsfw ? "e" : "s", + "is_nsfw": item.is_nsfw, + "md5": item.md5, + "preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk) + "sample_url": item.url, + "file_url": item.url, + "file_ext": item.extension, + "source": getWorkingImageSource(item.source) ?? item.url, + } + }) + }, + "tagSearchTemplate": "https://api.waifu.im/tags", + "tagMapFunc": (response) => { + return [...response.versatile.map(item => {return {"name": item}}), + ...response.nsfw.map(item => {return {"name": item}})] + } + }, + "t.alcy.cc": { + "name": "Alcy", + "url": "https://t.alcy.cc", + "api": "https://t.alcy.cc/", + "description": Translation.tr("Large images | God tier quality, no NSFW."), + "fixedTags": [ + { + "name": "ycy", + "count": "General" + }, + { + "name": "moez", + "count": "Moe" + }, + { + "name": "ysz", + "count": "Genshin Impact" + }, + { + "name": "fj", + "count": "Landscape" + }, + { + "name": "bd", + "count": "Girl on white background" + }, + { + "name": "xhl", + "count": "Shiggy" + }, + ], + "manualParseFunc": (responseText) => { + // Alcy just returns image links, each on a new line + const lines = responseText.trim().split('\n'); + return lines.map(line => { + return { + "id": Qt.md5(line), + // Alcy doesn't provide dimensions and images are often of god resolution + "width": 1000, + "height": 1000, + "aspect_ratio": 1, // Default aspect ratio + "tags": "[no tags]", + "rating": "s", + "is_nsfw": false, + "md5": Qt.md5(line), + "preview_url": line, + "sample_url": line, + "file_url": line, + "file_ext": line.split('.').pop(), + "source": "", + } + }); + }, + } + } + property var currentProvider: Persistent.states.booru.provider + + function getWorkingImageSource(url) { + if (url.includes('pximg.net')) { + return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`; + } + return url; + } + + function setProvider(provider) { + provider = provider.toLowerCase() + if (providerList.indexOf(provider) !== -1) { + Persistent.states.booru.provider = provider + root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name + + (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : "")) + } else { + root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- ")) + } + } + + function clearResponses() { + responses = [] + } + + function addSystemMessage(message) { + responses = [...responses, root.booruResponseDataComponent.createObject(null, { + "provider": "system", + "tags": [], + "page": -1, + "images": [], + "message": `${message}` + })] + } + + function constructRequestUrl(tags, nsfw=true, limit=20, page=1) { + var provider = providers[currentProvider] + var baseUrl = provider.api + var url = baseUrl + var tagString = tags.join(" ") + if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) { + if (currentProvider == "gelbooru") + tagString += " rating:general"; + else + tagString += " rating:safe"; + } + var params = [] + // Tags & limit + if (currentProvider === "zerochan") { + params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color + params.push("l=" + limit) + params.push("s=" + "fav") + params.push("t=" + 1) + params.push("p=" + page) + } + else if (currentProvider === "waifu.im") { + var tagsArray = tagString.split(" "); + tagsArray.forEach(tag => { + params.push("included_tags=" + encodeURIComponent(tag)); + }); + params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30 + params.push("is_nsfw=" + (nsfw ? "null" : "false")) // null is random + } + else if (currentProvider === "t.alcy.cc") { + url += tagString + params.push("json") + params.push("quantity=" + limit) + } + else { + params.push("tags=" + encodeURIComponent(tagString)) + params.push("limit=" + limit) + if (currentProvider == "gelbooru") { + params.push("pid=" + page) + } + else { + params.push("page=" + page) + } + } + if (baseUrl.indexOf("?") === -1) { + url += "?" + params.join("&") + } else { + url += "&" + params.join("&") + } + return url + } + + function makeRequest(tags, nsfw=false, limit=20, page=1) { + var url = constructRequestUrl(tags, nsfw, limit, page) + console.log("[Booru] Making request to " + url) + + const newResponse = root.booruResponseDataComponent.createObject(null, { + "provider": currentProvider, + "tags": tags, + "page": page, + "images": [], + "message": "" + }) + + var xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + const provider = providers[currentProvider] + let response; + if (provider.manualParseFunc) { + response = provider.manualParseFunc(xhr.responseText) + } else { + response = JSON.parse(xhr.responseText) + response = provider.mapFunc(response) + } + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + newResponse.images = response + newResponse.message = response.length > 0 ? "" : root.failMessage + + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + newResponse.message = root.failMessage + } finally { + root.runningRequests--; + root.responses = [...root.responses, newResponse] + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + else if (currentProvider == "zerochan") { + const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent + xhr.setRequestHeader("User-Agent", userAgent) + } + root.runningRequests++; + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } + + property var currentTagRequest: null + function triggerTagSearch(query) { + if (currentTagRequest) { + currentTagRequest.abort(); + } + + var provider = providers[currentProvider] + if (provider.fixedTags) { + root.tagSuggestion(query, provider.fixedTags) + return provider.fixedTags; + } else if (!provider.tagSearchTemplate) { + return + } + var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query)) + + var xhr = new XMLHttpRequest() + currentTagRequest = xhr + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + currentTagRequest = null + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + var response = JSON.parse(xhr.responseText) + response = provider.tagMapFunc(response) + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + root.tagSuggestion(query, response) + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } +} + diff --git a/configs/quickshell/ii/services/BooruResponseData.qml b/configs/quickshell/ii/services/BooruResponseData.qml new file mode 100644 index 0000000..2a61ff6 --- /dev/null +++ b/configs/quickshell/ii/services/BooruResponseData.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import QtQuick; + +/** + * A booru response. + */ +QtObject { + property string provider + property var tags + property var page + property var images + property string message +} diff --git a/configs/quickshell/ii/services/Brightness.qml b/configs/quickshell/ii/services/Brightness.qml new file mode 100644 index 0000000..927a10c --- /dev/null +++ b/configs/quickshell/ii/services/Brightness.qml @@ -0,0 +1,152 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://github.com/caelestia-dots/shell/ (`quickshell` branch) with modifications. +// License: GPLv3 + +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; + } + + 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({ + model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }); + } + } + } + onExited: root.ddcMonitorsChanged() + } + + Process { + id: setProc + } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? "" + property real brightness + property bool ready: false + + onBrightnessChanged: { + if (monitor.ready) { + root.brightnessChanged(); + } + } + + function initialize() { + monitor.ready = false; + 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.brightness = parseInt(current) / parseInt(max); + monitor.ready = true; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0.01, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + brightness = value; + setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"]; + setProc.startDetached(); + } + + Component.onCompleted: { + initialize(); + } + + onBusNumChanged: { + initialize(); + } + } + + Component { + id: monitorComp + + BrightnessMonitor {} + } + + 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/configs/quickshell/ii/services/Cliphist.qml b/configs/quickshell/ii/services/Cliphist.qml new file mode 100644 index 0000000..6e3f7a1 --- /dev/null +++ b/configs/quickshell/ii/services/Cliphist.qml @@ -0,0 +1,101 @@ +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 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 (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 refresh() { + readProc.buffer = [] + readProc.running = true + } + + function copy(entry) { + Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(entry)}' | cliphist decode | wl-copy`]); + } + + Process { + id: deleteProc + property string entry: "" + command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | cliphist delete`] + function deleteEntry(entry) { + deleteProc.entry = entry; + deleteProc.running = true; + deleteProc.entry = ""; + } + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + function deleteEntry(entry) { + deleteProc.deleteEntry(entry); + } + + 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: ["cliphist", "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) + } + } + } +} diff --git a/configs/quickshell/ii/services/DateTime.qml b/configs/quickshell/ii/services/DateTime.qml new file mode 100644 index 0000000..16dc6c4 --- /dev/null +++ b/configs/quickshell/ii/services/DateTime.qml @@ -0,0 +1,51 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property var clock: SystemClock { + id: clock + precision: SystemClock.Minutes + } + property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm") + property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy") + 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/configs/quickshell/ii/services/Emojis.qml b/configs/quickshell/ii/services/Emojis.qml new file mode 100644 index 0000000..436401b --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/services/FirstRunExperience.qml b/configs/quickshell/ii/services/FirstRunExperience.qml new file mode 100644 index 0000000..f23cce5 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/services/HyprlandData.qml b/configs/quickshell/ii/services/HyprlandData.qml new file mode 100644 index 0000000..07c2d89 --- /dev/null +++ b/configs/quickshell/ii/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: ["bash", "-c", "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: ["bash", "-c", "hyprctl monitors -j"] + stdout: StdioCollector { + id: monitorsCollector + onStreamFinished: { + root.monitors = JSON.parse(monitorsCollector.text); + } + } + } + + Process { + id: getLayers + command: ["bash", "-c", "hyprctl layers -j"] + stdout: StdioCollector { + id: layersCollector + onStreamFinished: { + root.layers = JSON.parse(layersCollector.text); + } + } + } + + Process { + id: getWorkspaces + command: ["bash", "-c", "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: ["bash", "-c", "hyprctl activeworkspace -j"] + stdout: StdioCollector { + id: activeWorkspaceCollector + onStreamFinished: { + root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text); + } + } + } +} diff --git a/configs/quickshell/ii/services/HyprlandKeybinds.qml b/configs/quickshell/ii/services/HyprlandKeybinds.qml new file mode 100644 index 0000000..3381926 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/services/HyprlandXkb.qml b/configs/quickshell/ii/services/HyprlandXkb.qml new file mode 100644 index 0000000..cace0d2 --- /dev/null +++ b/configs/quickshell/ii/services/HyprlandXkb.qml @@ -0,0 +1,108 @@ +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: key + whitespace + description + const match = line.match(/^\s*(\S+)\s+(.+)$/); + if (match && match[2] === targetDescription) { + root.cachedLayoutCodes[match[2]] = match[1]; + root.currentLayoutCode = match[1]; + return true; + } + }); + // 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.layouts.length > 1) + "): " + // + root.layouts.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.split(",")[1]; + + // Update layout for on-screen keyboard (osk) + Config.options.osk.layout = root.currentLayoutName; + } else if (event.name == "configreloaded") { + // Mark layout code list to be updated when config is reloaded + root.needsLayoutRefresh = true; + } + } + } +} diff --git a/configs/quickshell/ii/services/Hyprsunset.qml b/configs/quickshell/ii/services/Hyprsunset.qml new file mode 100644 index 0000000..d6def43 --- /dev/null +++ b/configs/quickshell/ii/services/Hyprsunset.qml @@ -0,0 +1,117 @@ +pragma Singleton + +import QtQuick +import qs.modules.common +import Quickshell +import Quickshell.Io + +/** + * 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 var manualActive + property string from: Config.options?.light?.night?.from ?? "19:00" // Default to 7 PM + property string to: Config.options?.light?.night?.to ?? "06:30" // Default to 6:30 AM + property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true) + property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000 // Default color temperature + 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 + + + function isNoLater(hour1, minute1, hour2, minute2) { + if (hour1 < hour2) + return true; + if (hour1 === hour2 && minute1 < minute2) + return true; + return false; + } + + + onClockMinuteChanged: reEvaluate() + onAutomaticChanged: { + root.manualActive = undefined; + root.firstEvaluation = true; + reEvaluate(); + } + function reEvaluate() { + const toHourIsNextDay = !isNoLater(fromHour, fromMinute, toHour, toMinute); + const toHourWrapped = toHourIsNextDay ? toHour + 24 : toHour; + const toMinuteWrapped = toMinute; + root.shouldBeOn = isNoLater(fromHour, fromMinute, clockHour, clockMinute) && isNoLater(clockHour, clockMinute, toHourWrapped, toMinuteWrapped); + 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"); + // console.log("[Hyprsunset] Fetched state:", output, "->", root.active); + } + } + } + + function toggle() { + if (root.manualActive === undefined) + root.manualActive = root.active; + + root.manualActive = !root.manualActive; + if (root.manualActive) { + root.enable(); + } else { + root.disable(); + } + } +} diff --git a/configs/quickshell/ii/services/KeyringStorage.qml b/configs/quickshell/ii/services/KeyringStorage.qml new file mode 100644 index 0000000..ce6b8eb --- /dev/null +++ b/configs/quickshell/ii/services/KeyringStorage.qml @@ -0,0 +1,118 @@ +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", `echo $(secret-tool lookup 'application' 'illogical-impulse')`, + ] + stdout: SplitParser { + onRead: data => { + if(data.length === 0) 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 !== 0) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + root.loaded = true; + } + } + +} diff --git a/configs/quickshell/ii/services/LatexRenderer.qml b/configs/quickshell/ii/services/LatexRenderer.qml new file mode 100644 index 0000000..5baf336 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/services/MaterialThemeLoader.qml b/configs/quickshell/ii/services/MaterialThemeLoader.qml new file mode 100644 index 0000000..c325d81 --- /dev/null +++ b/configs/quickshell/ii/services/MaterialThemeLoader.qml @@ -0,0 +1,62 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +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 + + Component.onCompleted: delayedFileRead.restart() + + function reapplyTheme() { + delayedFileRead.restart() + } + + Timer { + id: delayedFileRead + interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 300 + repeat: false + running: false + onTriggered: { + console.log("MaterialThemeLoader: Timer triggered") + const fileContent = themeFileView.text() + console.log("MaterialThemeLoader: Read", fileContent.length, "bytes") + const json = JSON.parse(fileContent) + let colorCount = 0 + for (const key in json) { + if (json.hasOwnProperty(key)) { + if (key === 'darkmode' || key === 'transparent' || key.includes('paletteKeyColor') || key.startsWith('term')) { + continue + } + const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()) + const m3Key = `m3${camelCaseKey}` + Appearance.m3colors[m3Key] = json[key] + colorCount++ + } + } + console.log("MaterialThemeLoader: Applied", colorCount, "colors") + console.log("MaterialThemeLoader: m3primary =", Appearance.m3colors.m3primary) + Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5) + } + } + + FileView { + id: themeFileView + path: Qt.resolvedUrl(root.filePath) + watchChanges: true + onFileChanged: { + this.reload() + delayedFileRead.restart() + } + onLoadedChanged: if (loaded) delayedFileRead.restart() + } +} diff --git a/configs/quickshell/ii/services/MprisController.qml b/configs/quickshell/ii/services/MprisController.qml new file mode 100644 index 0000000..60923a6 --- /dev/null +++ b/configs/quickshell/ii/services/MprisController.qml @@ -0,0 +1,165 @@ +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 qs +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root; + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + + 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/configs/quickshell/ii/services/Network.qml b/configs/quickshell/ii/services/Network.qml new file mode 100644 index 0000000..50bfb67 --- /dev/null +++ b/configs/quickshell/ii/services/Network.qml @@ -0,0 +1,93 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Simple polled network state service. + */ +Singleton { + id: root + + property bool wifi: true + property bool ethernet: false + property int updateInterval: 1000 + property string networkName: "" + property int networkStrength + property string materialSymbol: ethernet ? "lan" : + (Network.networkName.length > 0 && Network.networkName != "lo") ? ( + Network.networkStrength > 80 ? "signal_wifi_4_bar" : + Network.networkStrength > 60 ? "network_wifi_3_bar" : + Network.networkStrength > 40 ? "network_wifi_2_bar" : + Network.networkStrength > 20 ? "network_wifi_1_bar" : + "signal_wifi_0_bar" + ) : "signal_wifi_off" + function update() { + updateConnectionType.startCheck(); + updateNetworkName.running = true; + updateNetworkStrength.running = true; + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + root.update(); + interval = root.updateInterval; + } + } + + Process { + id: updateConnectionType + property string buffer + command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"] + 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'); + let hasEthernet = false; + let hasWifi = false; + lines.forEach(line => { + if (line.includes("ethernet")) + hasEthernet = true; + else if (line.includes("wireless")) + hasWifi = true; + }); + 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); + } + } + } +} diff --git a/configs/quickshell/ii/services/Notifications.qml b/configs/quickshell/ii/services/Notifications.qml new file mode 100644 index 0000000..bf5d4e7 --- /dev/null +++ b/configs/quickshell/ii/services/Notifications.qml @@ -0,0 +1,289 @@ +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 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: 5000 + running: true + onTriggered: () => { + root.timeoutNotification(notificationId); + destroy() + } + } + + property bool silent: false + 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 ? 5000 : notification.expireTimeout, + }); + } + } + + root.notify(newNotifObject); + // console.log(notifToString(newNotifObject)); + notifFileView.setText(stringifyList(root.list)); + } + } + + 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 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/configs/quickshell/ii/services/ResourceUsage.qml b/configs/quickshell/ii/services/ResourceUsage.qml new file mode 100644 index 0000000..6505284 --- /dev/null +++ b/configs/quickshell/ii/services/ResourceUsage.qml @@ -0,0 +1,62 @@ +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 { + property double memoryTotal: 1 + property double memoryFree: 1 + property double memoryUsed: memoryTotal - memoryFree + property double memoryUsedPercentage: memoryUsed / memoryTotal + property double swapTotal: 1 + property double swapFree: 1 + property double swapUsed: swapTotal - swapFree + property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 + property double cpuUsage: 0 + property var previousCpuStats + + 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 } + } + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { id: fileMeminfo; path: "/proc/meminfo" } + FileView { id: fileStat; path: "/proc/stat" } +} diff --git a/configs/quickshell/ii/services/SystemInfo.qml b/configs/quickshell/ii/services/SystemInfo.qml new file mode 100644 index 0000000..a8da8e1 --- /dev/null +++ b/configs/quickshell/ii/services/SystemInfo.qml @@ -0,0 +1,114 @@ +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 "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; + 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/configs/quickshell/ii/services/Todo.qml b/configs/quickshell/ii/services/Todo.qml new file mode 100644 index 0000000..93227cb --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/services/Weather.qml b/configs/quickshell/ii/services/Weather.qml new file mode 100644 index 0000000..c8d46e2 --- /dev/null +++ b/configs/quickshell/ii/services/Weather.qml @@ -0,0 +1,154 @@ +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 + + 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 + }) + + 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 = ""; + 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.temp += " (" + (data?.current?.FeelsLikeF || 0) + ") "; + temp.temp += "\u{02109}"; + } 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.temp += " (" + (data?.current?.FeelsLikeC || 0) + ") "; + temp.temp += "\u{02103}"; + } + 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/configs/quickshell/ii/services/Ydotool.qml b/configs/quickshell/ii/services/Ydotool.qml new file mode 100644 index 0000000..f25b093 --- /dev/null +++ b/configs/quickshell/ii/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/configs/quickshell/ii/services/ai/AiMessageData.qml b/configs/quickshell/ii/services/ai/AiMessageData.qml new file mode 100644 index 0000000..023458d --- /dev/null +++ b/configs/quickshell/ii/services/ai/AiMessageData.qml @@ -0,0 +1,21 @@ +import QtQuick; + +/** + * Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure. + */ +QtObject { + property string role + property string content + property string rawContent + property string model + property bool thinking: true + property bool done: false + property var annotations: [] + property var annotationSources: [] + property list searchQueries: [] + property string functionName + property var functionCall + property string functionResponse + property bool functionPending: false + property bool visibleToUser: true +} diff --git a/configs/quickshell/ii/services/ai/AiModel.qml b/configs/quickshell/ii/services/ai/AiModel.qml new file mode 100644 index 0000000..7cf9852 --- /dev/null +++ b/configs/quickshell/ii/services/ai/AiModel.qml @@ -0,0 +1,32 @@ +import QtQuick; + +/** + * An AI model representation. + * - name: Friendly name of the model + * - icon: Icon name of the model + * - description: Description of the model + * - endpoint: Endpoint of the model + * - model: Model code (like gpt-4.1 or gemini-2.5-flash) + * - requires_key: Whether the model requires an API key + * - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + * - key_get_link: Link to get an API key + * - key_get_description: Description of pricing and how to get an API key + * - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + * - extraParams: Extra parameters to be passed to the model. This is a JSON object. + */ + +QtObject { + property string name + property string icon + property string description + property string homepage + property string endpoint + property string model + property bool requires_key: true + property string key_id + property string key_get_link + property string key_get_description + property string api_format: "openai" + property var tools + property var extraParams: ({}) +} diff --git a/configs/quickshell/ii/services/ai/ApiStrategy.qml b/configs/quickshell/ii/services/ai/ApiStrategy.qml new file mode 100644 index 0000000..75736d6 --- /dev/null +++ b/configs/quickshell/ii/services/ai/ApiStrategy.qml @@ -0,0 +1,10 @@ +import QtQuick + +QtObject { + function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") } + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { throw new Error("Not implemented") } + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") } + function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") } + function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling + function reset() { } // Reset any internal state if needed +} diff --git a/configs/quickshell/ii/services/ai/GeminiApiStrategy.qml b/configs/quickshell/ii/services/ai/GeminiApiStrategy.qml new file mode 100644 index 0000000..12c775c --- /dev/null +++ b/configs/quickshell/ii/services/ai/GeminiApiStrategy.qml @@ -0,0 +1,155 @@ +import QtQuick + +ApiStrategy { + property string buffer: "" + + function buildEndpoint(model: AiModel): string { + const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}` + // console.log("[AI] Endpoint: " + result); + return result; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "contents": messages.map(message => { + const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role; + const usingSearch = tools[0]?.google_search !== undefined + if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionCall: { + "name": message.functionName, + } + }] + } + } + if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionResponse: { + "name": message.functionName, + "response": { "content": message.functionResponse } + } + }] + } + } + return { + "role": geminiApiRoleName, + "parts": [{ + text: message.rawContent, + }] + } + }), + "tools": tools, + "system_instruction": { + "parts": [{ text: systemPrompt }] + }, + "generationConfig": { + "temperature": temperature, + }, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + // Gemini doesn't use Authorization header, key is in URL + return ""; + } + + function parseResponseLine(line, message) { + if (line.startsWith("[")) { + buffer += line.slice(1).trim(); + } else if (line === "]") { + buffer += line.slice(0, -1).trim(); + return parseBuffer(message); + } else if (line.startsWith(",")) { + return parseBuffer(message); + } else { + buffer += line.trim(); + } + return {}; + } + + function parseBuffer(message) { + // console.log("[Ai] Gemini buffer: ", buffer); + let finished = false; + try { + if (buffer.length === 0) return {}; + const dataJson = JSON.parse(buffer); + if (!dataJson.candidates) return {}; + + if (dataJson.candidates[0]?.finishReason) { + finished = true; + } + + // Function call handling + if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) { + const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall; + message.functionName = functionCall.name; + message.functionCall = functionCall.name; + const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n` + message.rawContent += newContent; + message.content += newContent; + return { functionCall: { name: functionCall.name, args: functionCall.args }, finished: finished }; + } + + // Normal text response + const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text + message.rawContent += responseContent; + message.content += responseContent; + + // Handle annotations and metadata + const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => { + return { + "type": "url_citation", + "text": chunk?.web?.title, + "url": chunk?.web?.uri, + } + }) ?? []; + + const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => { + return { + "type": "url_citation", + "start_index": citation.segment?.startIndex, + "end_index": citation.segment?.endIndex, + "text": citation?.segment.text, + "url": annotationSources[citation.groundingChunkIndices[0]]?.url, + "sources": citation.groundingChunkIndices + } + }); + message.annotationSources = annotationSources; + message.annotations = annotations; + message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? []; + + // Usage metadata + if (dataJson.usageMetadata) { + return { + tokenUsage: { + input: dataJson.usageMetadata.promptTokenCount ?? -1, + output: dataJson.usageMetadata.candidatesTokenCount ?? -1, + total: dataJson.usageMetadata.totalTokenCount ?? -1 + }, + finished: finished + }; + } + + } catch (e) { + console.log("[AI] Gemini: Could not parse buffer: ", e); + message.rawContent += buffer; + message.content += buffer; + } finally { + buffer = ""; + } + return { finished: finished }; + } + + function onRequestFinished(message) { + return parseBuffer(message); + } + + function reset() { + buffer = ""; + } +} diff --git a/configs/quickshell/ii/services/ai/MistralApiStrategy.qml b/configs/quickshell/ii/services/ai/MistralApiStrategy.qml new file mode 100644 index 0000000..dfcb950 --- /dev/null +++ b/configs/quickshell/ii/services/ai/MistralApiStrategy.qml @@ -0,0 +1,124 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0 + let messageData = { + "role": message.role, + "content": message.rawContent, + } + if (hasFunctionCall) { + if (message.functionResponse?.length > 0) { + messageData.name = message.functionName; // Does the func call also need this name? or just the func output? + messageData.role = "tool"; + messageData.content = message.functionResponse; + messageData.tool_call_id = message.functionCall.id + } + } + return messageData + }), + ], + "stream": true, + "temperature": temperature, + "tools": tools, + }; + // console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2)); + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + // Function call + if (dataJson.choices[0]?.delta?.tool_calls) { + const functionCall = dataJson.choices[0].delta.tool_calls[0]; + const functionName = functionCall.function.name; + const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string??? + const functionId = functionCall.id; + const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`; + message.rawContent += newContent; + message.content += newContent; + message.functionName = functionName; + message.functionCall = functionName; + return { functionCall: { name: functionName, args: functionArgs, id: functionId } }; + } + + // Thinking? + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n
\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + // Text + message.content += newContent; + message.rawContent += newContent; + + if (`dataJson`.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] Mistral: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/configs/quickshell/ii/services/ai/OpenAiApiStrategy.qml b/configs/quickshell/ii/services/ai/OpenAiApiStrategy.qml new file mode 100644 index 0000000..a5792ac --- /dev/null +++ b/configs/quickshell/ii/services/ai/OpenAiApiStrategy.qml @@ -0,0 +1,97 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + return { + "role": message.role, + "content": message.rawContent, + } + }), + ], + "stream": true, + "temperature": temperature, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + message.content += newContent; + message.rawContent += newContent; + + if (dataJson.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] OpenAI: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + // OpenAI format doesn't need special finish handling + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/configs/quickshell/ii/services/qmldir b/configs/quickshell/ii/services/qmldir new file mode 100644 index 0000000..2dfda7a --- /dev/null +++ b/configs/quickshell/ii/services/qmldir @@ -0,0 +1,29 @@ +module qs.services + +Ai 1.0 Ai.qml +AppSearch 1.0 AppSearch.qml +Audio 1.0 Audio.qml +Battery 1.0 Battery.qml +Bluetooth 1.0 Bluetooth.qml +Booru 1.0 Booru.qml +BooruResponseData 1.0 BooruResponseData.qml +Brightness 1.0 Brightness.qml +Cliphist 1.0 Cliphist.qml +DateTime 1.0 DateTime.qml +Emojis 1.0 Emojis.qml +FirstRunExperience 1.0 FirstRunExperience.qml +HyprlandData 1.0 HyprlandData.qml +HyprlandKeybinds 1.0 HyprlandKeybinds.qml +HyprlandXkb 1.0 HyprlandXkb.qml +Hyprsunset 1.0 Hyprsunset.qml +KeyringStorage 1.0 KeyringStorage.qml +LatexRenderer 1.0 LatexRenderer.qml +MaterialThemeLoader 1.0 MaterialThemeLoader.qml +MprisController 1.0 MprisController.qml +Network 1.0 Network.qml +Notifications 1.0 Notifications.qml +ResourceUsage 1.0 ResourceUsage.qml +SystemInfo 1.0 SystemInfo.qml +Todo 1.0 Todo.qml +Weather 1.0 Weather.qml +Ydotool 1.0 Ydotool.qml diff --git a/configs/quickshell/ii/settings.qml b/configs/quickshell/ii/settings.qml new file mode 100644 index 0000000..a15670b --- /dev/null +++ b/configs/quickshell/ii/settings.qml @@ -0,0 +1,248 @@ +//@ 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 qs +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("Style"), + icon: "palette", + component: "modules/settings/StyleConfig.qml" + }, + { + name: Translation.tr("Interface"), + icon: "cards", + 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() + } + + minimumWidth: 600 + minimumHeight: 400 + 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.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.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 + iconText: "edit" + buttonText: Translation.tr("Edit config") + expanded: navRail.expanded + onClicked: { + Qt.openUrlExternally(`${Directories.config}/illogical-impulse/config.json`); + } + + StyledToolTip { + extraVisibleCondition: !navRail.expanded + content: "Edit shell config file" + } + } + + NavigationRailTabArray { + currentIndex: root.currentPage + expanded: navRail.expanded + Repeater { + model: root.pages + NavigationRailButton { + required property var index + required property var modelData + toggled: root.currentPage === index + onClicked: root.currentPage = index; + expanded: navRail.expanded + buttonIcon: modelData.icon + 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 + source: root.pages[0].component + Connections { + target: root + function onCurrentPageChanged() { + if (pageLoader.sourceComponent !== root.pages[root.currentPage].component) { + 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 + } + PropertyAction { + target: pageLoader + property: "source" + value: root.pages[root.currentPage].component + } + NumberAnimation { + target: pageLoader + properties: "opacity" + from: 0 + to: 1 + duration: 200 + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.emphasizedLastHalf + } + } + } + } + } + } +} diff --git a/configs/quickshell/ii/shell.qml b/configs/quickshell/ii/shell.qml new file mode 100644 index 0000000..289173e --- /dev/null +++ b/configs/quickshell/ii/shell.qml @@ -0,0 +1,77 @@ +//@ 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 "./modules/common/" +import "./modules/background/" +import "./modules/bar/" +import "./modules/cheatsheet/" +import "./modules/dock/" +import "./modules/lock/" +import "./modules/mediaControls/" +import "./modules/notificationPopup/" +import "./modules/onScreenDisplay/" +import "./modules/onScreenKeyboard/" +import "./modules/overview/" +import "./modules/screenCorners/" +import "./modules/session/" +import "./modules/sidebarLeft/" +import "./modules/sidebarRight/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import qs + +ShellRoot { + // Enable/disable modules here. False = not loaded at all, so rest assured + // no unnecessary stuff will take up memory if you decide to only use, say, the overview. + property bool enableBar: true + property bool enableBackground: true + property bool enableCheatsheet: true + property bool enableDock: true + property bool enableLock: true + property bool enableMediaControls: true + property bool enableNotificationPopup: true + property bool enableOnScreenDisplayBrightness: true + property bool enableOnScreenDisplayVolume: true + property bool enableOnScreenKeyboard: true + property bool enableOverview: true + property bool enableReloadPopup: true + property bool enableScreenCorners: true + property bool enableSession: true + property bool enableSidebarLeft: true + property bool enableSidebarRight: true + + // Force initialization of some singletons + Component.onCompleted: { + Cliphist.refresh() + FirstRunExperience.load() + Hyprsunset.load() + MaterialThemeLoader.reapplyTheme() + } + + LazyLoader { active: enableBar; component: Bar {} } + LazyLoader { active: enableBackground; component: Background {} } + LazyLoader { active: enableCheatsheet; component: Cheatsheet {} } + LazyLoader { active: enableDock && Config.options.dock.enable; component: Dock {} } + LazyLoader { active: enableLock; component: Lock {} } + LazyLoader { active: enableMediaControls; component: MediaControls {} } + LazyLoader { active: enableNotificationPopup; component: NotificationPopup {} } + LazyLoader { active: enableOnScreenDisplayBrightness; component: OnScreenDisplayBrightness {} } + LazyLoader { active: enableOnScreenDisplayVolume; component: OnScreenDisplayVolume {} } + LazyLoader { active: enableOnScreenKeyboard; component: OnScreenKeyboard {} } + LazyLoader { active: enableOverview; component: Overview {} } + LazyLoader { active: enableReloadPopup; component: ReloadPopup {} } + LazyLoader { active: enableScreenCorners; component: ScreenCorners {} } + LazyLoader { active: enableSession; component: Session {} } + LazyLoader { active: enableSidebarLeft; component: SidebarLeft {} } + LazyLoader { active: enableSidebarRight; component: SidebarRight {} } +} + diff --git a/configs/quickshell/ii/welcome.qml b/configs/quickshell/ii/welcome.qml new file mode 100644 index 0000000..df9b0d4 --- /dev/null +++ b/configs/quickshell/ii/welcome.qml @@ -0,0 +1,343 @@ +//@ 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 +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ApplicationWindow { + id: root + property string firstRunFilePath: 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 + visible: true + onClosing: Qt.quit() + title: Translation.tr("illogical-impulse Welcome") + + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + } + + minimumWidth: 600 + minimumHeight: 400 + width: 800 + height: 650 + color: Appearance.m3colors.m3background + + Process { + id: konachanWallProc + property string status: "" + command: ["bash", "-c", Quickshell.shellPath("scripts/colors/random_konachan_wall.sh")] + stdout: SplitParser { + onRead: data => { + console.log(`Konachan wall proc output: ${data}`); + konachanWallProc.status = data.trim(); + } + } + } + + 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("Yooooo hi there") + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + text: Translation.tr("Show next time") + } + StyledSwitch { + id: showNextTimeSwitch + checked: root.showNextTime + scale: 0.6 + Layout.alignment: Qt.AlignVCenter + onCheckedChanged: { + if (checked) { + Quickshell.execDetached(["rm", root.firstRunFilePath]) + } else { + Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(root.firstRunFileContent)}' > '${StringUtils.shellSingleQuoteEscape(root.firstRunFilePath)}'`]) + } + } + } + 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 + + + ContentPage { + id: contentColumn + anchors.fill: parent + + ContentSection { + title: Translation.tr("Bar style") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + configOptionName: "bar.cornerStyle" + onSelected: (newValue) => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { displayName: Translation.tr("Hug"), value: 0 }, + { displayName: Translation.tr("Float"), value: 1 }, + { displayName: Translation.tr("Plain rectangle"), value: 2 } + ] + } + } + + ContentSection { + title: Translation.tr("Style & wallpaper") + + ButtonGroup { + Layout.fillWidth: true + LightDarkPreferenceButton { + dark: false + } + LightDarkPreferenceButton { + dark: true + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + RippleButtonWithIcon { + id: rndWallBtn + Layout.alignment: Qt.AlignHCenter + buttonRadius: Appearance.rounding.small + materialIcon: "wallpaper" + mainText: konachanWallProc.running ? Translation.tr("Be patient...") : Translation.tr("Random: Konachan") + onClicked: { + console.log(konachanWallProc.command.join(" ")) + konachanWallProc.running = true; + } + StyledToolTip { + content: Translation.tr("Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers") + } + } + RippleButtonWithIcon { + materialIcon: "wallpaper" + StyledToolTip { + content: 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" + } + } + } + } + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: Translation.tr("Change any time later with /dark, /light, /img in the launcher") + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + } + } + + ContentSection { + title: Translation.tr("Policies") + + ConfigRow { + ColumnLayout { // Weeb policy + ContentSubsectionLabel { + text: Translation.tr("Weeb") + } + ConfigSelectionArray { + currentValue: Config.options.policies.weeb + configOptionName: "policies.weeb" + onSelected: (newValue) => { + Config.options.policies.weeb = newValue; + } + options: [ + { displayName: Translation.tr("No"), value: 0 }, + { displayName: Translation.tr("Yes"), value: 1 }, + { displayName: Translation.tr("Closet"), value: 2 } + ] + } + } + + ColumnLayout { // AI policy + ContentSubsectionLabel { + text: Translation.tr("AI") + } + ConfigSelectionArray { + currentValue: Config.options.policies.ai + configOptionName: "policies.ai" + onSelected: (newValue) => { + Config.options.policies.ai = newValue; + } + options: [ + { displayName: Translation.tr("No"), value: 0 }, + { displayName: Translation.tr("Yes"), value: 1 }, + { displayName: Translation.tr("Local only"), value: 2 } + ] + } + } + } + } + + ContentSection { + title: Translation.tr("Info") + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "keyboard_alt" + onClicked: { + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "cheatsheet", "toggle"]) + } + mainContentComponent: Component { + RowLayout { + spacing: 10 + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: Translation.tr("Keybinds") + color: Appearance.colors.colOnSecondaryContainer + } + RowLayout { + spacing: 3 + KeyboardKey { + key: "๓ฐ–ณ" + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + key: "/" + } + } + } + } + } + + RippleButtonWithIcon { + materialIcon: "help" + mainText: Translation.tr("Usage") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/02usage/") + } + } + RippleButtonWithIcon { + materialIcon: "construction" + mainText: Translation.tr("Configuration") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/03config/") + } + } + } + } + + ContentSection { + title: Translation.tr("Useless buttons") + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + nerdIcon: "๓ฐŠค" + mainText: Translation.tr("GitHub") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland") + } + } + RippleButtonWithIcon { + materialIcon: "favorite" + mainText: "Funny number" + onClicked: { + Qt.openUrlExternally("https://github.com/sponsors/end-4") + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + } + } + } +} diff --git a/configs/quickshell/modules/background/Background.qml b/configs/quickshell/modules/background/Background.qml new file mode 100644 index 0000000..dc6bdec --- /dev/null +++ b/configs/quickshell/modules/background/Background.qml @@ -0,0 +1,290 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions as CF +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + readonly property bool fixedClockPosition: Config.options.background.fixedClockPosition + readonly property real fixedClockX: Config.options.background.clockX + readonly property real fixedClockY: Config.options.background.clockY + + Variants { + model: Quickshell.screens + + PanelWindow { + id: bgRoot + + required property var modelData + + // Hide when fullscreen + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + property bool focusingThisMonitor: HyprlandData.activeWorkspace.monitor == monitor.name + visible: !(activeWindow?.fullscreen && activeWindow?.activated && focusingThisMonitor) + + // 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 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: (Math.min(wallpaperWidth * effectiveWallpaperScale, screen.width * preferredWallpaperScale) - screen.width) / 2 + property real movableYSpace: (Math.min(wallpaperHeight * effectiveWallpaperScale, screen.height * preferredWallpaperScale) - screen.height) / 2 + // Position + property real clockX: (modelData.width / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.width) + property real clockY: (modelData.height / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.height) + property var textHorizontalAlignment: clockX < screen.width / 3 ? Text.AlignLeft : + (clockX > screen.width * 2 / 3 ? Text.AlignRight : Text.AlignHCenter) + // Colors + property color dominantColor: Appearance.colors.colPrimary + property bool dominantColorIsDark: dominantColor.hslLightness < 0.5 + property color colText: CF.ColorUtils.colorWithLightness(Appearance.colors.colPrimary, (dominantColorIsDark ? 0.8 : 0.12)) + + // Layer props + screen: modelData + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: GlobalStates.screenLocked ? WlrLayer.Top : WlrLayer.Bottom + // WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.namespace: "quickshell:background" + anchors { + top: true + bottom: true + left: true + right: true + } + color: "transparent" + + 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); + bgRoot.wallpaperWidth = width + bgRoot.wallpaperHeight = height + bgRoot.effectiveWallpaperScale = Math.max(1, Math.min( + bgRoot.preferredWallpaperScale, + width / bgRoot.screen.width, + height / bgRoot.screen.height + )); + + bgRoot.updateClockPosition() + } + } + } + + // Clock positioning + function updateClockPosition() { + // Somehow all this manual setting is needed to make the proc correctly use the new values + leastBusyRegionProc.path = bgRoot.wallpaperPath + leastBusyRegionProc.contentWidth = clock.implicitWidth + leastBusyRegionProc.contentHeight = clock.implicitHeight + leastBusyRegionProc.horizontalPadding = (effectiveWallpaperScale - 1) / 2 * screen.width + 100 + leastBusyRegionProc.verticalPadding = (effectiveWallpaperScale - 1) / 2 * screen.height + 100 + leastBusyRegionProc.running = false; + leastBusyRegionProc.running = true; + } + Process { + id: leastBusyRegionProc + property string path: bgRoot.wallpaperPath + property int contentWidth: 300 + property int contentHeight: 300 + property int horizontalPadding: bgRoot.movableXSpace + property int verticalPadding: bgRoot.movableYSpace + command: [Quickshell.shellPath("scripts/images/least_busy_region.py"), + "--screen-width", bgRoot.screen.width, + "--screen-height", bgRoot.screen.height, + "--width", contentWidth, + "--height", contentHeight, + "--horizontal-padding", horizontalPadding, + "--vertical-padding", verticalPadding, + path + ] + 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) + bgRoot.clockX = parsedContent.center_x + bgRoot.clockY = parsedContent.center_y + bgRoot.dominantColor = parsedContent.dominant_color || Appearance.colors.colPrimary + } + } + } + + // Wallpaper + Image { + id: wallpaper + visible: !bgRoot.wallpaperIsVideo + property real value // 0 to 1, for offset + value: { + // Range = groups that workspaces span on + const chunkSize = Config?.options.bar.workspaces.shown ?? 10; + const lower = Math.floor(bgRoot.firstWorkspaceId / chunkSize) * chunkSize; + const upper = Math.ceil(bgRoot.lastWorkspaceId / chunkSize) * chunkSize; + const range = upper - lower; + return (Config.options.background.parallax.enableWorkspace ? ((bgRoot.monitor.activeWorkspace.id - lower) / range) : 0.5) + + (0.15 * GlobalStates.sidebarRightOpen * Config.options.background.parallax.enableSidebar) + - (0.15 * GlobalStates.sidebarLeftOpen * Config.options.background.parallax.enableSidebar) + } + property real effectiveValue: Math.max(0, Math.min(1, value)) + x: -(bgRoot.movableXSpace) - (effectiveValue - 0.5) * 2 * bgRoot.movableXSpace + y: -(bgRoot.movableYSpace) + source: bgRoot.wallpaperPath + fillMode: Image.PreserveAspectCrop + Behavior on x { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + sourceSize { + width: bgRoot.screen.width * bgRoot.effectiveWallpaperScale + height: bgRoot.screen.height * bgRoot.effectiveWallpaperScale + } + } + + // The clock + Item { + id: clock + anchors { + left: wallpaper.left + top: wallpaper.top + leftMargin: ((root.fixedClockPosition ? root.fixedClockX : bgRoot.clockX * bgRoot.effectiveWallpaperScale) - implicitWidth / 2) - (wallpaper.effectiveValue * bgRoot.movableXSpace) + topMargin: ((root.fixedClockPosition ? root.fixedClockY : bgRoot.clockY * bgRoot.effectiveWallpaperScale) - implicitHeight / 2) + Behavior on leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on topMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + implicitWidth: clockColumn.implicitWidth + implicitHeight: clockColumn.implicitHeight + + ColumnLayout { + id: clockColumn + anchors.centerIn: wallpaper + spacing: 0 + + StyledText { + Layout.fillWidth: true + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 90 + weight: Font.Bold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.time + } + StyledText { + Layout.fillWidth: true + Layout.topMargin: -5 + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: Font.DemiBold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.date + } + } + + RowLayout { + anchors { + top: clockColumn.bottom + left: bgRoot.textHorizontalAlignment === Text.AlignLeft ? clockColumn.left : undefined + right: bgRoot.textHorizontalAlignment === Text.AlignRight ? clockColumn.right : undefined + horizontalCenter: bgRoot.textHorizontalAlignment === Text.AlignHCenter ? clockColumn.horizontalCenter : undefined + topMargin: 5 + leftMargin: -5 + rightMargin: -5 + } + opacity: GlobalStates.screenLocked ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignLeft; implicitWidth: 1 } + MaterialSymbol { + text: "lock" + Layout.fillWidth: false + iconSize: Appearance.font.pixelSize.huge + color: bgRoot.colText + } + StyledText { + Layout.fillWidth: false + text: "Locked" + color: bgRoot.colText + font { + pixelSize: Appearance.font.pixelSize.larger + } + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignRight; implicitWidth: 1 } + + } + } + + // Password prompt + StyledText { + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 30 + } + opacity: (GlobalStates.screenLocked && !GlobalStates.screenLockContainsCharacters) ? 1 : 0 + scale: opacity + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + text: "Enter password" + color: CF.ColorUtils.transparentize(bgRoot.colText, 0.3) + font { + pixelSize: Appearance.font.pixelSize.normal + } + } + } + } +} diff --git a/configs/quickshell/modules/bar/ActiveWindow.qml b/configs/quickshell/modules/bar/ActiveWindow.qml new file mode 100644 index 0000000..636b231 --- /dev/null +++ b/configs/quickshell/modules/bar/ActiveWindow.qml @@ -0,0 +1,53 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs +import QtQuick +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + required property var bar + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.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}` + } + + } + +} diff --git a/configs/quickshell/modules/bar/Bar.qml b/configs/quickshell/modules/bar/Bar.qml new file mode 100644 index 0000000..7fe8668 --- /dev/null +++ b/configs/quickshell/modules/bar/Bar.qml @@ -0,0 +1,622 @@ +import "./weather" +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 + + readonly property int osdHideMouseMoveThreshold: 20 + property bool showBarBackground: Config.options.bar.showBackground + + component VerticalBarSeparator: Rectangle { + Layout.topMargin: Appearance.sizes.baseBarHeight / 3 + Layout.bottomMargin: Appearance.sizes.baseBarHeight / 3 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + + 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) + 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 + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 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: barContent + } + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + left: true + right: true + } + + Item { // Bar content region + id: barContent + anchors { + right: parent.right + left: parent.left + top: parent.top + bottom: undefined + } + implicitHeight: Appearance.sizes.barHeight + height: Appearance.sizes.barHeight + + states: State { + name: "bottom" + when: Config.options.bar.bottom + AnchorChanges { + target: barContent + anchors { + right: parent.right + left: parent.left + top: undefined + bottom: parent.bottom + } + } + } + + // Background shadow + Loader { + active: showBarBackground && 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: showBarBackground ? 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 + } + + MouseArea { // Left side | scroll to change brightness + id: barLeftSideMouseArea + anchors.left: parent.left + implicitHeight: Appearance.sizes.baseBarHeight + height: Appearance.sizes.barHeight + width: (barRoot.width - middleSection.width) / 2 + property bool hovered: false + property real lastScrollX: 0 + property real lastScrollY: 0 + property bool trackingScroll: false + acceptedButtons: Qt.LeftButton + hoverEnabled: true + propagateComposedEvents: true + onEntered: event => { + barLeftSideMouseArea.hovered = true; + } + onExited: event => { + barLeftSideMouseArea.hovered = false; + barLeftSideMouseArea.trackingScroll = false; + } + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + } + // Scroll to change brightness + WheelHandler { + onWheel: event => { + if (event.angleDelta.y < 0) + barRoot.brightnessMonitor.setBrightness(barRoot.brightnessMonitor.brightness - 0.05); + else if (event.angleDelta.y > 0) + barRoot.brightnessMonitor.setBrightness(barRoot.brightnessMonitor.brightness + 0.05); + // Store the mouse position and start tracking + barLeftSideMouseArea.lastScrollX = event.x; + barLeftSideMouseArea.lastScrollY = event.y; + barLeftSideMouseArea.trackingScroll = true; + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + onPositionChanged: mouse => { + if (barLeftSideMouseArea.trackingScroll) { + const dx = mouse.x - barLeftSideMouseArea.lastScrollX; + const dy = mouse.y - barLeftSideMouseArea.lastScrollY; + if (Math.sqrt(dx * dx + dy * dy) > osdHideMouseMoveThreshold) { + GlobalStates.osdBrightnessOpen = false; + barLeftSideMouseArea.trackingScroll = false; + } + } + } + Item { + // Left section + anchors.fill: parent + implicitHeight: leftSectionRowLayout.implicitHeight + implicitWidth: leftSectionRowLayout.implicitWidth + + 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 { // Content + id: leftSectionRowLayout + anchors.fill: parent + spacing: 10 + + RippleButton { + // Left sidebar button + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + Layout.leftMargin: Appearance.rounding.screenRounding + Layout.fillWidth: false + property real buttonPadding: 5 + implicitWidth: distroIcon.width + buttonPadding * 2 + implicitHeight: distroIcon.height + buttonPadding * 2 + + buttonRadius: Appearance.rounding.full + colBackground: barLeftSideMouseArea.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.sidebarLeftOpen + property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0 + + onPressed: { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + + CustomIcon { + id: distroIcon + anchors.centerIn: parent + width: 19.5 + height: 19.5 + source: Config.options.bar.topLeftIcon == 'distro' ? SystemInfo.distroIcon : "spark-symbolic" + colorize: true + color: Appearance.colors.colOnLayer0 + } + } + + ActiveWindow { + visible: barRoot.useShortenedForm === 0 + Layout.rightMargin: Appearance.rounding.screenRounding + Layout.fillWidth: true + Layout.fillHeight: true + bar: barRoot + } + } + } + } + + RowLayout { // Middle section + id: middleSection + anchors.centerIn: parent + spacing: Config.options?.bar.borderless ? 4 : 8 + + BarGroup { + id: leftCenterGroup + Layout.preferredWidth: barRoot.centerSideModuleWidth + Layout.fillHeight: true + + Resources { + alwaysShowAllResources: barRoot.useShortenedForm === 2 + Layout.fillWidth: barRoot.useShortenedForm === 2 + } + + Media { + visible: barRoot.useShortenedForm < 2 + Layout.fillWidth: true + } + } + + VerticalBarSeparator { + visible: Config.options?.bar.borderless + } + + BarGroup { + id: middleCenterGroup + padding: workspacesWidget.widgetPadding + Layout.fillHeight: true + + Workspaces { + id: workspacesWidget + bar: barRoot + 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 + implicitWidth: rightCenterGroupContent.implicitWidth + implicitHeight: rightCenterGroupContent.implicitHeight + Layout.preferredWidth: barRoot.centerSideModuleWidth + Layout.fillHeight: true + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + BarGroup { + id: rightCenterGroupContent + anchors.fill: parent + + ClockWidget { + showDate: (Config.options.bar.verbose && barRoot.useShortenedForm < 2) + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + + UtilButtons { + visible: (Config.options.bar.verbose && barRoot.useShortenedForm === 0) + Layout.alignment: Qt.AlignVCenter + } + + BatteryIndicator { + visible: (barRoot.useShortenedForm < 2 && UPower.displayDevice.isLaptopBattery) + Layout.alignment: Qt.AlignVCenter + } + } + } + + VerticalBarSeparator { + visible: Config.options.bar.borderless && Config.options.bar.weather.enable + } + } + + MouseArea { // Right side | scroll to change volume + id: barRightSideMouseArea + + anchors.right: parent.right + implicitHeight: Appearance.sizes.baseBarHeight + height: Appearance.sizes.barHeight + width: (barRoot.width - middleSection.width) / 2 + + property bool hovered: false + property real lastScrollX: 0 + property real lastScrollY: 0 + property bool trackingScroll: false + + acceptedButtons: Qt.LeftButton + hoverEnabled: true + propagateComposedEvents: true + onEntered: event => { + barRightSideMouseArea.hovered = true; + } + onExited: event => { + barRightSideMouseArea.hovered = false; + barRightSideMouseArea.trackingScroll = false; + } + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } else if (event.button === Qt.RightButton) { + MprisController.activePlayer.next(); + } + } + // Scroll to change volume + WheelHandler { + onWheel: event => { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + if (event.angleDelta.y < 0) + Audio.sink.audio.volume -= step; + else if (event.angleDelta.y > 0) + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + // Store the mouse position and start tracking + barRightSideMouseArea.lastScrollX = event.x; + barRightSideMouseArea.lastScrollY = event.y; + barRightSideMouseArea.trackingScroll = true; + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + onPositionChanged: mouse => { + if (barRightSideMouseArea.trackingScroll) { + const dx = mouse.x - barRightSideMouseArea.lastScrollX; + const dy = mouse.y - barRightSideMouseArea.lastScrollY; + if (Math.sqrt(dx * dx + dy * dy) > osdHideMouseMoveThreshold) { + GlobalStates.osdVolumeOpen = false; + barRightSideMouseArea.trackingScroll = false; + } + } + } + + Item { + anchors.fill: parent + implicitHeight: rightSectionRowLayout.implicitHeight + implicitWidth: rightSectionRowLayout.implicitWidth + + 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 { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + 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 { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + MaterialSymbol { + text: "mic_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Loader { + active: HyprlandXkb.layoutCodes.length > 1 + visible: active + Layout.rightMargin: indicatorsRowLayout.realSpacing + sourceComponent: StyledText { + text: HyprlandXkb.currentLayoutCode + font.pixelSize: Appearance.font.pixelSize.small + color: rightSidebarButton.colText + } + } + MaterialSymbol { + Layout.rightMargin: indicatorsRowLayout.realSpacing + text: Network.materialSymbol + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + MaterialSymbol { + text: Bluetooth.bluetoothConnected ? "bluetooth_connected" : Bluetooth.bluetoothEnabled ? "bluetooth" : "bluetooth_disabled" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + } + + SysTray { + bar: barRoot + visible: barRoot.useShortenedForm === 0 + Layout.fillWidth: false + Layout.fillHeight: true + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Weather + Loader { + Layout.leftMargin: 8 + Layout.fillHeight: true + active: Config.options.bar.weather.enable + sourceComponent: BarGroup { + implicitHeight: Appearance.sizes.baseBarHeight + WeatherBar {} + } + } + } + } + } + } + + // Round decorators + Loader { + id: roundDecorators + anchors { + left: parent.left + right: parent.right + } + y: Appearance.sizes.barHeight + width: parent.width + height: Appearance.rounding.screenRounding + active: showBarBackground && Config.options.bar.cornerStyle === 0 // Hug + + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + roundDecorators.y: 0 + } + } + + sourceComponent: Item { + implicitHeight: Appearance.rounding.screenRounding + RoundCorner { + id: leftCorner + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + } + + size: 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 + } + size: 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/configs/quickshell/modules/bar/BarGroup.qml b/configs/quickshell/modules/bar/BarGroup.qml new file mode 100644 index 0000000..e2371d1 --- /dev/null +++ b/configs/quickshell/modules/bar/BarGroup.qml @@ -0,0 +1,36 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property real padding: 5 + implicitHeight: Appearance.sizes.baseBarHeight + height: Appearance.sizes.barHeight + implicitWidth: rowLayout.implicitWidth + padding * 2 + default property alias items: rowLayout.children + + Rectangle { + id: background + anchors { + fill: parent + topMargin: 4 + bottomMargin: 4 + } + color: Config.options?.bar.borderless ? "transparent" : Appearance.colors.colLayer1 + radius: Appearance.rounding.small + } + + RowLayout { + id: rowLayout + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + leftMargin: root.padding + rightMargin: root.padding + } + spacing: 4 + + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/bar/BatteryIndicator.qml b/configs/quickshell/modules/bar/BatteryIndicator.qml new file mode 100644 index 0000000..72cc932 --- /dev/null +++ b/configs/quickshell/modules/bar/BatteryIndicator.qml @@ -0,0 +1,95 @@ +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 + 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 + readonly property color batteryLowBackground: Appearance.m3colors.darkmode ? Appearance.m3colors.m3error : Appearance.m3colors.m3errorContainer + readonly property color batteryLowOnBackground: Appearance.m3colors.darkmode ? Appearance.m3colors.m3errorContainer : Appearance.m3colors.m3error + + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: 32 + + RowLayout { + id: rowLayout + + spacing: 4 + anchors.centerIn: parent + + Rectangle { + implicitWidth: (isCharging ? (boltIconLoader?.item?.width ?? 0) : 0) + + Behavior on implicitWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + color: Appearance.colors.colOnLayer1 + text: `${Math.round(percentage * 100)}` + } + + CircularProgress { + enableAnimation: false + Layout.alignment: Qt.AlignVCenter + lineWidth: 2 + value: percentage + size: 26 + secondaryColor: (isLow && !isCharging) ? batteryLowBackground : Appearance.colors.colSecondaryContainer + primaryColor: (isLow && !isCharging) ? batteryLowOnBackground : Appearance.m3colors.m3onSecondaryContainer + fill: (isLow && !isCharging) + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: "battery_full" + iconSize: Appearance.font.pixelSize.normal + color: (isLow && !isCharging) ? batteryLowOnBackground : Appearance.m3colors.m3onSecondaryContainer + } + + } + + } + + Loader { + id: boltIconLoader + active: true + anchors.left: rowLayout.left + anchors.verticalCenter: rowLayout.verticalCenter + + Connections { + target: root + function onIsChargingChanged() { + if (isCharging) boltIconLoader.active = true + } + } + + sourceComponent: MaterialSymbol { + id: boltIcon + + text: "bolt" + iconSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSecondaryContainer + visible: opacity > 0 // Only show when charging + opacity: isCharging ? 1 : 0 // Keep opacity for visibility + onVisibleChanged: { + if (!visible) boltIconLoader.active = false + } + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + } + +} diff --git a/configs/quickshell/modules/bar/CircleUtilButton.qml b/configs/quickshell/modules/bar/CircleUtilButton.qml new file mode 100644 index 0000000..bd80a6c --- /dev/null +++ b/configs/quickshell/modules/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/configs/quickshell/modules/bar/ClockWidget.qml b/configs/quickshell/modules/bar/ClockWidget.qml new file mode 100644 index 0000000..9f45aa0 --- /dev/null +++ b/configs/quickshell/modules/bar/ClockWidget.qml @@ -0,0 +1,41 @@ +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: 32 + + 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.date + } + + } + +} diff --git a/configs/quickshell/modules/bar/Media.qml b/configs/quickshell/modules/bar/Media.qml new file mode 100644 index 0000000..f3e70a8 --- /dev/null +++ b/configs/quickshell/modules/bar/Media.qml @@ -0,0 +1,85 @@ +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: 1000 + 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 + + CircularProgress { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: rowLayout.spacing + lineWidth: 2 + value: activePlayer?.position / activePlayer?.length + size: 26 + secondaryColor: Appearance.colors.colSecondaryContainer + primaryColor: Appearance.m3colors.m3onSecondaryContainer + enableAnimation: false + + 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/configs/quickshell/modules/bar/Resource.qml b/configs/quickshell/modules/bar/Resource.qml new file mode 100644 index 0000000..eb3683d --- /dev/null +++ b/configs/quickshell/modules/bar/Resource.qml @@ -0,0 +1,58 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { + required property string iconName + required property double percentage + property bool shown: true + clip: true + visible: width > 0 && height > 0 + implicitWidth: resourceRowLayout.x < 0 ? 0 : childrenRect.width + implicitHeight: childrenRect.height + + RowLayout { + spacing: 4 + id: resourceRowLayout + x: shown ? 0 : -resourceRowLayout.width + + CircularProgress { + Layout.alignment: Qt.AlignVCenter + lineWidth: 2 + value: percentage + size: 26 + secondaryColor: Appearance.colors.colSecondaryContainer + primaryColor: Appearance.m3colors.m3onSecondaryContainer + enableAnimation: false + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: iconName + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + color: Appearance.colors.colOnLayer1 + text: `${Math.round(percentage * 100)}` + } + + Behavior on x { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/bar/Resources.qml b/configs/quickshell/modules/bar/Resources.qml new file mode 100644 index 0000000..f57372a --- /dev/null +++ b/configs/quickshell/modules/bar/Resources.qml @@ -0,0 +1,47 @@ +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 alwaysShowAllResources: false + implicitWidth: rowLayout.implicitWidth + rowLayout.anchors.leftMargin + rowLayout.anchors.rightMargin + implicitHeight: 32 + + RowLayout { + id: rowLayout + + spacing: 0 + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + + Resource { + iconName: "memory" + percentage: ResourceUsage.memoryUsedPercentage + } + + Resource { + iconName: "swap_horiz" + percentage: ResourceUsage.swapUsedPercentage + shown: (Config.options.bar.resources.alwaysShowSwap && percentage > 0) || + (MprisController.activePlayer?.trackTitle == null) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 4 : 0 + } + + Resource { + iconName: "settings_slow_motion" + percentage: ResourceUsage.cpuUsage + shown: Config.options.bar.resources.alwaysShowCpu || + !(MprisController.activePlayer?.trackTitle?.length > 0) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 4 : 0 + } + + } + +} diff --git a/configs/quickshell/modules/bar/ScrollHint.qml b/configs/quickshell/modules/bar/ScrollHint.qml new file mode 100644 index 0000000..a8e1c8d --- /dev/null +++ b/configs/quickshell/modules/bar/ScrollHint.qml @@ -0,0 +1,57 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Revealer { // Scroll hint + id: root + property string icon + property string side: "left" + property string tooltipText: "" + + MouseArea { + anchors.right: root.side === "left" ? parent.right : undefined + anchors.left: root.side === "right" ? parent.left : undefined + implicitWidth: contentColumnLayout.implicitWidth + implicitHeight: contentColumnLayout.implicitHeight + property bool hovered: false + + hoverEnabled: true + onEntered: hovered = true + onExited: hovered = false + acceptedButtons: Qt.NoButton + + // StyledToolTip { + // extraVisibleCondition: tooltipText.length > 0 + // content: tooltipText + // } + + ColumnLayout { + id: contentColumnLayout + anchors.centerIn: parent + spacing: -5 + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: "keyboard_arrow_up" + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: root.icon + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: "keyboard_arrow_down" + iconSize: 14 + color: Appearance.colors.colSubtext + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/bar/SysTray.qml b/configs/quickshell/modules/bar/SysTray.qml new file mode 100644 index 0000000..34919a3 --- /dev/null +++ b/configs/quickshell/modules/bar/SysTray.qml @@ -0,0 +1,47 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.SystemTray + +// TODO: More fancy animation +Item { + id: root + + required property var bar + + height: parent.height + implicitWidth: rowLayout.implicitWidth + Layout.leftMargin: Appearance.rounding.screenRounding + + RowLayout { + id: rowLayout + + anchors.fill: parent + spacing: 15 + + Repeater { + model: SystemTray.items + + SysTrayItem { + required property SystemTrayItem modelData + + bar: root.bar + item: modelData + } + + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colSubtext + text: "โ€ข" + visible: { + SystemTray.items.values.length > 0 + } + } + + } + +} diff --git a/configs/quickshell/modules/bar/SysTrayItem.qml b/configs/quickshell/modules/bar/SysTrayItem.qml new file mode 100644 index 0000000..9696c49 --- /dev/null +++ b/configs/quickshell/modules/bar/SysTrayItem.qml @@ -0,0 +1,72 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +MouseArea { + id: root + + required property var bar + required property SystemTrayItem item + property bool targetMenuOpen: false + property int trayItemWidth: Appearance.font.pixelSize.larger + + acceptedButtons: Qt.LeftButton | Qt.RightButton + Layout.fillHeight: true + implicitWidth: trayItemWidth + onClicked: (event) => { + switch (event.button) { + case Qt.LeftButton: + item.activate(); + break; + case Qt.RightButton: + if (item.hasMenu) menu.open(); + break; + } + event.accepted = true; + } + + QsMenuAnchor { + id: menu + + menu: root.item.menu + anchor.window: bar + anchor.rect.x: root.x + bar.width + anchor.rect.y: root.y + anchor.rect.height: root.height + anchor.edges: Edges.Bottom + } + + IconImage { + id: trayIcon + visible: !Config.options.bar.tray.monochromeIcons + source: root.item.icon + anchors.centerIn: parent + width: parent.width + height: parent.height + } + + Loader { + active: Config.options.bar.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) + } + } + } + +} diff --git a/configs/quickshell/modules/bar/UtilButtons.qml b/configs/quickshell/modules/bar/UtilButtons.qml new file mode 100644 index 0000000..a9ecbb5 --- /dev/null +++ b/configs/quickshell/modules/bar/UtilButtons.qml @@ -0,0 +1,142 @@ +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("screenshot.qml")]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "screenshot_region" + 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 "battery_saver" + case PowerProfile.Balanced: return "dynamic_form" + case PowerProfile.Performance: return "speed" + } + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + } +} diff --git a/configs/quickshell/modules/bar/Workspaces.qml b/configs/quickshell/modules/bar/Workspaces.qml new file mode 100644 index 0000000..8758fe5 --- /dev/null +++ b/configs/quickshell/modules/bar/Workspaces.qml @@ -0,0 +1,282 @@ +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.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +Item { + required property var bar + property bool borderless: Config.options.bar.borderless + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.screen) + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / Config.options.bar.workspaces.shown) + property list workspaceOccupied: [] + property int widgetPadding: 4 + property int workspaceButtonWidth: 26 + 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) % Config.options.bar.workspaces.shown + + // Function to update workspaceOccupied + function updateWorkspaceOccupied() { + workspaceOccupied = Array.from({ length: Config.options.bar.workspaces.shown }, (_, i) => { + return Hyprland.workspaces.values.some(ws => ws.id === workspaceGroup * Config.options.bar.workspaces.shown + i + 1); + }) + } + + // Initialize workspaceOccupied when the component is created + Component.onCompleted: updateWorkspaceOccupied() + + // Listen for changes in Hyprland.workspaces.values + Connections { + target: Hyprland.workspaces + function onValuesChanged() { + updateWorkspaceOccupied(); + } + } + + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: 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 + RowLayout { + id: rowLayout + z: 1 + + spacing: 0 + anchors.fill: parent + implicitHeight: Appearance.sizes.barHeight + + Repeater { + model: Config.options.bar.workspaces.shown + + Rectangle { + z: 1 + implicitWidth: workspaceButtonWidth + implicitHeight: workspaceButtonWidth + radius: Appearance.rounding.full + property var leftOccupied: (workspaceOccupied[index-1] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index)) + property var rightOccupied: (workspaceOccupied[index+1] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index+2)) + property var radiusLeft: leftOccupied ? 0 : Appearance.rounding.full + property var radiusRight: rightOccupied ? 0 : Appearance.rounding.full + + topLeftRadius: radiusLeft + bottomLeftRadius: radiusLeft + topRightRadius: radiusRight + bottomRightRadius: radiusRight + + 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 radiusLeft { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on radiusRight { + 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 + property real activeWorkspaceMargin: 2 + implicitHeight: workspaceButtonWidth - activeWorkspaceMargin * 2 + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + anchors.verticalCenter: parent.verticalCenter + + property real idx1: workspaceIndexInGroup + property real idx2: workspaceIndexInGroup + x: Math.min(idx1, idx2) * workspaceButtonWidth + activeWorkspaceMargin + implicitWidth: Math.abs(idx1 - idx2) * workspaceButtonWidth + workspaceButtonWidth - activeWorkspaceMargin * 2 + + Behavior on activeWorkspaceMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on idx1 { // Leading anim + NumberAnimation { + duration: 100 + easing.type: Easing.OutSine + } + } + Behavior on idx2 { // Following anim + NumberAnimation { + duration: 300 + easing.type: Easing.OutSine + } + } + } + + // Workspaces - numbers + RowLayout { + id: rowLayoutNumbers + z: 3 + + spacing: 0 + anchors.fill: parent + implicitHeight: Appearance.sizes.barHeight + + Repeater { + model: Config.options.bar.workspaces.shown + + Button { + id: button + property int workspaceValue: workspaceGroup * Config.options.bar.workspaces.shown + index + 1 + Layout.fillHeight: true + onPressed: Hyprland.dispatch(`workspace ${workspaceValue}`) + width: workspaceButtonWidth + + 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: GlobalStates.workspaceShowNumbers + || ((Config.options?.bar.workspaces.alwaysShowNumbers && (!Config.options?.bar.workspaces.showAppIcons || !workspaceButtonBackground.biggestWindow || GlobalStates.workspaceShowNumbers)) + || (GlobalStates.workspaceShowNumbers && !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) + text: `${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 + || GlobalStates.workspaceShowNumbers + || (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 && !GlobalStates.workspaceShowNumbers && 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: (!GlobalStates.workspaceShowNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + anchors.rightMargin: (!GlobalStates.workspaceShowNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + + source: workspaceButtonBackground.mainAppIconSource + implicitSize: (!GlobalStates.workspaceShowNumbers && 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/configs/quickshell/modules/bar/weather/WeatherBar.qml b/configs/quickshell/modules/bar/weather/WeatherBar.qml new file mode 100644 index 0000000..363d9ba --- /dev/null +++ b/configs/quickshell/modules/bar/weather/WeatherBar.qml @@ -0,0 +1,60 @@ +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 real margin: 10 + property bool hovered: false + implicitWidth: rowLayout.implicitWidth + margin * 2 + implicitHeight: rowLayout.implicitHeight + + hoverEnabled: true + + RowLayout { + id: rowLayout + anchors.centerIn: parent + + MaterialSymbol { + fill: 0 + text: WeatherIcons.codeToName[Weather.data.wCode] + 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 + } + } + + LazyLoader { + id: popupLoader + active: root.containsMouse + + component: PopupWindow { + id: popupWindow + visible: true + implicitWidth: weatherPopup.implicitWidth + implicitHeight: weatherPopup.implicitHeight + anchor.item: root + anchor.edges: Edges.Top + anchor.rect.x: (root.implicitWidth - popupWindow.implicitWidth) / 2 + anchor.rect.y: Config.options.bar.bottom ? + (-weatherPopup.implicitHeight - 15) : + (root.implicitHeight + 15 ) + color: "transparent" + WeatherPopup { + id: weatherPopup + } + } + } +} diff --git a/configs/quickshell/modules/bar/weather/WeatherCard.qml b/configs/quickshell/modules/bar/weather/WeatherCard.qml new file mode 100644 index 0000000..a85ed8c --- /dev/null +++ b/configs/quickshell/modules/bar/weather/WeatherCard.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts + +import qs.modules.common +import qs.modules.common.widgets + +Rectangle { + id: root + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: columnLayout.implicitWidth * 2 + implicitHeight: columnLayout.implicitHeight * 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 + } + StyledText { + id: title + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer1 + } + } + StyledText { + id: value + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer1 + } + } +} diff --git a/configs/quickshell/modules/bar/weather/WeatherIcons.qml b/configs/quickshell/modules/bar/weather/WeatherIcons.qml new file mode 100644 index 0000000..bd74d4e --- /dev/null +++ b/configs/quickshell/modules/bar/weather/WeatherIcons.qml @@ -0,0 +1,59 @@ +pragma Singleton + +import Quickshell + +Singleton { + // credits: calestia + // this snippet is taken from + // https://github.com/caelestia-dots/shell + readonly property var codeToName: ({ + "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" + }) +} diff --git a/configs/quickshell/modules/bar/weather/WeatherPopup.qml b/configs/quickshell/modules/bar/weather/WeatherPopup.qml new file mode 100644 index 0000000..827abea --- /dev/null +++ b/configs/quickshell/modules/bar/weather/WeatherPopup.qml @@ -0,0 +1,97 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + readonly property real margin: 10 + implicitWidth: columnLayout.implicitWidth + margin * 2 + implicitHeight: columnLayout.implicitHeight + margin * 2 + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.small + border.width: 1 + border.color: Appearance.colors.colLayer0Border + clip: true + + ColumnLayout { + id: columnLayout + spacing: 5 + anchors.centerIn: root + implicitWidth: Math.max(header.implicitWidth, gridLayout.implicitWidth) + implicitHeight: gridLayout.implicitHeight + + // Header + RowLayout { + id: header + spacing: 5 + Layout.fillWidth: parent + Layout.alignment: Qt.AlignHCenter + MaterialSymbol { + fill: 0 + text: "location_on" + iconSize: Appearance.font.pixelSize.huge + } + + StyledText { + text: Weather.data.city + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + color: Appearance.colors.colOnLayer0 + } + } + + // 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/configs/quickshell/modules/cheatsheet/Cheatsheet.qml b/configs/quickshell/modules/cheatsheet/Cheatsheet.qml new file mode 100644 index 0000000..6009392 --- /dev/null +++ b/configs/quickshell/modules/cheatsheet/Cheatsheet.qml @@ -0,0 +1,236 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property var tabButtonList: [ + { + "icon": "keyboard", + "name": Translation.tr("Keybinds") + }, + { + "icon": "experiment", + "name": Translation.tr("Elements") + }, + ] + property int selectedTab: 0 + + Loader { + id: cheatsheetLoader + active: false + + sourceComponent: PanelWindow { // Window + id: cheatsheetRoot + visible: cheatsheetLoader.active + + anchors { + top: true + bottom: true + left: true + right: true + } + + function hide() { + cheatsheetLoader.active = false; + } + exclusiveZone: 0 + implicitWidth: cheatsheetBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: cheatsheetBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:cheatsheet" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: cheatsheetBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [cheatsheetRoot] + active: cheatsheetRoot.visible + onCleared: () => { + if (!active) + cheatsheetRoot.hide(); + } + } + + // Background + StyledRectangularShadow { + target: cheatsheetBackground + } + Rectangle { + id: cheatsheetBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.windowRounding + property real padding: 30 + implicitWidth: cheatsheetColumnLayout.implicitWidth + padding * 2 + implicitHeight: cheatsheetColumnLayout.implicitHeight + padding * 2 + + Keys.onPressed: event => { // Esc to close + if (event.key === Qt.Key_Escape) { + cheatsheetRoot.hide(); + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1); + event.accepted = true; + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0); + event.accepted = true; + } else if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length; + event.accepted = true; + } else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length; + event.accepted = true; + } + } + } + + RippleButton { // Close button + id: closeButton + focus: cheatsheetRoot.visible + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.full + anchors { + top: parent.top + right: parent.right + topMargin: 20 + rightMargin: 20 + } + + onClicked: { + cheatsheetRoot.hide(); + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.title + text: "close" + } + } + + ColumnLayout { // Real content + id: cheatsheetColumnLayout + anchors.centerIn: parent + spacing: 20 + + StyledText { + id: cheatsheetTitle + Layout.alignment: Qt.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.title + text: Translation.tr("Cheat sheet") + } + PrimaryTabBar { // Tab strip + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex; + } + } + + SwipeView { // Content pages + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + Behavior on implicitWidth { + id: contentWidthBehavior + enabled: false + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + id: contentHeightBehavior + enabled: false + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + currentIndex: tabBar.externalTrackedTab + onCurrentIndexChanged: { + contentWidthBehavior.enabled = true; + contentHeightBehavior.enabled = true; + tabBar.enableIndicatorAnimation = true; + root.selectedTab = currentIndex; + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + CheatsheetKeybinds {} + CheatsheetPeriodicTable {} + } + } + } + } + } + + IpcHandler { + target: "cheatsheet" + + function toggle(): void { + cheatsheetLoader.active = !cheatsheetLoader.active; + } + + function close(): void { + cheatsheetLoader.active = false; + } + + function open(): void { + cheatsheetLoader.active = true; + } + } + + GlobalShortcut { + name: "cheatsheetToggle" + description: "Toggles cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = !cheatsheetLoader.active; + } + } + + GlobalShortcut { + name: "cheatsheetOpen" + description: "Opens cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = true; + } + } + + GlobalShortcut { + name: "cheatsheetClose" + description: "Closes cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = false; + } + } +} diff --git a/configs/quickshell/modules/cheatsheet/CheatsheetKeybinds.qml b/configs/quickshell/modules/cheatsheet/CheatsheetKeybinds.qml new file mode 100644 index 0000000..e0e8ce6 --- /dev/null +++ b/configs/quickshell/modules/cheatsheet/CheatsheetKeybinds.qml @@ -0,0 +1,144 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +Item { + id: root + readonly property var keybinds: HyprlandKeybinds.keybinds + property real spacing: 20 + property real titleSpacing: 7 + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + + property var keyBlacklist: ["Super_L"] + property var keySubstitutions: ({ + "Super": "๓ฐ–ณ", + "mouse_up": "Scroll โ†“", // ikr, weird + "mouse_down": "Scroll โ†‘", // trust me bro + "mouse:272": "LMB", + "mouse:273": "RMB", + "mouse:275": "MouseBack", + "Slash": "/", + "Hash": "#", + "Return": "Enter", + // "Shift": "๏ข", + }) + + RowLayout { // Keybind columns + id: rowLayout + spacing: root.spacing + Repeater { + model: keybinds.children + + delegate: ColumnLayout { // Keybind sections + spacing: root.spacing + required property var modelData + Layout.alignment: Qt.AlignTop + Repeater { + model: modelData.children + + delegate: Item { // Section with real keybinds + required property var modelData + implicitWidth: sectionColumnLayout.implicitWidth + implicitHeight: sectionColumnLayout.implicitHeight + ColumnLayout { + id: sectionColumnLayout + anchors.centerIn: parent + spacing: root.titleSpacing + StyledText { + id: sectionTitle + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnLayer0 + text: modelData.name + } + + GridLayout { + id: keybindGrid + columns: 2 + Repeater { + model: { + var result = []; + for (var i = 0; i < modelData.keybinds.length; i++) { + const keybind = modelData.keybinds[i]; + result.push({ + "type": "keys", + "mods": keybind.mods, + "key": keybind.key, + }); + result.push({ + "type": "comment", + "comment": keybind.comment, + }); + } + return result; + } + delegate: Item { + required property var modelData + implicitWidth: keybindLoader.implicitWidth + implicitHeight: keybindLoader.implicitHeight + + Loader { + id: keybindLoader + sourceComponent: (modelData.type === "keys") ? keysComponent : commentComponent + } + + Component { + id: keysComponent + RowLayout { + spacing: 4 + Repeater { + model: modelData.mods + delegate: KeyboardKey { + required property var modelData + key: keySubstitutions[modelData] || modelData + } + } + StyledText { + id: keybindPlus + visible: !keyBlacklist.includes(modelData.key) && modelData.mods.length > 0 + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + id: keybindKey + visible: !keyBlacklist.includes(modelData.key) + key: keySubstitutions[modelData.key] || modelData.key + color: Appearance.colors.colOnLayer0 + } + } + } + + Component { + id: commentComponent + Item { + id: commentItem + implicitWidth: commentText.implicitWidth + 8 * 2 + implicitHeight: commentText.implicitHeight + + StyledText { + id: commentText + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + text: modelData.comment + } + } + } + } + + } + } + } + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/modules/cheatsheet/CheatsheetPeriodicTable.qml b/configs/quickshell/modules/cheatsheet/CheatsheetPeriodicTable.qml new file mode 100644 index 0000000..a0a8ecf --- /dev/null +++ b/configs/quickshell/modules/cheatsheet/CheatsheetPeriodicTable.qml @@ -0,0 +1,68 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "periodic_table.js" as PTable +import QtQuick +import QtQuick.Layouts + +Item { + id: root + readonly property var elements: PTable.elements + readonly property var series: PTable.series + property real spacing: 6 + implicitWidth: mainLayout.implicitWidth + implicitHeight: mainLayout.implicitHeight + + ColumnLayout { + id: mainLayout + spacing: root.spacing + + Repeater { // Main table rows + model: root.elements + + delegate: RowLayout { // Table cells + id: tableRow + spacing: root.spacing + required property var modelData + + Repeater { + model: tableRow.modelData + delegate: ElementTile { + required property var modelData + element: modelData + } + + } + } + + } + + Item { + id: gap + implicitHeight: 20 + } + + Repeater { // Main table rows + model: root.series + + delegate: RowLayout { // Table cells + id: seriesTableRow + spacing: root.spacing + required property var modelData + + Repeater { + model: seriesTableRow.modelData + delegate: ElementTile { + required property var modelData + element: modelData + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/modules/cheatsheet/ElementTile.qml b/configs/quickshell/modules/cheatsheet/ElementTile.qml new file mode 100644 index 0000000..70e7b4d --- /dev/null +++ b/configs/quickshell/modules/cheatsheet/ElementTile.qml @@ -0,0 +1,55 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: root + required property var element + opacity: element.type != "empty" ? 1 : 0 + implicitHeight: 60 + implicitWidth: 60 + colBackground: Appearance.colors.colLayer2 + buttonRadius: Appearance.rounding.small + + Rectangle { + anchors { + top: parent.top + left: parent.left + topMargin: 4 + leftMargin: 4 + } + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.full + implicitWidth: Math.max(20, elementNumber.implicitWidth) + implicitHeight: Math.max(20, elementNumber.implicitHeight) + width: height + + StyledText { + id: elementNumber + anchors.centerIn: parent + color: Appearance.colors.colOnLayer2 + text: root.element.number + font.pixelSize: Appearance.font.pixelSize.smallest + } + } + + StyledText { + id: elementSymbol + anchors.centerIn: parent + color: Appearance.colors.colSecondary + font.pixelSize: Appearance.font.pixelSize.huge + text: root.element.symbol + } + + StyledText { + id: elementName + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 4 + } + font.pixelSize: Appearance.font.pixelSize.smallest + color: Appearance.colors.colOnLayer2 + text: root.element.name + } +} diff --git a/configs/quickshell/modules/cheatsheet/periodic_table.js b/configs/quickshell/modules/cheatsheet/periodic_table.js new file mode 100644 index 0000000..45d69cc --- /dev/null +++ b/configs/quickshell/modules/cheatsheet/periodic_table.js @@ -0,0 +1,196 @@ +// List of rows +const elements = [ + [ + { name: 'Hydrogen', symbol: 'H', number: 1, weight: 1.01, type: 'nonmetal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Helium', symbol: 'He', number: 2, weight: 4.00, type: 'noblegas' }, + ], + [ + { name: 'Lithium', symbol: 'Li', number: 3, weight: 6.94, type: 'metal' }, + { name: 'Beryllium', symbol: 'Be', number: 4, weight: 9.01, type: 'metal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Boron', symbol: 'B', number: 5, weight: 10.81, type: 'nonmetal' }, + { name: 'Carbon', symbol: 'C', number: 6, weight: 12.01, type: 'nonmetal' }, + { name: 'Nitrogen', symbol: 'N', number: 7, weight: 14.01, type: 'nonmetal' }, + { name: 'Oxygen', symbol: 'O', number: 8, weight: 16, type: 'nonmetal' }, + { name: 'Fluorine', symbol: 'F', number: 9, weight: 19, type: 'nonmetal' }, + { name: 'Neon', symbol: 'Ne', number: 10, weight: 20.18, type: 'noblegas' }, + + + ], + [ + { name: 'Sodium', symbol: 'Na', number: 11, weight: 22.99, type: 'metal' }, + { name: 'Magnesium', symbol: 'Mg', number: 12, weight: 24.31, type: 'metal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Aluminum', symbol: 'Al', number: 13, weight: 26.98, type: 'metal' }, + { name: 'Silicon', symbol: 'Si', number: 14, weight: 28.09, type: 'nonmetal' }, + { name: 'Phosphorus', symbol: 'P', number: 15, weight: 30.97, type: 'nonmetal' }, + { name: 'Sulfur', symbol: 'S', number: 16, weight: 32.07, type: 'nonmetal' }, + { name: 'Chlorine', symbol: 'Cl', number: 17, weight: 35.45, type: 'nonmetal' }, + { name: 'Argon', symbol: 'Ar', number: 18, weight: 39.95, type: 'noblegas' }, + ], + [ + { name: 'Potassium', symbol: 'K', number: 19, weight: 39.098, type: 'metal' }, + { name: 'Calcium', symbol: 'Ca', number: 20, weight: 40.078, type: 'metal' }, + { name: 'Scandium', symbol: 'Sc', number: 21, weight: 44.956, type: 'metal' }, + { name: 'Titanium', symbol: 'Ti', number: 22, weight: 47.87, type: 'metal' }, + { name: 'Vanadium', symbol: 'V', number: 23, weight: 50.94, type: 'metal' }, + { name: 'Chromium', symbol: 'Cr', number: 24, weight: 52, type: 'metal'/*, icon: 'chromium-browser'*/ }, + { name: 'Manganese', symbol: 'Mn', number: 25, weight: 54.94, type: 'metal' }, + { name: 'Iron', symbol: 'Fe', number: 26, weight: 55.85, type: 'metal' }, + { name: 'Cobalt', symbol: 'Co', number: 27, weight: 58.93, type: 'metal' }, + { name: 'Nickel', symbol: 'Ni', number: 28, weight: 58.69, type: 'metal' }, + { name: 'Copper', symbol: 'Cu', number: 29, weight: 63.55, type: 'metal' }, + { name: 'Zinc', symbol: 'Zn', number: 30, weight: 65.38, type: 'metal' }, + { name: 'Gallium', symbol: 'Ga', number: 31, weight: 69.72, type: 'metal' }, + { name: 'Germanium', symbol: 'Ge', number: 32, weight: 72.63, type: 'metal' }, + { name: 'Arsenic', symbol: 'As', number: 33, weight: 74.92, type: 'nonmetal' }, + { name: 'Selenium', symbol: 'Se', number: 34, weight: 78.96, type: 'nonmetal' }, + { name: 'Bromine', symbol: 'Br', number: 35, weight: 79.904, type: 'nonmetal' }, + { name: 'Krypton', symbol: 'Kr', number: 36, weight: 83.8, type: 'noblegas' }, + ], + [ + { name: 'Rubidium', symbol: 'Rb', number: 37, weight: 85.47, type: 'metal' }, + { name: 'Strontium', symbol: 'Sr', number: 38, weight: 87.62, type: 'metal' }, + { name: 'Yttrium', symbol: 'Y', number: 39, weight: 88.91, type: 'metal' }, + { name: 'Zirconium', symbol: 'Zr', number: 40, weight: 91.22, type: 'metal' }, + { name: 'Niobium', symbol: 'Nb', number: 41, weight: 92.91, type: 'metal' }, + { name: 'Molybdenum', symbol: 'Mo', number: 42, weight: 95.94, type: 'metal' }, + { name: 'Technetium', symbol: 'Tc', number: 43, weight: 98, type: 'metal' }, + { name: 'Ruthenium', symbol: 'Ru', number: 44, weight: 101.07, type: 'metal' }, + { name: 'Rhodium', symbol: 'Rh', number: 45, weight: 102.91, type: 'metal' }, + { name: 'Palladium', symbol: 'Pd', number: 46, weight: 106.42, type: 'metal' }, + { name: 'Silver', symbol: 'Ag', number: 47, weight: 107.87, type: 'metal' }, + { name: 'Cadmium', symbol: 'Cd', number: 48, weight: 112.41, type: 'metal' }, + { name: 'Indium', symbol: 'In', number: 49, weight: 114.82, type: 'metal' }, + { name: 'Tin', symbol: 'Sn', number: 50, weight: 118.71, type: 'metal' }, + { name: 'Antimony', symbol: 'Sb', number: 51, weight: 121.76, type: 'metal' }, + { name: 'Tellurium', symbol: 'Te', number: 52, weight: 127.6, type: 'nonmetal' }, + { name: 'Iodine', symbol: 'I', number: 53, weight: 126.9, type: 'nonmetal' }, + { name: 'Xenon', symbol: 'Xe', number: 54, weight: 131.29, type: 'noblegas' }, + ], + [ + { name: 'Cesium', symbol: 'Cs', number: 55, weight: 132.91, type: 'metal' }, + { name: 'Barium', symbol: 'Ba', number: 56, weight: 137.33, type: 'metal' }, + { name: 'Lanthanum', symbol: 'La', number: 57, weight: 138.91, type: 'lanthanum' }, + { name: 'Hafnium', symbol: 'Hf', number: 72, weight: 178.49, type: 'metal' }, + { name: 'Tantalum', symbol: 'Ta', number: 73, weight: 180.95, type: 'metal' }, + { name: 'Tungsten', symbol: 'W', number: 74, weight: 183.84, type: 'metal' }, + { name: 'Rhenium', symbol: 'Re', number: 75, weight: 186.21, type: 'metal' }, + { name: 'Osmium', symbol: 'Os', number: 76, weight: 190.23, type: 'metal' }, + { name: 'Iridium', symbol: 'Ir', number: 77, weight: 192.22, type: 'metal' }, + { name: 'Platinum', symbol: 'Pt', number: 78, weight: 195.09, type: 'metal' }, + { name: 'Gold', symbol: 'Au', number: 79, weight: 196.97, type: 'metal' }, + { name: 'Mercury', symbol: 'Hg', number: 80, weight: 200.59, type: 'metal' }, + { name: 'Thallium', symbol: 'Tl', number: 81, weight: 204.38, type: 'metal' }, + { name: 'Lead', symbol: 'Pb', number: 82, weight: 207.2, type: 'metal' }, + { name: 'Bismuth', symbol: 'Bi', number: 83, weight: 208.98, type: 'metal' }, + { name: 'Polonium', symbol: 'Po', number: 84, weight: 209, type: 'metal' }, + { name: 'Astatine', symbol: 'At', number: 85, weight: 210, type: 'nonmetal' }, + { name: 'Radon', symbol: 'Rn', number: 86, weight: 222, type: 'noblegas' }, + ], + [ + { name: 'Francium', symbol: 'Fr', number: 87, weight: 223, type: 'metal' }, + { name: 'Radium', symbol: 'Ra', number: 88, weight: 226, type: 'metal' }, + { name: 'Actinium', symbol: 'Ac', number: 89, weight: 227, type: 'actinium' }, + { name: 'Rutherfordium', symbol: 'Rf', number: 104, weight: 267, type: 'metal' }, + { name: 'Dubnium', symbol: 'Db', number: 105, weight: 268, type: 'metal' }, + { name: 'Seaborgium', symbol: 'Sg', number: 106, weight: 271, type: 'metal' }, + { name: 'Bohrium', symbol: 'Bh', number: 107, weight: 272, type: 'metal' }, + { name: 'Hassium', symbol: 'Hs', number: 108, weight: 277, type: 'metal' }, + { name: 'Meitnerium', symbol: 'Mt', number: 109, weight: 278, type: 'metal' }, + { name: 'Darmstadtium', symbol: 'Ds', number: 110, weight: 281, type: 'metal' }, + { name: 'Roentgenium', symbol: 'Rg', number: 111, weight: 280, type: 'metal' }, + { name: 'Copernicium', symbol: 'Cn', number: 112, weight: 285, type: 'metal' }, + { name: 'Nihonium', symbol: 'Nh', number: 113, weight: 286, type: 'metal' }, + { name: 'Flerovium', symbol: 'Fl', number: 114, weight: 289, type: 'metal' }, + { name: 'Moscovium', symbol: 'Mc', number: 115, weight: 290, type: 'metal' }, + { name: 'Livermorium', symbol: 'Lv', number: 116, weight: 293, type: 'metal' }, + { name: 'Tennessine', symbol: 'Ts', number: 117, weight: 294, type: 'metal' }, + { name: 'Oganesson', symbol: 'Og', number: 118, weight: 294, type: 'noblegas' }, + ], +] + +const series = [ + [ + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Cerium', symbol: 'Ce', number: 58, weight: 140.12, type: 'lanthanum' }, + { name: 'Praseodymium', symbol: 'Pr', number: 59, weight: 140.91, type: 'lanthanum' }, + { name: 'Neodymium', symbol: 'Nd', number: 60, weight: 144.24, type: 'lanthanum' }, + { name: 'Promethium', symbol: 'Pm', number: 61, weight: 145, type: 'lanthanum' }, + { name: 'Samarium', symbol: 'Sm', number: 62, weight: 150.36, type: 'lanthanum' }, + { name: 'Europium', symbol: 'Eu', number: 63, weight: 151.96, type: 'lanthanum' }, + { name: 'Gadolinium', symbol: 'Gd', number: 64, weight: 157.25, type: 'lanthanum' }, + { name: 'Terbium', symbol: 'Tb', number: 65, weight: 158.93, type: 'lanthanum' }, + { name: 'Dysprosium', symbol: 'Dy', number: 66, weight: 162.5, type: 'lanthanum' }, + { name: 'Holmium', symbol: 'Ho', number: 67, weight: 164.93, type: 'lanthanum' }, + { name: 'Erbium', symbol: 'Er', number: 68, weight: 167.26, type: 'lanthanum' }, + { name: 'Thulium', symbol: 'Tm', number: 69, weight: 168.93, type: 'lanthanum' }, + { name: 'Ytterbium', symbol: 'Yb', number: 70, weight: 173.04, type: 'lanthanum' }, + { name: 'Lutetium', symbol: 'Lu', number: 71, weight: 174.97, type: 'lanthanum' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + ], + [ + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Thorium', symbol: 'Th', number: 90, weight: 232.04, type: 'actinium' }, + { name: 'Protactinium', symbol: 'Pa', number: 91, weight: 231.04, type: 'actinium' }, + { name: 'Uranium', symbol: 'U', number: 92, weight: 238.03, type: 'actinium' }, + { name: 'Neptunium', symbol: 'Np', number: 93, weight: 237, type: 'actinium' }, + { name: 'Plutonium', symbol: 'Pu', number: 94, weight: 244, type: 'actinium' }, + { name: 'Americium', symbol: 'Am', number: 95, weight: 243, type: 'actinium' }, + { name: 'Curium', symbol: 'Cm', number: 96, weight: 247, type: 'actinium' }, + { name: 'Berkelium', symbol: 'Bk', number: 97, weight: 247, type: 'actinium' }, + { name: 'Californium', symbol: 'Cf', number: 98, weight: 251, type: 'actinium' }, + { name: 'Einsteinium', symbol: 'Es', number: 99, weight: 252, type: 'actinium' }, + { name: 'Fermium', symbol: 'Fm', number: 100, weight: 257, type: 'actinium' }, + { name: 'Mendelevium', symbol: 'Md', number: 101, weight: 258, type: 'actinium' }, + { name: 'Nobelium', symbol: 'No', number: 102, weight: 259, type: 'actinium' }, + { name: 'Lawrencium', symbol: 'Lr', number: 103, weight: 262, type: 'actinium' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + ], +]; + +const niceTypes = { + 'metal': "Metal", + 'nonmetal': "Nonmetal", + 'noblegas': "Noble gas", + 'lanthanum': "Lanthanum", + 'actinium': "Actinium" +} diff --git a/configs/quickshell/modules/common/Appearance.qml b/configs/quickshell/modules/common/Appearance.qml new file mode 100644 index 0000000..9c93748 --- /dev/null +++ b/configs/quickshell/modules/common/Appearance.qml @@ -0,0 +1,316 @@ +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 + + // Extremely conservative transparency values for consistency and readability + property real transparency: Config.options?.appearance.transparency ? (m3colors.darkmode ? 0.1 : 0.07) : 0 + property real contentTransparency: Config.options?.appearance.transparency ? (m3colors.darkmode ? 0.55 : 0.55) : 0 + + m3colors: QtObject { + property bool darkmode: false + property bool transparent: false + property color m3primary_paletteKeyColor: "#91689E" + property color m3secondary_paletteKeyColor: "#837186" + property color m3tertiary_paletteKeyColor: "#9D6A67" + property color m3neutral_paletteKeyColor: "#7C757B" + property color m3neutral_variant_paletteKeyColor: "#7D747D" + property color m3background: "#161217" + property color m3onBackground: "#EAE0E7" + property color m3surface: "#161217" + property color m3surfaceDim: "#161217" + property color m3surfaceBright: "#3D373D" + property color m3surfaceContainerLowest: "#110D12" + property color m3surfaceContainerLow: "#1F1A1F" + property color m3surfaceContainer: "#231E23" + property color m3surfaceContainerHigh: "#2D282E" + property color m3surfaceContainerHighest: "#383339" + property color m3onSurface: "#EAE0E7" + property color m3surfaceVariant: "#4C444D" + property color m3onSurfaceVariant: "#CFC3CD" + property color m3inverseSurface: "#EAE0E7" + property color m3inverseOnSurface: "#342F34" + property color m3outline: "#988E97" + property color m3outlineVariant: "#4C444D" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#E5B6F2" + property color m3primary: "#E5B6F2" + property color m3onPrimary: "#452152" + property color m3primaryContainer: "#5D386A" + property color m3onPrimaryContainer: "#F9D8FF" + property color m3inversePrimary: "#775084" + property color m3secondary: "#D5C0D7" + property color m3onSecondary: "#392C3D" + property color m3secondaryContainer: "#534457" + property color m3onSecondaryContainer: "#F2DCF3" + property color m3tertiary: "#F5B7B3" + property color m3onTertiary: "#4C2523" + property color m3tertiaryContainer: "#BA837F" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#FFB4AB" + property color m3onError: "#690005" + property color m3errorContainer: "#93000A" + property color m3onErrorContainer: "#FFDAD6" + property color m3primaryFixed: "#F9D8FF" + property color m3primaryFixedDim: "#E5B6F2" + property color m3onPrimaryFixed: "#2E0A3C" + property color m3onPrimaryFixedVariant: "#5D386A" + property color m3secondaryFixed: "#F2DCF3" + property color m3secondaryFixedDim: "#D5C0D7" + property color m3onSecondaryFixed: "#241727" + property color m3onSecondaryFixedVariant: "#514254" + property color m3tertiaryFixed: "#FFDAD7" + property color m3tertiaryFixedDim: "#F5B7B3" + property color m3onTertiaryFixed: "#331110" + property color m3onTertiaryFixedVariant: "#663B39" + 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.transparency), 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(ColorUtils.mix(m3colors.m3surfaceContainerLow, m3colors.m3background, 0.8), root.contentTransparency); + property color colOnLayer1: m3colors.m3onSurfaceVariant; + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45); + property color colLayer2: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainer, m3colors.m3surfaceContainerHigh, 0.1), root.contentTransparency) + property color colOnLayer2: m3colors.m3onSurface; + property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4); + property color colLayer3: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainerHigh, m3colors.m3onSurface, 0.96), root.contentTransparency) + property color colOnLayer3: m3colors.m3onSurface; + 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 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 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, colLayer1Hover, 0.7) + property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colLayer1Active, 0.6) + property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer + property color colSecondary: m3colors.m3secondary + 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, colLayer1Active, 0.54) + 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 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 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 colOutlineVariant: m3colors.m3outlineVariant + } + + 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: "Rubik" + property string title: "Gabarito" + property string iconMaterial: "Material Symbols Rounded" + property string iconNerd: "SpaceMono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property QtObject pixelSize: QtObject { + property int smallest: 10 + property int smaller: 12 + 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 Component colorAnimation: Component { + ColorAnimation { + 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 { + 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 { + 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 { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + } + + property QtObject clickBounce: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveFastSpatial + property int velocity: 850 + property Component numberAnimation: Component { NumberAnimation { + duration: root.animation.clickBounce.duration + easing.type: root.animation.clickBounce.type + easing.bezierCurve: root.animation.clickBounce.bezierCurve + }} + } + property QtObject scroll: QtObject { + property int duration: 400 + 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 + Appearance.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 sidebarWidth: 460 + property real sidebarWidthExtended: 750 + property real osdWidth: 200 + property real mediaControlsWidth: 440 + property real mediaControlsHeight: 160 + property real notificationPopupWidth: 410 + property real searchWidthCollapsed: 260 + property real searchWidth: 450 + property real hyprlandGapsOut: 5 + property real elevationMargin: 10 + property real fabShadowRadius: 5 + property real fabHoveredShadowRadius: 7 + } + + syntaxHighlightingTheme: Appearance.m3colors.darkmode ? "Monokai" : "ayu Light" +} diff --git a/configs/quickshell/modules/common/Config.qml b/configs/quickshell/modules/common/Config.qml new file mode 100644 index 0000000..1b6bcd0 --- /dev/null +++ b/configs/quickshell/modules/common/Config.qml @@ -0,0 +1,272 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: 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; + } + + FileView { + path: root.filePath + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + property JsonObject policies: JsonObject { + property int ai: 1 // 0: No | 1: Yes | 2: Local + property int weeb: 1 // 0: No | 1: Open | 2: Closet + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## ๐Ÿง Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n" + property string tool: "functions" // search, functions, or none + property list extraModels: [ + { + "api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models + "description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory + "icon": "spark-symbolic", // Not mandatory + "key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory + "key_id": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b:free", + "name": "Custom: DS R1 Dstl. LLaMA 70B", + "requires_key": true + } + ] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property bool transparency: false + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + } + 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: true + property real maxAllowedIncrease: 10 + property real maxAllowed: 90 // Realistically should already provide some protection when it's 99... + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6-bluetooth" + property string network: "plasmawindowed-network" + property string networkEthernet: "kcmshell6-network" + property string taskManager: "plasma-systemmonitor --page-name Processes" + property string terminal: "kitty -1" // This is only for shell actions + } + + property JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property string wallpaperPath: "" + property string thumbnailPath: "" + property JsonObject parallax: JsonObject { + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + } + } + + property JsonObject bar: JsonObject { + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: distro, spark + property bool showBackground: true + property bool verbose: true + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: false + } + 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 JsonObject tray: JsonObject { + property bool monochromeIcons: true + } + 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 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 battery: JsonObject { + property int low: 20 + property int critical: 5 + property bool automaticSuspend: true + property int suspend: 3 + } + + 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 language: JsonObject { + 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 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 osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + 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 JsonObject resources: JsonObject { + property int updateInterval: 3000 + } + + 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"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property string action: "/" + property string clipboard: ";" + property string emojis: ":" + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string dateFormat: "ddd, dd/MM" + } + + 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 screenshotTool: JsonObject { + property bool showContentRegions: true + } + } + } +} diff --git a/configs/quickshell/modules/common/Directories.qml b/configs/quickshell/modules/common/Directories.qml new file mode 100644 index 0000000..86b955d --- /dev/null +++ b/configs/quickshell/modules/common/Directories.qml @@ -0,0 +1,49 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions as Functions +import Qt.labs.platform +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + 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 pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + + // Other dirs used by the shell, without "file://" + property string assetsPath: Quickshell.shellPath("assets") + property string scriptPath: Quickshell.shellPath("scripts") + property string favicons: Functions.FileUtils.trimFileProtocol(`${cache}/media/favicons`) + property string coverArt: Functions.FileUtils.trimFileProtocol(`${cache}/media/coverart`) + property string booruPreviews: Functions.FileUtils.trimFileProtocol(`${cache}/media/boorus`) + property string booruDownloads: Functions.FileUtils.trimFileProtocol(pictures + "/homework") + property string booruDownloadsNsfw: Functions.FileUtils.trimFileProtocol(pictures + "/homework/๐ŸŒถ๏ธ") + property string latexOutput: Functions.FileUtils.trimFileProtocol(`${cache}/media/latex`) + property string shellConfig: Functions.FileUtils.trimFileProtocol(`${config}/illogical-impulse`) + property string shellConfigName: "config.json" + property string shellConfigPath: `${shellConfig}/${shellConfigName}` + property string todoPath: Functions.FileUtils.trimFileProtocol(`${state}/user/todo.json`) + property string notificationsPath: Functions.FileUtils.trimFileProtocol(`${cache}/notifications/notifications.json`) + property string generatedMaterialThemePath: Functions.FileUtils.trimFileProtocol(`${state}/user/generated/colors.json`) + property string cliphistDecode: Functions.FileUtils.trimFileProtocol(`/tmp/quickshell/media/cliphist`) + property string screenshotTemp: "/tmp/quickshell/media/screenshot" + property string wallpaperSwitchScriptPath: Functions.FileUtils.trimFileProtocol(`${scriptPath}/colors/switchwall.sh`) + property string defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts") + property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`) + property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`) + // 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 '${booruPreviews}'; mkdir -p '${booruPreviews}'`]) + Quickshell.execDetached(["bash", "-c", `mkdir -p '${booruDownloads}' && mkdir -p '${booruDownloadsNsfw}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${latexOutput}'; mkdir -p '${latexOutput}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${cliphistDecode}'; mkdir -p '${cliphistDecode}'`]) + Quickshell.execDetached(["mkdir", "-p", `${aiChats}`]) + } +} diff --git a/configs/quickshell/modules/common/Persistent.qml b/configs/quickshell/modules/common/Persistent.qml new file mode 100644 index 0000000..abd062d --- /dev/null +++ b/configs/quickshell/modules/common/Persistent.qml @@ -0,0 +1,49 @@ +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}` + + FileView { + path: root.filePath + + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: { + writeAdapter() + } + onLoadFailed: error => { + console.log("Failed to load persistent states file:", error); + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + adapter: JsonAdapter { + id: persistentStatesJsonAdapter + 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 booru: JsonObject { + property bool allowNsfw: false + property string provider: "yandere" + } + } + } +} diff --git a/configs/quickshell/modules/common/functions/ColorUtils.qml b/configs/quickshell/modules/common/functions/ColorUtils.qml new file mode 100644 index 0000000..27d4818 --- /dev/null +++ b/configs/quickshell/modules/common/functions/ColorUtils.qml @@ -0,0 +1,114 @@ +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)); + } +} diff --git a/configs/quickshell/modules/common/functions/FileUtils.qml b/configs/quickshell/modules/common/functions/FileUtils.qml new file mode 100644 index 0000000..c051674 --- /dev/null +++ b/configs/quickshell/modules/common/functions/FileUtils.qml @@ -0,0 +1,41 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } + + /** + * 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(); + } + + /** + * 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; + } +} diff --git a/configs/quickshell/modules/common/functions/Fuzzy.qml b/configs/quickshell/modules/common/functions/Fuzzy.qml new file mode 100644 index 0000000..7a132ad --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/functions/Levendist.qml b/configs/quickshell/modules/common/functions/Levendist.qml new file mode 100644 index 0000000..a327c3c --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/functions/ObjectUtils.qml b/configs/quickshell/modules/common/functions/ObjectUtils.qml new file mode 100644 index 0000000..d1204cd --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/functions/StringUtils.qml b/configs/quickshell/modules/common/functions/StringUtils.qml new file mode 100644 index 0000000..e824183 --- /dev/null +++ b/configs/quickshell/modules/common/functions/StringUtils.qml @@ -0,0 +1,221 @@ +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 + */ + 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) { + // escape single quotes + return String(str) + // .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); + } + + /** + * Splits markdown blocks into three different types: text, think, and code. + * @param { string } markdown + */ + 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"); + } + + 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(); + } + + 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')}`; + } + } + + function escapeHtml(str) { + if (typeof str !== 'string') + return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } +} diff --git a/configs/quickshell/modules/common/functions/fuzzysort.js b/configs/quickshell/modules/common/functions/fuzzysort.js new file mode 100644 index 0000000..1c1d9b9 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/functions/levendist.js b/configs/quickshell/modules/common/functions/levendist.js new file mode 100644 index 0000000..90180d2 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/functions/qmldir b/configs/quickshell/modules/common/functions/qmldir new file mode 100644 index 0000000..d23088c --- /dev/null +++ b/configs/quickshell/modules/common/functions/qmldir @@ -0,0 +1,7 @@ +module qs.modules.common.functions +singleton ColorUtils 1.0 ColorUtils.qml +singleton FileUtils 1.0 FileUtils.qml +singleton Fuzzy 1.0 Fuzzy.qml +singleton Levendist 1.0 Levendist.qml +singleton ObjectUtils 1.0 ObjectUtils.qml +singleton StringUtils 1.0 StringUtils.qml diff --git a/configs/quickshell/modules/common/qmldir b/configs/quickshell/modules/common/qmldir new file mode 100644 index 0000000..57b638a --- /dev/null +++ b/configs/quickshell/modules/common/qmldir @@ -0,0 +1,5 @@ +module qs.modules.common +singleton Appearance 1.0 Appearance.qml +singleton Config 1.0 Config.qml +singleton Directories 1.0 Directories.qml +singleton Persistent 1.0 Persistent.qml diff --git a/configs/quickshell/modules/common/widgets/ButtonGroup.qml b/configs/quickshell/modules/common/widgets/ButtonGroup.qml new file mode 100644 index 0000000..7dc7a59 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/ButtonGroup.qml @@ -0,0 +1,46 @@ +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 real spacing: 5 + property real padding: 0 + property int clickIndex: rowLayout.clickIndex + + 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 + }] +} diff --git a/configs/quickshell/modules/common/widgets/CircularProgress.qml b/configs/quickshell/modules/common/widgets/CircularProgress.qml new file mode 100644 index 0000000..7ff2724 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/CircularProgress.qml @@ -0,0 +1,97 @@ +// From https://github.com/rafzby/circular-progressbar with modifications +// License: LGPL-3.0 - A copy can be found in `licenses` folder of repo + +import QtQuick +import qs.modules.common + +/** + * Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs + */ +Item { + id: root + + property int size: 30 + property int lineWidth: 2 + property real value: 0 + property color primaryColor: Appearance.m3colors.m3onSecondaryContainer + property color secondaryColor: Appearance.colors.colSecondaryContainer + property real gapAngle: Math.PI / 9 + property bool fill: false + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 1000 + property var easingType: Easing.OutCubic + + width: size + height: size + + signal animationFinished(); + + onValueChanged: { + canvas.degree = value * 360; + } + onPrimaryColorChanged: { + canvas.requestPaint(); + } + onSecondaryColorChanged: { + canvas.requestPaint(); + } + + Canvas { + id: canvas + + property real degree: 0 + + anchors.fill: parent + antialiasing: true + + onDegreeChanged: { + requestPaint(); + } + + onPaint: { + var ctx = getContext("2d"); + var x = root.width / 2; + var y = root.height / 2; + var radius = root.size / 2 - root.lineWidth; + var startAngle = (Math.PI / 180) * 270; + var fullAngle = (Math.PI / 180) * (270 + 360); + var progressAngle = (Math.PI / 180) * (270 + degree); + var epsilon = 0.01; // Small angle in radians + + ctx.reset(); + if (root.fill) { + ctx.fillStyle = root.secondaryColor; + ctx.beginPath(); + ctx.arc(x, y, radius + fillOverflow, startAngle, fullAngle); + ctx.fill(); + } + ctx.lineCap = 'round'; + ctx.lineWidth = root.lineWidth; + + // Secondary + ctx.beginPath(); + ctx.arc(x, y, radius, progressAngle + gapAngle, fullAngle - gapAngle); + ctx.strokeStyle = root.secondaryColor; + ctx.stroke(); + + // Primary (value indication) + var endAngle = progressAngle + (value > 0 ? 0 : epsilon); + ctx.beginPath(); + ctx.arc(x, y, radius, startAngle, endAngle); + ctx.strokeStyle = root.primaryColor; + ctx.stroke(); + } + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + } + +} diff --git a/configs/quickshell/modules/common/widgets/CliphistImage.qml b/configs/quickshell/modules/common/widgets/CliphistImage.qml new file mode 100644 index 0000000..ce15ef3 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/CliphistImage.qml @@ -0,0 +1,96 @@ +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 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 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}'`]) + } + + Image { + 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 + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: image.width + height: image.height + radius: root.radius + } + } + } +} + diff --git a/configs/quickshell/modules/common/widgets/ConfigRow.qml b/configs/quickshell/modules/common/widgets/ConfigRow.qml new file mode 100644 index 0000000..3cdc3f8 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/ConfigRow.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts + +RowLayout { + property bool uniform: false + spacing: 10 + uniformCellSizes: uniform +} diff --git a/configs/quickshell/modules/common/widgets/ConfigSelectionArray.qml b/configs/quickshell/modules/common/widgets/ConfigSelectionArray.qml new file mode 100644 index 0000000..318ffe1 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/ConfigSelectionArray.qml @@ -0,0 +1,43 @@ +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: [] + property string configOptionName: "" + 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 + buttonText: modelData.displayName; + toggled: root.currentValue === modelData.value + onClicked: { + root.selected(modelData.value); + } + } + } +} diff --git a/configs/quickshell/modules/common/widgets/ConfigSpinBox.qml b/configs/quickshell/modules/common/widgets/ConfigSpinBox.qml new file mode 100644 index 0000000..375f78e --- /dev/null +++ b/configs/quickshell/modules/common/widgets/ConfigSpinBox.qml @@ -0,0 +1,30 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + property string text: "" + 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 + + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + + StyledSpinBox { + id: spinBoxWidget + Layout.fillWidth: false + value: root.value + } +} diff --git a/configs/quickshell/modules/common/widgets/ConfigSwitch.qml b/configs/quickshell/modules/common/widgets/ConfigSwitch.qml new file mode 100644 index 0000000..e10f74d --- /dev/null +++ b/configs/quickshell/modules/common/widgets/ConfigSwitch.qml @@ -0,0 +1,32 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +RippleButton { + id: root + Layout.fillWidth: true + implicitHeight: contentItem.implicitHeight + 8 * 2 + onClicked: checked = !checked + + contentItem: RowLayout { + spacing: 10 + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + StyledSwitch { + id: switchWidget + down: root.down + scale: 0.6 + Layout.fillWidth: false + checked: root.checked + onClicked: root.clicked() + } + } +} + diff --git a/configs/quickshell/modules/common/widgets/ContentPage.qml b/configs/quickshell/modules/common/widgets/ContentPage.qml new file mode 100644 index 0000000..5b110f8 --- /dev/null +++ b/configs/quickshell/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: 550 + 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: 10 + } + spacing: 20 + } + +} diff --git a/configs/quickshell/modules/common/widgets/ContentSection.qml b/configs/quickshell/modules/common/widgets/ContentSection.qml new file mode 100644 index 0000000..2f038e1 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/ContentSection.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title + default property alias data: sectionContent.data + + Layout.fillWidth: true + spacing: 8 + StyledText { + text: root.title + font.pixelSize: Appearance.font.pixelSize.larger + font.weight: Font.Medium + } + ColumnLayout { + id: sectionContent + spacing: 8 + } +} diff --git a/configs/quickshell/modules/common/widgets/ContentSubsection.qml b/configs/quickshell/modules/common/widgets/ContentSubsection.qml new file mode 100644 index 0000000..b78f3aa --- /dev/null +++ b/configs/quickshell/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 + content: root.tooltip + } + } + } + Item { Layout.fillWidth: true } + } + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 2 + } +} diff --git a/configs/quickshell/modules/common/widgets/ContentSubsectionLabel.qml b/configs/quickshell/modules/common/widgets/ContentSubsectionLabel.qml new file mode 100644 index 0000000..5d29e0e --- /dev/null +++ b/configs/quickshell/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: 4 +} diff --git a/configs/quickshell/modules/common/widgets/CustomIcon.qml b/configs/quickshell/modules/common/widgets/CustomIcon.qml new file mode 100644 index 0000000..d7a1c63 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/DialogButton.qml b/configs/quickshell/modules/common/widgets/DialogButton.qml new file mode 100644 index 0000000..972c29b --- /dev/null +++ b/configs/quickshell/modules/common/widgets/DialogButton.qml @@ -0,0 +1,33 @@ +import qs.modules.common +import QtQuick + +/** + * Material 3 dialog button. See https://m3.material.io/components/dialogs/overview + */ +RippleButton { + id: button + + property string buttonText + implicitHeight: 30 + implicitWidth: buttonTextWidget.implicitWidth + 15 * 2 + buttonRadius: Appearance?.rounding.full ?? 9999 + + property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F" + property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96" + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small ?? 12 + color: button.enabled ? button.colEnabled : button.colDisabled + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/configs/quickshell/modules/common/widgets/DragManager.qml b/configs/quickshell/modules/common/widgets/DragManager.qml new file mode 100644 index 0000000..9a430d9 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/Favicon.qml b/configs/quickshell/modules/common/widgets/Favicon.qml new file mode 100644 index 0000000..04e9285 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/FloatingActionButton.qml b/configs/quickshell/modules/common/widgets/FloatingActionButton.qml new file mode 100644 index 0000000..14702aa --- /dev/null +++ b/configs/quickshell/modules/common/widgets/FloatingActionButton.qml @@ -0,0 +1,59 @@ +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: Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize) + implicitHeight: baseSize + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colPrimaryContainer + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + contentItem: RowLayout { + 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 + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + iconSize: 24 + color: Appearance.colors.colOnPrimaryContainer + text: root.iconText + } + Loader { + 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 + } + text: root.buttonText + color: Appearance.colors.colOnPrimaryContainer + font.pixelSize: 14 + font.weight: 450 + } + } + } + } +} diff --git a/configs/quickshell/modules/common/widgets/FlowButtonGroup.qml b/configs/quickshell/modules/common/widgets/FlowButtonGroup.qml new file mode 100644 index 0000000..ec9526e --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/GroupButton.qml b/configs/quickshell/modules/common/widgets/GroupButton.qml new file mode 100644 index 0000000..4a524e1 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/GroupButton.qml @@ -0,0 +1,130 @@ +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 real clickedWidth: baseWidth + 20 + property real clickedHeight: baseHeight + property var parentGroup: root.parent + property int clickIndex: parentGroup?.clickIndex ?? -1 + + Layout.fillWidth: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + Layout.fillHeight: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth + implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 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 { + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on implicitHeight { + 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) + } + + MouseArea { + 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; + }; + } + + + 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) + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/configs/quickshell/modules/common/widgets/KeyboardKey.qml b/configs/quickshell/modules/common/widgets/KeyboardKey.qml new file mode 100644 index 0000000..14c75c6 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/KeyboardKey.qml @@ -0,0 +1,42 @@ +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 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: Appearance.font.pixelSize.smaller + text: key + } + } +} diff --git a/configs/quickshell/modules/common/widgets/LightDarkPreferenceButton.qml b/configs/quickshell/modules/common/widgets/LightDarkPreferenceButton.qml new file mode 100644 index 0000000..63dbd2c --- /dev/null +++ b/configs/quickshell/modules/common/widgets/LightDarkPreferenceButton.qml @@ -0,0 +1,122 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell + +GroupButton { + 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 + sperm: true + animateSperm: 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/configs/quickshell/modules/common/widgets/MaterialSymbol.qml b/configs/quickshell/modules/common/widgets/MaterialSymbol.qml new file mode 100644 index 0000000..92da991 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/MaterialSymbol.qml @@ -0,0 +1,32 @@ +import qs.modules.common +import QtQuick + +Text { + id: root + property real iconSize: Appearance?.font.pixelSize.small ?? 16 + property real fill: 0 + property real truncatedFill: Math.round(fill * 100) / 100 // Reduce memory consumption spikes from constant font remapping + renderType: Text.NativeRendering + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded" + pixelSize: iconSize + weight: Font.Normal + (Font.DemiBold - Font.Normal) * fill + variableAxes: { + "FILL": truncatedFill, + // "wght": font.weight, + // "GRAD": 0, + "opsz": iconSize, + } + } + verticalAlignment: Text.AlignVCenter + color: Appearance.m3colors.m3onBackground + + // 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/configs/quickshell/modules/common/widgets/MaterialTextField.qml b/configs/quickshell/modules/common/widgets/MaterialTextField.qml new file mode 100644 index 0000000..241cc90 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/MaterialTextField.qml @@ -0,0 +1,52 @@ +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 ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + wrapMode: TextEdit.Wrap +} diff --git a/configs/quickshell/modules/common/widgets/MenuButton.qml b/configs/quickshell/modules/common/widgets/MenuButton.qml new file mode 100644 index 0000000..9185bc9 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/NavigationRail.qml b/configs/quickshell/modules/common/widgets/NavigationRail.qml new file mode 100644 index 0000000..11082a7 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/NavigationRailButton.qml b/configs/quickshell/modules/common/widgets/NavigationRailButton.qml new file mode 100644 index 0000000..0b83b45 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NavigationRailButton.qml @@ -0,0 +1,148 @@ +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 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 + 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/configs/quickshell/modules/common/widgets/NavigationRailExpandButton.qml b/configs/quickshell/modules/common/widgets/NavigationRailExpandButton.qml new file mode 100644 index 0000000..57e15f0 --- /dev/null +++ b/configs/quickshell/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 + onClicked: { + 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/configs/quickshell/modules/common/widgets/NavigationRailTabArray.qml b/configs/quickshell/modules/common/widgets/NavigationRailTabArray.qml new file mode 100644 index 0000000..6596141 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NavigationRailTabArray.qml @@ -0,0 +1,41 @@ +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 + property real baseHighlightHeight: tabBarColumn.children[0].baseHighlightHeight + 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 + + 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/configs/quickshell/modules/common/widgets/NotificationActionButton.qml b/configs/quickshell/modules/common/widgets/NotificationActionButton.qml new file mode 100644 index 0000000..2a73725 --- /dev/null +++ b/configs/quickshell/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: 30 + leftPadding: 15 + rightPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSurfaceContainerHighestHover + colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colSurfaceContainerHighestActive + + 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/configs/quickshell/modules/common/widgets/NotificationAppIcon.qml b/configs/quickshell/modules/common/widgets/NotificationAppIcon.qml new file mode 100644 index 0000000..5158d64 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NotificationAppIcon.qml @@ -0,0 +1,103 @@ +import qs.modules.common +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications + +Rectangle { // App icon + id: root + property var appIcon: "" + property var summary: "" + property var urgency: NotificationUrgency.Normal + property var image: "" + property real scale: 1 + property real size: 45 * scale + property real materialIconScale: 0.57 + property real appIconScale: 0.7 + property real smallAppIconScale: 0.49 + property real materialIconSize: size * materialIconScale + property real appIconSize: size * appIconScale + property real smallAppIconSize: size * smallAppIconScale + + implicitWidth: size + implicitHeight: size + radius: Appearance.rounding.full + color: 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) ? + "release_alert" : guessedIcon + } + anchors.fill: parent + color: (root.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) : + Appearance.m3colors.m3onSecondaryContainer + 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/configs/quickshell/modules/common/widgets/NotificationGroup.qml b/configs/quickshell/modules/common/widgets/NotificationGroup.qml new file mode 100644 index 0000000..d63bbb3 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NotificationGroup.qml @@ -0,0 +1,236 @@ +import qs.modules.common +import qs.services +import qs.modules.common.functions +import "./notification_utils.js" as NotificationUtils +import QtQuick +import QtQuick.Layouts +import Quickshell + +/** + * A group of notifications from the same app. + * Similar to Android's notifications + */ +Item { // 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 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + function destroyWithAnimation() { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.running = true; + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: root.width + root.dismissOvershoot + 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 + + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + root.toggleExpanded(); + else 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 (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + StyledRectangularShadow { + target: background + visible: popup + } + Rectangle { // Background of the notification + id: background + anchors.left: parent.left + width: parent.width + color: Appearance.colors.colSurfaceContainer + 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: 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: notificationGroup?.appIcon + summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary + } + + 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() } + } + } + + 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/configs/quickshell/modules/common/widgets/NotificationGroupExpandButton.qml b/configs/quickshell/modules/common/widgets/NotificationGroupExpandButton.qml new file mode 100644 index 0000000..ba2b7e1 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NotificationGroupExpandButton.qml @@ -0,0 +1,48 @@ +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/configs/quickshell/modules/common/widgets/NotificationItem.qml b/configs/quickshell/modules/common/widgets/NotificationItem.qml new file mode 100644 index 0000000..d5e9c4f --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NotificationItem.qml @@ -0,0 +1,313 @@ +import qs +import qs.modules.common +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +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 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 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, 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 (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + 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.colSurfaceContainerHigh) : + ColorUtils.transparentize(Appearance.colors.colSurfaceContainerHighest) + + 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 + // Layout.fillWidth: true + StyledText { + id: summaryText + visible: !root.onlyNotification + font.pixelSize: root.fontSize + color: Appearance.colors.colOnLayer2 + 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 {} + } + + StyledFlickable { // Notification actions + id: actionsFlickable + Layout.fillWidth: true + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + clip: !onlyNotification + + 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.large + 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.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + } + } + } + } + } +} diff --git a/configs/quickshell/modules/common/widgets/NotificationListView.qml b/configs/quickshell/modules/common/widgets/NotificationListView.qml new file mode 100644 index 0000000..389a5a8 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,27 @@ +import qs +import qs.modules.common +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 + anchors.left: parent?.left + anchors.right: parent?.right + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/common/widgets/PointingHandInteraction.qml b/configs/quickshell/modules/common/widgets/PointingHandInteraction.qml new file mode 100644 index 0000000..cf8b065 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/PointingHandLinkHover.qml b/configs/quickshell/modules/common/widgets/PointingHandLinkHover.qml new file mode 100644 index 0000000..4d14c81 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/PrimaryTabBar.qml b/configs/quickshell/modules/common/widgets/PrimaryTabBar.qml new file mode 100644 index 0000000..63f5e17 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/PrimaryTabBar.qml @@ -0,0 +1,97 @@ +import qs.modules.common +import qs +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + spacing: 0 + required property var tabButtonList // Something like [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}] + required property var externalTrackedTab + property bool enableIndicatorAnimation: false + property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F" + property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0" + signal currentIndexChanged(int index) + + property bool centerTabBar: parent.width > 500 + Layout.fillWidth: !centerTabBar + Layout.alignment: Qt.AlignHCenter + implicitWidth: Math.max(tabBar.implicitWidth, 600) + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: root.externalTrackedTab + onCurrentIndexChanged: { + root.onCurrentIndexChanged(currentIndex) + } + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: PrimaryTabButton { + selected: (index == root.externalTrackedTab) + buttonText: modelData.name + buttonIcon: modelData.icon + minimumWidth: 160 + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + Connections { + target: root + function onExternalTrackedTabChanged() { + root.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem?.children[0]?.children[tabBar.currentIndex]?.tabContentWidth ?? 0 + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: root.colIndicator + radius: Appearance?.rounding.full ?? 9999 + + Behavior on x { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + implicitHeight: 1 + color: root.colBorder + } +} diff --git a/configs/quickshell/modules/common/widgets/PrimaryTabButton.qml b/configs/quickshell/modules/common/widgets/PrimaryTabButton.qml new file mode 100644 index 0000000..0b4b6f8 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/PrimaryTabButton.qml @@ -0,0 +1,171 @@ +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 + +TabButton { + id: button + property string buttonText + property string buttonIcon + property real minimumWidth: 110 + property bool selected: false + property int tabContentWidth: contentItem.children[0].implicitWidth + property int rippleDuration: 1200 + height: buttonBackground.height + implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth) + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colActive: Appearance?.colors.colPrimary ?? "#65558F" + property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F" + + 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) => { + 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) => { + button.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + 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: Appearance?.rounding.small + implicitHeight: 50 + color: (button.hovered ? button.colBackgroundHover : button.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: button.colRipple } + GradientStop { position: 0.3; color: button.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + visible: buttonIcon?.length > 0 + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: buttonIcon + iconSize: Appearance?.font.pixelSize.hugeass ?? 25 + fill: selected ? 1 : 0 + color: selected ? button.colActive : button.colInactive + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + StyledText { + id: buttonTextWidget + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small + color: selected ? button.colActive : button.colInactive + text: buttonText + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/common/widgets/Revealer.qml b/configs/quickshell/modules/common/widgets/Revealer.qml new file mode 100644 index 0000000..bbbe2ef --- /dev/null +++ b/configs/quickshell/modules/common/widgets/Revealer.qml @@ -0,0 +1,25 @@ +import qs.modules.common +import QtQuick + +/** + * Recreation of GTK revealer. Expects one single child. + */ +Item { + id: root + property bool reveal + property bool vertical: false + clip: true + + implicitWidth: (reveal || vertical) ? childrenRect.width : 0 + implicitHeight: (reveal || !vertical) ? childrenRect.height : 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/configs/quickshell/modules/common/widgets/RippleButton.qml b/configs/quickshell/modules/common/widgets/RippleButton.qml new file mode 100644 index 0000000..7487203 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/RippleButton.qml @@ -0,0 +1,183 @@ +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 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" + + property color buttonColor: root.enabled ? (root.toggled ? + (root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.hovered ? colBackgroundHover : + colBackground)) : colBackground + 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: 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(); + 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 + 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: 50 + + 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/configs/quickshell/modules/common/widgets/RippleButtonWithIcon.qml b/configs/quickshell/modules/common/widgets/RippleButtonWithIcon.qml new file mode 100644 index 0000000..f84ae4d --- /dev/null +++ b/configs/quickshell/modules/common/widgets/RippleButtonWithIcon.qml @@ -0,0 +1,55 @@ +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 { + text: buttonWithIconRoot.mainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + } + implicitHeight: 35 + horizontalPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + + contentItem: RowLayout { + Item { + 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 { + sourceComponent: buttonWithIconRoot.mainContentComponent + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/configs/quickshell/modules/common/widgets/RoundCorner.qml b/configs/quickshell/modules/common/widgets/RoundCorner.qml new file mode 100644 index 0000000..6fba4b9 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/RoundCorner.qml @@ -0,0 +1,61 @@ +import QtQuick 2.9 + +Item { + id: root + + enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight } + property var corner: RoundCorner.CornerEnum.TopLeft // Default to TopLeft + + property int size: 25 + property color color: "#000000" + + onColorChanged: { + canvas.requestPaint(); + } + onCornerChanged: { + canvas.requestPaint(); + } + + implicitWidth: size + implicitHeight: size + + Canvas { + id: canvas + + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d"); + var r = root.size; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.beginPath(); + switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: + ctx.arc(r, r, r, Math.PI, 3 * Math.PI / 2); + ctx.lineTo(0, 0); + break; + case RoundCorner.CornerEnum.TopRight: + ctx.arc(0, r, r, 3 * Math.PI / 2, 2 * Math.PI); + ctx.lineTo(r, 0); + break; + case RoundCorner.CornerEnum.BottomLeft: + ctx.arc(r, 0, r, Math.PI / 2, Math.PI); + ctx.lineTo(0, r); + break; + case RoundCorner.CornerEnum.BottomRight: + ctx.arc(0, 0, r, 0, Math.PI / 2); + ctx.lineTo(r, r); + break; + } + ctx.closePath(); + ctx.fillStyle = root.color; + ctx.fill(); + } + } + + Behavior on size { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + +} diff --git a/configs/quickshell/modules/common/widgets/SecondaryTabButton.qml b/configs/quickshell/modules/common/widgets/SecondaryTabButton.qml new file mode 100644 index 0000000..983dd02 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/SecondaryTabButton.qml @@ -0,0 +1,161 @@ +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 + +TabButton { + id: root + property string buttonText + property string buttonIcon + property bool selected: false + property int rippleDuration: 1200 + height: buttonBackground.height + property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2 + + property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + property color colBackgroundHover: Appearance.colors.colLayer1Hover + property color colRipple: Appearance.colors.colLayer1Active + + 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) => { + 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) => { + root.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + 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: Appearance?.rounding.small ?? 7 + implicitHeight: 37 + 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) + } + + Rectangle { + id: ripple + + radius: Appearance.rounding.full + color: root.colRipple + opacity: 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: selected ? 1 : 0 + color: selected ? 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: selected ? 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/configs/quickshell/modules/common/widgets/SelectionDialog.qml b/configs/quickshell/modules/common/widgets/SelectionDialog.qml new file mode 100644 index 0000000..72da7ec --- /dev/null +++ b/configs/quickshell/modules/common/widgets/SelectionDialog.qml @@ -0,0 +1,132 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +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 + } + + ListView { + id: choiceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1 + spacing: 6 + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + 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/configs/quickshell/modules/common/widgets/SelectionGroupButton.qml b/configs/quickshell/modules/common/widgets/SelectionGroupButton.qml new file mode 100644 index 0000000..6a225eb --- /dev/null +++ b/configs/quickshell/modules/common/widgets/SelectionGroupButton.qml @@ -0,0 +1,24 @@ +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 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 + contentItem: StyledText { + color: parent.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.buttonText + } +} diff --git a/configs/quickshell/modules/common/widgets/StyledFlickable.qml b/configs/quickshell/modules/common/widgets/StyledFlickable.qml new file mode 100644 index 0000000..14b3af0 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledFlickable.qml @@ -0,0 +1,6 @@ +import QtQuick + +Flickable { + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds +} diff --git a/configs/quickshell/modules/common/widgets/StyledLabel.qml b/configs/quickshell/modules/common/widgets/StyledLabel.qml new file mode 100644 index 0000000..35b3cbf --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledLabel.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +Label { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/configs/quickshell/modules/common/widgets/StyledListView.qml b/configs/quickshell/modules/common/widgets/StyledListView.qml new file mode 100644 index 0000000..7021f24 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledListView.qml @@ -0,0 +1,108 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick + +/** + * 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 + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + add: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + from: 0, + to: 1, + }), + ] + } + + addDisplaced: Transition { + animations: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + to: 1, + }), + ] + } + + // displaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + // move: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + // moveDisplaced: Transition { + // animations: [ + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // property: "y", + // }), + // Appearance?.animation.elementMove.numberAnimation.createObject(this, { + // properties: "opacity,scale", + // to: 1, + // }), + // ] + // } + + remove: Transition { + animations: [ + 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: [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } +} diff --git a/configs/quickshell/modules/common/widgets/StyledProgressBar.qml b/configs/quickshell/modules/common/widgets/StyledProgressBar.qml new file mode 100644 index 0000000..fa1cd0b --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledProgressBar.qml @@ -0,0 +1,103 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +/** + * 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 sperm: false // If true, the progress bar will have a wavy fill effect + property bool animateSperm: true + property real spermAmplitudeMultiplier: sperm ? 0.5 : 0 + property real spermFrequency: 6 + property real spermFps: 60 + + Behavior on spermAmplitudeMultiplier { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Behavior on value { + animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + background: Item { + anchors.fill: parent + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Item { + anchors.fill: parent + + Canvas { + id: wavyFill + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + height: parent.height * 6 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var progress = root.visualPosition; + var fillWidth = progress * width; + var amplitude = parent.height * root.spermAmplitudeMultiplier; + var frequency = root.spermFrequency; + var phase = Date.now() / 400.0; + var centerY = height / 2; + + ctx.strokeStyle = root.highlightColor; + ctx.lineWidth = parent.height; + ctx.lineCap = "round"; + ctx.beginPath(); + for (var x = ctx.lineWidth / 2; x <= fillWidth; x += 1) { + var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / width + phase); + if (x === 0) + ctx.moveTo(x, waveY); + else + ctx.lineTo(x, waveY); + } + ctx.stroke(); + } + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + Timer { + interval: 1000 / root.spermFps + running: root.animateSperm + repeat: root.sperm + onTriggered: wavyFill.requestPaint() + } + } + Rectangle { // Right remaining part fill + anchors.right: parent.right + width: (1 - root.visualPosition) * parent.width - valueBarGap + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.trackColor + } + Rectangle { // Stop point + anchors.right: parent.right + width: valueBarGap + height: valueBarGap + radius: Appearance?.rounding.full ?? 9999 + color: root.highlightColor + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/common/widgets/StyledRadioButton.qml b/configs/quickshell/modules/common/widgets/StyledRadioButton.qml new file mode 100644 index 0000000..a6a63b7 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledRadioButton.qml @@ -0,0 +1,87 @@ +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 + implicitHeight: contentItem.implicitHeight + 4 * 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/configs/quickshell/modules/common/widgets/StyledRectangularShadow.qml b/configs/quickshell/modules/common/widgets/StyledRectangularShadow.qml new file mode 100644 index 0000000..a3c842c --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/StyledSlider.qml b/configs/quickshell/modules/common/widgets/StyledSlider.qml new file mode 100644 index 0000000..e940f1a --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledSlider.qml @@ -0,0 +1,155 @@ +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 { + 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.m3colors.m3onSecondaryContainer + 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 + : 6 + property real handleHeight: Math.max(33, trackWidth + 9) + property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth + property real handleMargins: 4 + onHandleMarginsChanged: { + console.log("Handle margins changed to", handleMargins); + } + property real trackDotSize: 3 + property string tooltipContent: `${Math.round(value * 100)}%` + + leftPadding: handleMargins + rightPadding: handleMargins + property real effectiveDraggingWidth: width - leftPadding - rightPadding + + Layout.fillWidth: true + from: 0 + to: 1 + + Behavior on value { // This makes the adjusted value (like volume) shift smoothly + SmoothedAnimation { + velocity: Appearance.animation.elementMoveFast.velocity + } + } + + Behavior on handleMargins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + component TrackDot: Rectangle { + required property real value + anchors.verticalCenter: parent.verticalCenter + x: root.handleMargins + (value * root.effectiveDraggingWidth) - (root.trackDotSize / 2) + width: root.trackDotSize + height: root.trackDotSize + radius: Appearance.rounding.full + color: value > 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 + Rectangle { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: trackWidth + color: root.highlightColor + topLeftRadius: root.trackRadius + bottomLeftRadius: root.trackRadius + topRightRadius: root.unsharpenRadius + bottomRightRadius: root.unsharpenRadius + } + + // 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 + content: root.tooltipContent + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/common/widgets/StyledSpinBox.qml b/configs/quickshell/modules/common/widgets/StyledSpinBox.qml new file mode 100644 index 0000000..c11f241 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledSpinBox.qml @@ -0,0 +1,92 @@ +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 + + 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.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/configs/quickshell/modules/common/widgets/StyledSwitch.qml b/configs/quickshell/modules/common/widgets/StyledSwitch.qml new file mode 100644 index 0000000..f16e213 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledSwitch.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * Material 3 switch. See https://m3.material.io/components/switch/overview + */ +Switch { + id: root + property real scale: 0.6 // 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 { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/configs/quickshell/modules/common/widgets/StyledText.qml b/configs/quickshell/modules/common/widgets/StyledText.qml new file mode 100644 index 0000000..6024fc6 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledText.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Text { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/configs/quickshell/modules/common/widgets/StyledTextArea.qml b/configs/quickshell/modules/common/widgets/StyledTextArea.qml new file mode 100644 index 0000000..e0abba3 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledTextArea.qml @@ -0,0 +1,18 @@ +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 + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/configs/quickshell/modules/common/widgets/StyledTextInput.qml b/configs/quickshell/modules/common/widgets/StyledTextInput.qml new file mode 100644 index 0000000..57d0c72 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledTextInput.qml @@ -0,0 +1,17 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextInput { + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/configs/quickshell/modules/common/widgets/StyledToolTip.qml b/configs/quickshell/modules/common/widgets/StyledToolTip.qml new file mode 100644 index 0000000..813c9ed --- /dev/null +++ b/configs/quickshell/modules/common/widgets/StyledToolTip.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolTip { + id: root + property string content + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + property bool internalVisibleCondition: { + const ans = (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + return ans + } + verticalPadding: 5 + horizontalPadding: 10 + opacity: internalVisibleCondition ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + background: null + + contentItem: Item { + id: contentItemBackground + implicitWidth: tooltipTextObject.width + 2 * root.horizontalPadding + implicitHeight: tooltipTextObject.height + 2 * root.verticalPadding + + Rectangle { + id: backgroundRectangle + anchors.bottom: contentItemBackground.bottom + anchors.horizontalCenter: contentItemBackground.horizontalCenter + color: Appearance?.colors.colTooltip ?? "#3C4043" + radius: Appearance?.rounding.verysmall ?? 7 + width: internalVisibleCondition ? (tooltipTextObject.width + 2 * padding) : 0 + height: internalVisibleCondition ? (tooltipTextObject.height + 2 * padding) : 0 + clip: true + + Behavior on width { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledText { + id: tooltipTextObject + anchors.centerIn: parent + text: content + font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + font.hintingPreference: Font.PreferNoHinting // Prevent shaky text + color: Appearance?.colors.colOnTooltip ?? "#FFFFFF" + wrapMode: Text.Wrap + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/common/widgets/VerticalButtonGroup.qml b/configs/quickshell/modules/common/widgets/VerticalButtonGroup.qml new file mode 100644 index 0000000..b1ca845 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/WaveVisualizer.qml b/configs/quickshell/modules/common/widgets/WaveVisualizer.qml new file mode 100644 index 0000000..64559c1 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/modules/common/widgets/notification_utils.js b/configs/quickshell/modules/common/widgets/notification_utils.js new file mode 100644 index 0000000..9b15105 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/notification_utils.js @@ -0,0 +1,77 @@ + +/** + * @param { string } summary + * @returns { string } + */ +function findSuitableMaterialSymbol(summary = "") { + const defaultType = 'chat'; + if(summary.length === 0) return defaultType; + + const keywordsToTypes = { + 'reboot': 'restart_alt', + 'recording': 'screen_record', + 'battery': 'power', + 'power': 'power', + 'screenshot': 'screenshot_monitor', + 'welcome': 'waving_hand', + 'time': 'scheduleb', + 'installed': 'download', + 'configuration reloaded': 'reset_wrench', + 'config': 'reset_wrench', + 'update': 'update', + 'ai response': 'neurology', + 'control': 'settings', + 'upscale': 'compare', + 'install': 'deployed_code_update', + '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 } + */ +const 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"); +}; \ No newline at end of file diff --git a/configs/quickshell/modules/common/widgets/qmldir b/configs/quickshell/modules/common/widgets/qmldir new file mode 100644 index 0000000..4f4b063 --- /dev/null +++ b/configs/quickshell/modules/common/widgets/qmldir @@ -0,0 +1,15 @@ +module qs.modules.common.widgets +RippleButton 1.0 RippleButton.qml +ClickableIcon 1.0 ClickableIcon.qml +HoverableIcon 1.0 HoverableIcon.qml +IconButton 1.0 IconButton.qml +IconLabel 1.0 IconLabel.qml +IconLabelButton 1.0 IconLabelButton.qml +IconTextButton 1.0 IconTextButton.qml +PanelButton 1.0 PanelButton.qml +PanelIconButton 1.0 PanelIconButton.qml +PanelIconLabel 1.0 PanelIconLabel.qml +PanelIconLabelButton 1.0 PanelIconLabelButton.qml +PanelIconTextButton 1.0 PanelIconTextButton.qml +PanelTextButton 1.0 PanelTextButton.qml +TextButton 1.0 TextButton.qml diff --git a/configs/quickshell/modules/dock/Dock.qml b/configs/quickshell/modules/dock/Dock.qml new file mode 100644 index 0000000..b4237e5 --- /dev/null +++ b/configs/quickshell/modules/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 { // Window + required property var modelData + id: dockRoot + 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; } + DockSeparator {} + DockButton { + Layout.fillHeight: true + onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen + contentItem: MaterialSymbol { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: parent.width / 2 + text: "apps" + color: Appearance.colors.colOnLayer0 + } + } + } + } + } + + } + } + } +} diff --git a/configs/quickshell/modules/dock/DockAppButton.qml b/configs/quickshell/modules/dock/DockAppButton.qml new file mode 100644 index 0000000..1ebbffa --- /dev/null +++ b/configs/quickshell/modules/dock/DockAppButton.qml @@ -0,0 +1,139 @@ +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.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 + + property bool isSeparator: appToplevel.appId === "SEPARATOR" + property var desktopEntry: DesktopEntries.byId(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: () => { + if (Config.options.dock.pinnedApps.indexOf(appToplevel.appId) !== -1) { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appToplevel.appId) + } else { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([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/configs/quickshell/modules/dock/DockApps.qml b/configs/quickshell/modules/dock/DockApps.qml new file mode 100644 index 0000000..623bdc2 --- /dev/null +++ b/configs/quickshell/modules/dock/DockApps.qml @@ -0,0 +1,263 @@ +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.Widgets +import Quickshell.Wayland + +Item { + id: root + property real maxWindowPreviewHeight: 200 + property real maxWindowPreviewWidth: 300 + property real windowControlsHeight: 30 + + 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: { + 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({ appId: key, toplevels: value.toplevels, pinned: value.pinned }); + } + + return values; + } + } + delegate: DockAppButton { + required property var modelData + appToplevel: modelData + appListRoot: root + } + } + + 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/configs/quickshell/modules/dock/DockButton.qml b/configs/quickshell/modules/dock/DockButton.qml new file mode 100644 index 0000000..6165578 --- /dev/null +++ b/configs/quickshell/modules/dock/DockButton.qml @@ -0,0 +1,16 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + implicitWidth: implicitHeight - topInset - bottomInset + buttonRadius: Appearance.rounding.normal + + topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding +} diff --git a/configs/quickshell/modules/dock/DockSeparator.qml b/configs/quickshell/modules/dock/DockSeparator.qml new file mode 100644 index 0000000..419b0fe --- /dev/null +++ b/configs/quickshell/modules/dock/DockSeparator.qml @@ -0,0 +1,13 @@ +import qs +import qs.modules.common +import QtQuick +import QtQuick.Controls +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/configs/quickshell/modules/lock/Lock.qml b/configs/quickshell/modules/lock/Lock.qml new file mode 100644 index 0000000..89d3977 --- /dev/null +++ b/configs/quickshell/modules/lock/Lock.qml @@ -0,0 +1,99 @@ +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.lock +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + // 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 + + onUnlocked: { + // Unlock the screen before exiting, or the compositor will display a + // fallback lock you can't interact with. + GlobalStates.screenLocked = false; + } + } + + 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 + + LazyLoader { + id: blurLayerLoader + required property var modelData + active: GlobalStates.screenLocked + component: PanelWindow { + screen: blurLayerLoader.modelData + WlrLayershell.namespace: "quickshell:lockWindowPusher" + color: "transparent" + anchors { + top: true + left: true + right: true + } + // implicitHeight: lockContext.currentText == "" ? 1 : screen.height + implicitHeight: 1 + exclusiveZone: screen.height * 3 // For some reason if we don't multiply by some number it would look really weird + } + } + } + + IpcHandler { + target: "lock" + + function activate(): void { + GlobalStates.screenLocked = true; + } + function focus(): void { + lockContext.shouldReFocus(); + } + } + + GlobalShortcut { + name: "lock" + description: "Locks the screen" + + onPressed: { + GlobalStates.screenLocked = true; + } + } + + 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: { + // console.log("I BEG FOR PLEAS REFOCUZ") + lockContext.shouldReFocus(); + } + } +} diff --git a/configs/quickshell/modules/lock/LockContext.qml b/configs/quickshell/modules/lock/LockContext.qml new file mode 100644 index 0000000..ede61ee --- /dev/null +++ b/configs/quickshell/modules/lock/LockContext.qml @@ -0,0 +1,67 @@ +import qs +import QtQuick +import Quickshell +import Quickshell.Services.Pam + +Scope { + id: root + signal shouldReFocus() + signal unlocked() + 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 + + Timer { + id: passwordClearTimer + interval: 10000 + onTriggered: { + root.currentText = ""; + } + } + + onCurrentTextChanged: { + showFailure = false; // Clear the failure text once the user starts typing. + GlobalStates.screenLockContainsCharacters = currentText.length > 0; + passwordClearTimer.restart(); + } + + function tryUnlock() { + if (currentText === "") return; + + root.unlockInProgress = true; + pam.start(); + } + + PamContext { + id: pam + + // Its best to have a custom pam config for quickshell, as the system one + // might not be what your interface expects, and break in some way. + // This particular example only supports passwords. + configDirectory: "pam" + config: "password.conf" + + // 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(); + } else { + root.showFailure = true; + } + + root.currentText = ""; + root.unlockInProgress = false; + } + } +} diff --git a/configs/quickshell/modules/lock/LockSurface.qml b/configs/quickshell/modules/lock/LockSurface.qml new file mode 100644 index 0000000..98d1f57 --- /dev/null +++ b/configs/quickshell/modules/lock/LockSurface.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtQuick.Layouts +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 LockContext context + property bool active: false + property bool showInputField: active || context.currentText.length > 0 + + function forceFieldFocus() { + passwordBox.forceActiveFocus(); + } + + Component.onCompleted: { + forceFieldFocus(); + } + + Connections { + target: context + function onShouldReFocus() { + forceFieldFocus(); + } + } + + Keys.onPressed: (event) => { // Esc to clear + // console.log("KEY!!") + if (event.key === Qt.Key_Escape) { + root.context.currentText = "" + } + forceFieldFocus(); + } + + hoverEnabled: true + acceptedButtons: Qt.LeftButton + onPressed: (mouse) => { + forceFieldFocus(); + // console.log("Pressed") + } + onPositionChanged: (mouse) => { + forceFieldFocus(); + // console.log(JSON.stringify(mouse)) + } + + anchors.fill: parent + + // RippleButton { + // anchors { + // top: parent.top + // left: parent.left + // leftMargin: 10 + // topMargin: 10 + // } + // implicitHeight: 40 + // colBackground: Appearance.colors.colLayer2 + // onClicked: context.unlocked() + // contentItem: StyledText { + // text: "[[ DEBUG BYPASS ]]" + // } + // } + + // Password entry + Rectangle { + id: passwordBoxContainer + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: root.showInputField ? 20 : -height + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: 160 + implicitHeight: 44 + + StyledText { + visible: root.context.showFailure && passwordBox.text.length == 0 + anchors.centerIn: parent + text: "Incorrect" + color: Appearance.m3colors.m3error + } + + StyledTextInput { + id: passwordBox + + anchors { + fill: parent + margins: 10 + } + clip: true + horizontalAlignment: TextInput.AlignHCenter + verticalAlignment: TextInput.AlignVCenter + focus: true + onFocusChanged: root.forceFieldFocus(); + color: Appearance.colors.colOnLayer2 + font { + pixelSize: 10 + } + + // 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; + } + } + } + } + + RippleButton { + anchors { + verticalCenter: passwordBoxContainer.verticalCenter + left: passwordBoxContainer.right + leftMargin: 5 + } + + visible: opacity > 0 + implicitHeight: passwordBoxContainer.implicitHeight - 12 + implicitWidth: implicitHeight + toggled: true + buttonRadius: passwordBoxContainer.radius + colBackground: Appearance.colors.colLayer2 + onClicked: root.context.tryUnlock() + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 24 + text: "arrow_right_alt" + color: Appearance.colors.colOnPrimary + } + } +} diff --git a/configs/quickshell/modules/lock/pam/password.conf b/configs/quickshell/modules/lock/pam/password.conf new file mode 100644 index 0000000..7e5d75a --- /dev/null +++ b/configs/quickshell/modules/lock/pam/password.conf @@ -0,0 +1 @@ +auth required pam_unix.so diff --git a/configs/quickshell/modules/mediaControls/MediaControls.qml b/configs/quickshell/modules/mediaControls/MediaControls.qml new file mode 100644 index 0000000..06d1a38 --- /dev/null +++ b/configs/quickshell/modules/mediaControls/MediaControls.qml @@ -0,0 +1,196 @@ +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 +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: Mpris.players.values.filter(player => isRealPlayer(player)) + 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 contentPadding: 13 + property real popupRounding: Appearance.rounding.screenRounding - Appearance.sizes.elevationMargin + 1 + property real artRounding: Appearance.rounding.verysmall + property list visualizerPoints: [] + + property bool hasPlasmaIntegration: true + Process { + id: plasmaIntegrationAvailabilityCheckProc + running: true + command: ["bash", "-c", "command -v plasma-browser-integration-host"] + onExited: (exitCode, exitStatus) => { + root.hasPlasmaIntegration = (exitCode === 0); + } + } + function isRealPlayer(player) { + // 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')) + ); + } + 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 && Mpris.players.values.filter(player => isRealPlayer(player)).length === 0) { + GlobalStates.mediaControlsOpen = false; + } + } + + sourceComponent: PanelWindow { + id: mediaControlsRoot + visible: true + + exclusiveZone: 0 + implicitWidth: ( + (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth / 2) // Account for widget width + ) * 2 + implicitHeight: playerColumnLayout.implicitHeight + color: "transparent" + WlrLayershell.namespace: "quickshell:mediaControls" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + left: true + } + mask: Region { + item: playerColumnLayout + } + + ColumnLayout { + id: playerColumnLayout + anchors.top: parent.top + anchors.bottom: parent.bottom + x: (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth) // Account for widget width + + (Appearance.sizes.elevationMargin) // It's fine for shadows to overlap + spacing: -Appearance.sizes.elevationMargin // Shadow overlap okay + + Repeater { + model: ScriptModel { + values: root.meaningfulPlayers + } + delegate: PlayerControl { + required property MprisPlayer modelData + player: modelData + visualizerPoints: root.visualizerPoints + } + } + } + } + } + + 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; + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/modules/mediaControls/PlayerControl.qml b/configs/quickshell/modules/mediaControls/PlayerControl.qml new file mode 100644 index 0000000..6f63c83 --- /dev/null +++ b/configs/quickshell/modules/mediaControls/PlayerControl.qml @@ -0,0 +1,297 @@ +import qs.modules.common +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: playerController + required property MprisPlayer player + property var artUrl: player?.trackArtUrl + property string artDownloadLocation: Directories.coverArt + property string artFileName: Qt.md5(artUrl) + ".jpg" + 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 + + implicitWidth: widgetWidth + implicitHeight: widgetHeight + + 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 prevision + running: playerController.player?.playbackState == MprisPlaybackState.Playing + interval: 1000 + repeat: true + onTriggered: { + playerController.player.positionChanged() + } + } + + onArtUrlChanged: { + if (playerController.artUrl.length == 0) { + playerController.artDominantColor = Appearance.m3colors.m3secondaryContainer + return; + } + // console.log("PlayerControl: Art URL changed to", playerController.artUrl) + // console.log("Download cmd:", coverArtDownloader.command.join(" ")) + playerController.downloaded = false + coverArtDownloader.running = true + } + + Process { // Cover art downloader + id: coverArtDownloader + property string targetFile: playerController.artUrl + command: [ "bash", "-c", `[ -f ${artFilePath} ] || curl -sSL '${targetFile}' -o '${artFilePath}'` ] + onExited: (exitCode, exitStatus) => { + playerController.downloaded = true + } + } + + ColorQuantizer { + id: colorQuantizer + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + depth: 0 // 2^0 = 1 color + rescaleSize: 1 // Rescale to 1x1 pixel for faster processing + } + + property bool backgroundIsDark: artDominantColor.hslLightness < 0.5 + property QtObject blendedColors: QtObject { + property color colLayer0: ColorUtils.mix(Appearance.colors.colLayer0, artDominantColor, (backgroundIsDark && Appearance.m3colors.darkmode) ? 0.6 : 0.5) + property color colLayer1: ColorUtils.mix(Appearance.colors.colLayer1, artDominantColor, 0.5) + property color colOnLayer0: ColorUtils.mix(Appearance.colors.colOnLayer0, artDominantColor, 0.5) + property color colOnLayer1: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colSubtext: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimary, artDominantColor), artDominantColor, 0.5) + property color colPrimaryHover: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryHover, artDominantColor), artDominantColor, 0.3) + property color colPrimaryActive: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryActive, artDominantColor), artDominantColor, 0.3) + property color colSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, artDominantColor, 0.15) + property color colSecondaryContainerHover: ColorUtils.mix(Appearance.colors.colSecondaryContainerHover, artDominantColor, 0.3) + property color colSecondaryContainerActive: ColorUtils.mix(Appearance.colors.colSecondaryContainerActive, artDominantColor, 0.5) + property color colOnPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.m3colors.m3onPrimary, artDominantColor), artDominantColor, 0.5) + property color colOnSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3onSecondaryContainer, artDominantColor, 0.5) + + } + + StyledRectangularShadow { + target: background + } + Rectangle { // Background + id: background + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + color: blendedColors.colLayer0 + radius: root.popupRounding + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: background.width + height: background.height + radius: background.radius + } + } + + Image { + id: blurredArt + anchors.fill: parent + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + sourceSize.width: background.width + sourceSize.height: background.height + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + layer.enabled: true + layer.effect: MultiEffect { + source: blurredArt + saturation: 0.2 + blurEnabled: true + blurMax: 100 + blur: 1 + } + + Rectangle { + anchors.fill: parent + color: ColorUtils.transparentize(blendedColors.colLayer0, 0.3) + radius: root.popupRounding + } + } + + WaveVisualizer { + id: visualizerCanvas + anchors.fill: parent + live: playerController.player?.isPlaying + points: playerController.visualizerPoints + maxVisualizerValue: playerController.maxVisualizerValue + smoothing: playerController.visualizerSmoothing + color: blendedColors.colPrimary + } + + RowLayout { + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 15 + + Rectangle { // Art background + id: artBackground + Layout.fillHeight: true + implicitWidth: height + radius: root.artRounding + color: ColorUtils.transparentize(blendedColors.colLayer1, 0.5) + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: artBackground.width + height: artBackground.height + radius: artBackground.radius + } + } + + Image { // Art image + id: mediaArt + property int size: parent.height + anchors.fill: parent + + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: 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(playerController.player?.trackTitle) || "Untitled" + } + StyledText { + id: trackArtist + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: blendedColors.colSubtext + elide: Text.ElideRight + text: playerController.player?.trackArtist + } + 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(playerController.player?.position)} / ${StringUtils.friendlyTimeForSeconds(playerController.player?.length)}` + } + RowLayout { + id: sliderRow + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + TrackChangeButton { + iconName: "skip_previous" + onClicked: playerController.player?.previous() + } + Item { + id: progressBarContainer + Layout.fillWidth: true + implicitHeight: progressBar.implicitHeight + + StyledProgressBar { + id: progressBar + anchors.fill: parent + highlightColor: blendedColors.colPrimary + trackColor: blendedColors.colSecondaryContainer + value: playerController.player?.position / playerController.player?.length + sperm: playerController.player?.isPlaying + } + } + TrackChangeButton { + iconName: "skip_next" + onClicked: playerController.player?.next() + } + } + + RippleButton { + id: playPauseButton + anchors.right: parent.right + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + property real size: 44 + implicitWidth: size + implicitHeight: size + onClicked: playerController.player.togglePlaying(); + + buttonRadius: playerController.player?.isPlaying ? Appearance?.rounding.normal : size / 2 + colBackground: playerController.player?.isPlaying ? blendedColors.colPrimary : blendedColors.colSecondaryContainer + colBackgroundHover: playerController.player?.isPlaying ? blendedColors.colPrimaryHover : blendedColors.colSecondaryContainerHover + colRipple: playerController.player?.isPlaying ? blendedColors.colPrimaryActive : blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: playerController.player?.isPlaying ? blendedColors.colOnPrimary : blendedColors.colOnSecondaryContainer + text: playerController.player?.isPlaying ? "pause" : "play_arrow" + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/notificationPopup/NotificationPopup.qml b/configs/quickshell/modules/notificationPopup/NotificationPopup.qml new file mode 100644 index 0000000..d954cbf --- /dev/null +++ b/configs/quickshell/modules/notificationPopup/NotificationPopup.qml @@ -0,0 +1,50 @@ +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: 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/configs/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml b/configs/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml new file mode 100644 index 0000000..572963c --- /dev/null +++ b/configs/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml @@ -0,0 +1,152 @@ +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.Hyprland +import Quickshell.Wayland + +Scope { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property var brightnessMonitor: Brightness.getMonitorForScreen(focusedScreen) + + function triggerOsd() { + GlobalStates.osdBrightnessOpen = true + osdTimeout.restart() + } + + Timer { + id: osdTimeout + interval: Config.options.osd.timeout + repeat: false + running: false + onTriggered: { + GlobalStates.osdBrightnessOpen = false + } + } + + Connections { + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + GlobalStates.osdBrightnessOpen = false + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + if (!root.brightnessMonitor.ready) return + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdBrightnessOpen + + sourceComponent: PanelWindow { + id: osdRoot + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: osdValues.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: osdValues.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdBrightnessOpen = false + } + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + OsdValueIndicator { + id: osdValues + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + value: root.brightnessMonitor?.brightness ?? 50 + icon: "light_mode" + rotateIcon: true + scaleIcon: true + name: Translation.tr("Brightness") + } + } + } + + } + } + + IpcHandler { + target: "osdBrightness" + + function trigger() { + root.triggerOsd() + } + + function hide() { + GlobalStates.osdBrightnessOpen = false + } + + function toggle() { + GlobalStates.osdBrightnessOpen = !GlobalStates.osdBrightnessOpen + } + } + + GlobalShortcut { + name: "osdBrightnessTrigger" + description: "Triggers brightness OSD on press" + + onPressed: { + root.triggerOsd() + } + } + GlobalShortcut { + name: "osdBrightnessHide" + description: "Hides brightness OSD on press" + + onPressed: { + GlobalStates.osdBrightnessOpen = false + } + } + +} diff --git a/configs/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml b/configs/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml new file mode 100644 index 0000000..6128cdf --- /dev/null +++ b/configs/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml @@ -0,0 +1,203 @@ +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) + + 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() { + GlobalStates.osdVolumeOpen = false + } + } + + Connections { // Listen to volume changes + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + function onMutedChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + } + + Connections { // Listen to protection triggers + target: Audio + function onSinkProtectionTriggered(reason) { + root.protectionMessage = reason; + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdVolumeOpen + + sourceComponent: PanelWindow { + id: osdRoot + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + 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 + Appearance.sizes.elevationMargin * 2 + implicitWidth: contentColumnLayout.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdVolumeOpen = false + } + + ColumnLayout { + id: contentColumnLayout + anchors { + top: parent.top + left: parent.left + right: parent.right + leftMargin: Appearance.sizes.elevationMargin + rightMargin: Appearance.sizes.elevationMargin + } + spacing: 0 + + OsdValueIndicator { + id: osdValues + Layout.fillWidth: true + value: Audio.sink?.audio.volume ?? 0 + icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" + name: Translation.tr("Volume") + } + + Item { + id: protectionMessageWrapper + implicitHeight: protectionMessageBackground.implicitHeight + implicitWidth: protectionMessageBackground.implicitWidth + Layout.alignment: Qt.AlignHCenter + 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/configs/quickshell/modules/onScreenDisplay/OsdValueIndicator.qml b/configs/quickshell/modules/onScreenDisplay/OsdValueIndicator.qml new file mode 100644 index 0000000..6edd24a --- /dev/null +++ b/configs/quickshell/modules/onScreenDisplay/OsdValueIndicator.qml @@ -0,0 +1,104 @@ +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 +import Quickshell.Widgets +// import Qt5Compat.GraphicalEffects + +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 + + Layout.margins: Appearance.sizes.elevationMargin + implicitWidth: Appearance.sizes.osdWidth + implicitHeight: valueIndicator.implicitHeight + + StyledRectangularShadow { + target: valueIndicator + } + WrapperRectangle { + id: valueIndicator + anchors.fill: parent + radius: Appearance.rounding.full + color: Appearance.colors.colLayer0 + implicitWidth: valueRow.implicitWidth + + 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 + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/onScreenKeyboard/OnScreenKeyboard.qml b/configs/quickshell/modules/onScreenKeyboard/OnScreenKeyboard.qml new file mode 100644 index 0000000..eae543c --- /dev/null +++ b/configs/quickshell/modules/onScreenKeyboard/OnScreenKeyboard.qml @@ -0,0 +1,166 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: Config.options?.osk.pinnedOnStartup ?? false + + component OskControlButton: GroupButton { // Pin button + baseWidth: 40 + baseHeight: 40 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + } + + Loader { + id: oskLoader + active: GlobalStates.oskOpen + onActiveChanged: { + if (!oskLoader.active) { + Ydotool.releaseAllKeys(); + } + } + + sourceComponent: PanelWindow { // Window + id: oskRoot + visible: oskLoader.active && !GlobalStates.screenLocked + + anchors { + bottom: true + left: true + right: true + } + + function hide() { + oskLoader.active = false + } + exclusiveZone: root.pinned ? implicitHeight - Appearance.sizes.hyprlandGapsOut : 0 + implicitWidth: oskBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: oskBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:osk" + WlrLayershell.layer: WlrLayer.Overlay + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: oskBackground + } + + + // Background + StyledRectangularShadow { + target: oskBackground + } + Rectangle { + id: oskBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + property real padding: 10 + implicitWidth: oskRowLayout.implicitWidth + padding * 2 + implicitHeight: oskRowLayout.implicitHeight + padding * 2 + + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + oskRoot.hide() + } + } + + RowLayout { + id: oskRowLayout + anchors.centerIn: parent + spacing: 5 + VerticalButtonGroup { + OskControlButton { // Pin button + 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 + } + } + OskControlButton { + onClicked: () => { + oskRoot.hide() + } + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + text: "keyboard_hide" + iconSize: Appearance.font.pixelSize.larger + } + } + } + Rectangle { + Layout.topMargin: 20 + Layout.bottomMargin: 20 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + OskContent { + id: oskContent + Layout.fillWidth: true + } + } + } + + } + } + + IpcHandler { + target: "osk" + + function toggle(): void { + GlobalStates.oskOpen = !GlobalStates.oskOpen; + } + + function close(): void { + GlobalStates.oskOpen = false + } + + function open(): void { + GlobalStates.oskOpen = true + } + } + + GlobalShortcut { + name: "oskToggle" + description: "Toggles on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = !GlobalStates.oskOpen; + } + } + + GlobalShortcut { + name: "oskOpen" + description: "Opens on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = true + } + } + + GlobalShortcut { + name: "oskClose" + description: "Closes on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = false + } + } + +} diff --git a/configs/quickshell/modules/onScreenKeyboard/OskContent.qml b/configs/quickshell/modules/onScreenKeyboard/OskContent.qml new file mode 100644 index 0000000..df37969 --- /dev/null +++ b/configs/quickshell/modules/onScreenKeyboard/OskContent.qml @@ -0,0 +1,49 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import "layouts.js" as Layouts +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +Item { + id: root + property var layouts: Layouts.byName + property var activeLayoutName: (layouts.hasOwnProperty(Config.options?.osk.layout)) + ? Config.options?.osk.layout + : Layouts.defaultLayout + property var currentLayout: layouts[activeLayoutName] + + implicitWidth: keyRows.implicitWidth + implicitHeight: keyRows.implicitHeight + + ColumnLayout { + id: keyRows + anchors.fill: parent + spacing: 5 + + Repeater { + model: root.currentLayout.keys + + delegate: RowLayout { + id: keyRow + required property var modelData + spacing: 5 + + Repeater { + model: modelData + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + delegate: OskKey { + required property var modelData + keyData: modelData + } + } + } + } + } +} diff --git a/configs/quickshell/modules/onScreenKeyboard/OskKey.qml b/configs/quickshell/modules/onScreenKeyboard/OskKey.qml new file mode 100644 index 0000000..5ae53b9 --- /dev/null +++ b/configs/quickshell/modules/onScreenKeyboard/OskKey.qml @@ -0,0 +1,121 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: root + property var keyData + property string key: keyData.label + property string type: keyData.keytype + property var keycode: keyData.keycode + property string shape: keyData.shape + property bool isShift: Ydotool.shiftKeys.includes(keycode) + property bool isBackspace: (key.toLowerCase() == "backspace") + property bool isEnter: (key.toLowerCase() == "enter" || key.toLowerCase() == "return") + property real baseWidth: 45 + property real baseHeight: 45 + property var widthMultiplier: ({ + "normal": 1, + "fn": 1, + "tab": 1.6, + "caps": 1.9, + "shift": 2.5, + "control": 1.3 + }) + property var heightMultiplier: ({ + "normal": 1, + "fn": 0.7, + "tab": 1, + "caps": 1, + "shift": 1, + "control": 1 + }) + toggled: isShift ? Ydotool.shiftMode : false + + enabled: shape != "empty" + colBackground: shape == "empty" ? ColorUtils.transparentize(Appearance.colors.colLayer1) : Appearance.colors.colLayer1 + buttonRadius: Appearance.rounding.small + implicitWidth: baseWidth * widthMultiplier[shape] || baseWidth + implicitHeight: baseHeight * heightMultiplier[shape] || baseHeight + Layout.fillWidth: shape == "space" || shape == "expand" + + Connections { + target: Ydotool + enabled: isShift + function onShiftModeChanged() { + if (Ydotool.shiftMode == 0) { + capsLockTimer.hasStarted = false; + } + } + } + + Timer { + id: capsLockTimer + property bool hasStarted: false + property bool canCaps: false + interval: 300 + function startWaiting() { + hasStarted = true; + canCaps = true; + start(); + } + onTriggered: { + canCaps = false; + } + } + + downAction: () => { + Ydotool.press(root.keycode); + if (isShift && Ydotool.shiftMode == 0) Ydotool.shiftMode = 1; + } + releaseAction: () => { + if (root.type == "normal") { + Ydotool.release(root.keycode); + if (Ydotool.shiftMode == 1) { + Ydotool.releaseShiftKeys() + } + } else if (isShift) { + if (Ydotool.shiftMode == 1) { + if (!capsLockTimer.hasStarted) { + capsLockTimer.startWaiting(); + } else { + if (capsLockTimer.canCaps) { + Ydotool.shiftMode = 2; // Caps lock mode + } else { + Ydotool.releaseShiftKeys() + } + } + } else if (Ydotool.shiftMode == 2) { + Ydotool.releaseShiftKeys(); + } + } else if (root.type == "modkey") { + root.toggled = !root.toggled; + if (!root.toggled) { + if (isShift) { + Ydotool.releaseShiftKeys(); + } else { + Ydotool.release(root.keycode); + } + } + } + + } + + contentItem: StyledText { + id: keyText + anchors.fill: parent + font.family: (isBackspace || isEnter) ? Appearance.font.family.iconMaterial : Appearance.font.family.main + font.pixelSize: root.shape == "fn" ? Appearance.font.pixelSize.small : + (isBackspace || isEnter) ? Appearance.font.pixelSize.huge : + Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: root.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + text: root.isBackspace ? "backspace" : root.isEnter ? "subdirectory_arrow_left" : + Ydotool.shiftMode == 2 ? (root.keyData.labelCaps || root.keyData.labelShift || root.keyData.label) : + Ydotool.shiftMode == 1 ? (root.keyData.labelShift || root.keyData.label) : + root.keyData.label + } +} diff --git a/configs/quickshell/modules/onScreenKeyboard/layouts.js b/configs/quickshell/modules/onScreenKeyboard/layouts.js new file mode 100644 index 0000000..949dd45 --- /dev/null +++ b/configs/quickshell/modules/onScreenKeyboard/layouts.js @@ -0,0 +1,312 @@ +// We're going to use ydotool +// See /usr/include/linux/input-event-codes.h for keycodes + +const defaultLayout = "English (US)"; +const byName = { + "English (US)": { + name_short: "US", + description: "QWERTY - Full", + comment: "Like physical keyboard", + // A key looks like this: { k: "a", ks: "A", t: "normal" } (key, key-shift, type) + // key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand + // keys: [ + // [{ k: "Esc", t: "fn" }, { k: "F1", t: "fn" }, { k: "F2", t: "fn" }, { k: "F3", t: "fn" }, { k: "F4", t: "fn" }, { k: "F5", t: "fn" }, { k: "F6", t: "fn" }, { k: "F7", t: "fn" }, { k: "F8", t: "fn" }, { k: "F9", t: "fn" }, { k: "F10", t: "fn" }, { k: "F11", t: "fn" }, { k: "F12", t: "fn" }, { k: "PrtSc", t: "fn" }, { k: "Del", t: "fn" }], + // [{ k: "`", ks: "~", t: "normal" }, { k: "1", ks: "!", t: "normal" }, { k: "2", ks: "@", t: "normal" }, { k: "3", ks: "#", t: "normal" }, { k: "4", ks: "$", t: "normal" }, { k: "5", ks: "%", t: "normal" }, { k: "6", ks: "^", t: "normal" }, { k: "7", ks: "&", t: "normal" }, { k: "8", ks: "*", t: "normal" }, { k: "9", ks: "(", t: "normal" }, { k: "0", ks: ")", t: "normal" }, { k: "-", ks: "_", t: "normal" }, { k: "=", ks: "+", t: "normal" }, { k: "Backspace", t: "shift" }], + // [{ k: "Tab", t: "tab" }, { k: "q", ks: "Q", t: "normal" }, { k: "w", ks: "W", t: "normal" }, { k: "e", ks: "E", t: "normal" }, { k: "r", ks: "R", t: "normal" }, { k: "t", ks: "T", t: "normal" }, { k: "y", ks: "Y", t: "normal" }, { k: "u", ks: "U", t: "normal" }, { k: "i", ks: "I", t: "normal" }, { k: "o", ks: "O", t: "normal" }, { k: "p", ks: "P", t: "normal" }, { k: "[", ks: "{", t: "normal" }, { k: "]", ks: "}", t: "normal" }, { k: "\\", ks: "|", t: "expand" }], + // [{ k: "Caps", t: "caps" }, { k: "a", ks: "A", t: "normal" }, { k: "s", ks: "S", t: "normal" }, { k: "d", ks: "D", t: "normal" }, { k: "f", ks: "F", t: "normal" }, { k: "g", ks: "G", t: "normal" }, { k: "h", ks: "H", t: "normal" }, { k: "j", ks: "J", t: "normal" }, { k: "k", ks: "K", t: "normal" }, { k: "l", ks: "L", t: "normal" }, { k: ";", ks: ":", t: "normal" }, { k: "'", ks: '"', t: "normal" }, { k: "Enter", t: "expand" }], + // [{ k: "Shift", t: "shift" }, { k: "z", ks: "Z", t: "normal" }, { k: "x", ks: "X", t: "normal" }, { k: "c", ks: "C", t: "normal" }, { k: "v", ks: "V", t: "normal" }, { k: "b", ks: "B", t: "normal" }, { k: "n", ks: "N", t: "normal" }, { k: "m", ks: "M", t: "normal" }, { k: ",", ks: "<", t: "normal" }, { k: ".", ks: ">", t: "normal" }, { k: "/", ks: "?", t: "normal" }, { k: "Shift", t: "expand" }], + // [{ k: "Ctrl", t: "control" }, { k: "Fn", t: "normal" }, { k: "Win", t: "normal" }, { k: "Alt", t: "normal" }, { k: "Space", t: "space" }, { k: "Alt", t: "normal" }, { k: "Menu", t: "normal" }, { k: "Ctrl", t: "control" }] + // ] + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + // A modkey looks like this: {label: "Ctrl", shape: "control", keycode: 29, type: "modkey"} + // key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "PrtSc", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Del", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "`", labelShift: "~", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "@", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "#", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: "$", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: "^", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "&", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "*", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: "(", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: ")", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "-", labelShift: "_", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "=", labelShift: "+", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "Backspace", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "q", labelShift: "Q", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "w", labelShift: "W", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "e", labelShift: "E", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "r", labelShift: "R", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "t", labelShift: "T", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "y", labelShift: "Y", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "u", labelShift: "U", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "i", labelShift: "I", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "o", labelShift: "O", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "p", labelShift: "P", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "[", labelShift: "{", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "]", labelShift: "}", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "\\", labelShift: "|", shape: "expand", keycode: 43 } + ], + [ + //{ keytype: "normal", label: "Caps", shape: "caps", keycode: 58 }, // not needed as double-pressing shift does that + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "a", labelShift: "A", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "s", labelShift: "S", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "d", labelShift: "D", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "f", labelShift: "F", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "g", labelShift: "G", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "h", labelShift: "H", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "j", labelShift: "J", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "k", labelShift: "K", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "l", labelShift: "L", shape: "normal", keycode: 38 }, + { keytype: "normal", label: ";", labelShift: ":", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "'", labelShift: '"', shape: "normal", keycode: 40 }, + { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "z", labelShift: "Z", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "x", labelShift: "X", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "c", labelShift: "C", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "v", labelShift: "V", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "b", labelShift: "B", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "n", labelShift: "N", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "m", labelShift: "M", shape: "normal", keycode: 50 }, + { keytype: "normal", label: ",", labelShift: "<", shape: "normal", keycode: 51 }, + { keytype: "normal", label: ".", labelShift: ">", shape: "normal", keycode: 52 }, + { keytype: "normal", label: "/", labelShift: "?", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "expand", keycode: 54 } // optional + ], + [ + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, + // { label: "Super", shape: "normal", keycode: 125 }, // dangerous + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Space", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 100 }, + // { label: "Super", shape: "normal", keycode: 126 }, // dangerous + { keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 97 } + ] + ] + }, + "German": { + name_short: "DE", + description: "QWERTZ - Full", + comment: "Keyboard layout commonly used in German-speaking countries", + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "Druck", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Entf", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "^", labelShift: "ยฐ", labelAlt: "โ€ฒ", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", labelAlt: "ยน", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "\"", labelAlt: "ยฒ", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "ยง", labelAlt: "ยณ", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: "$", labelAlt: "ยผ", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", labelAlt: "ยฝ", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: "&", labelAlt: "ยฌ", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "/", labelAlt: "{", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "(", labelAlt: "[", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: ")", labelAlt: "]", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: "=", labelAlt: "}", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "รŸ", labelShift: "?", labelAlt: "\\", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "ยด", labelShift: "`", labelAlt: "ยธ", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "โŸต", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab โ‡†", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "q", labelShift: "Q", labelAlt: "@", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "w", labelShift: "W", labelAlt: "ลฟ", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "e", labelShift: "E", labelAlt: "โ‚ฌ", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "r", labelShift: "R", labelAlt: "ยถ", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "t", labelShift: "T", labelAlt: "ลง", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "z", labelShift: "Z", labelAlt: "โ†", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "u", labelShift: "U", labelAlt: "โ†“", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "i", labelShift: "I", labelAlt: "โ†’", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "o", labelShift: "O", labelAlt: "รธ", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "p", labelShift: "P", labelAlt: "รพ", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "รผ", labelShift: "รœ", labelAlt: "ยจ", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "+", labelShift: "*", labelAlt: "~", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "โ†ต", shape: "expand", keycode: 28 } + ], + [ + //{ keytype: "normal", label: "Umschalt โ‡ฉ", shape: "caps", keycode: 58 }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "a", labelShift: "A", labelAlt: "รฆ", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "s", labelShift: "S", labelAlt: "ลฟ", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "d", labelShift: "D", labelAlt: "รฐ", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "f", labelShift: "F", labelAlt: "ฤ‘", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "g", labelShift: "G", labelAlt: "ล‹", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "h", labelShift: "H", labelAlt: "ฤง", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "j", labelShift: "J", labelAlt: "", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "k", labelShift: "K", labelAlt: "ฤธ", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "l", labelShift: "L", labelAlt: "ล‚", shape: "normal", keycode: 38 }, + { keytype: "normal", label: "รถ", labelShift: "ร–", labelAlt: "ห", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "รค", labelShift: 'ร„', labelAlt: "^", shape: "normal", keycode: 40 }, + { keytype: "normal", label: "#", labelShift: '\'', labelAlt: "โ€™", shape: "normal", keycode: 43 }, + { keytype: "spacer", label: "", shape: "empty" }, + //{ keytype: "normal", label: "โ†ต", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", labelShift: "Shift โ‡ง", labelCaps: "Locked โ‡ฉ", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "<", labelShift: ">", labelAlt: "|", shape: "normal", keycode: 86 }, + { keytype: "normal", label: "y", labelShift: "Y", labelAlt: "ยป", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "x", labelShift: "X", labelAlt: "ยซ", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "c", labelShift: "C", labelAlt: "ยข", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "v", labelShift: "V", labelAlt: "โ€ž", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "b", labelShift: "B", labelAlt: "โ€œ", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "n", labelShift: "N", labelAlt: "โ€", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "m", labelShift: "M", labelAlt: "ยต", shape: "normal", keycode: 50 }, + { keytype: "normal", label: ",", labelShift: ";", labelAlt: "ยท", shape: "normal", keycode: 51 }, + { keytype: "normal", label: ".", labelShift: ":", labelAlt: "โ€ฆ", shape: "normal", keycode: 52 }, + { keytype: "normal", label: "-", labelShift: "_", labelAlt: "โ€“", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift โ‡ง", labelCaps: "Locked โ‡ฉ", shape: "expand", keycode: 54 }, // optional + ], + [ + { keytype: "modkey", label: "Strg", shape: "control", keycode: 29 }, + //{ keytype: "normal", label: "๏Œš", shape: "normal", keycode: 125 }, // dangerous + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Leertaste", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Altโ€‰Gr", shape: "normal", keycode: 100 }, + // { label: "Super", shape: "normal", keycode: 126 }, // dangerous + //{ keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, // doesn't work? + { keytype: "modkey", label: "Strg", shape: "control", keycode: 97 }, + { keytype: "normal", label: "โ‡ฆ", shape: "normal", keycode: 105 }, + { keytype: "normal", label: "โ‡จ", shape: "normal", keycode: 106 }, + ] + ] + }, + "Russian": { + name_short: "RU", + description: "ะ™ะฆะฃะšะ•ะ - Full", + comment: "Standard Russian keyboard layout", + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "PrtSc", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Del", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "ั‘", labelShift: "ะ", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "\"", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "โ„–", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: ";", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: ":", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "?", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "*", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: "(", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: ")", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "-", labelShift: "_", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "=", labelShift: "+", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "Backspace", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "ะน", labelShift: "ะ™", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "ั†", labelShift: "ะฆ", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "ัƒ", labelShift: "ะฃ", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "ะบ", labelShift: "ะš", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "ะต", labelShift: "ะ•", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "ะฝ", labelShift: "ะ", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "ะณ", labelShift: "ะ“", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "ัˆ", labelShift: "ะจ", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "ั‰", labelShift: "ะฉ", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "ะท", labelShift: "ะ—", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "ั…", labelShift: "ะฅ", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "ัŠ", labelShift: "ะช", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "\\", labelShift: "/", shape: "expand", keycode: 43 } + ], + [ + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "ั„", labelShift: "ะค", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "ั‹", labelShift: "ะซ", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "ะฒ", labelShift: "ะ’", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "ะฐ", labelShift: "ะ", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "ะฟ", labelShift: "ะŸ", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "ั€", labelShift: "ะ ", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "ะพ", labelShift: "ะž", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "ะป", labelShift: "ะ›", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "ะด", labelShift: "ะ”", shape: "normal", keycode: 38 }, + { keytype: "normal", label: "ะถ", labelShift: "ะ–", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "ั", labelShift: "ะญ", shape: "normal", keycode: 40 }, + { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "ั", labelShift: "ะฏ", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "ั‡", labelShift: "ะง", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "ั", labelShift: "ะก", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "ะผ", labelShift: "ะœ", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "ะธ", labelShift: "ะ˜", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "ั‚", labelShift: "ะข", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "ัŒ", labelShift: "ะฌ", shape: "normal", keycode: 50 }, + { keytype: "normal", label: "ะฑ", labelShift: "ะ‘", shape: "normal", keycode: 51 }, + { keytype: "normal", label: "ัŽ", labelShift: "ะฎ", shape: "normal", keycode: 52 }, + { keytype: "normal", label: ".", labelShift: ",", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", shape: "expand", keycode: 54 } + ], + [ + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Space", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 100 }, + { keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 97 } + ] + ] + } +} diff --git a/configs/quickshell/modules/overview/Overview.qml b/configs/quickshell/modules/overview/Overview.qml new file mode 100644 index 0000000..80c692b --- /dev/null +++ b/configs/quickshell/modules/overview/Overview.qml @@ -0,0 +1,246 @@ +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: 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 + } + // HyprlandWindow.visibleMask: Region { // Buggy with scaled monitors + // item: GlobalStates.overviewOpen ? columnLayout : null + // } + + anchors { + top: true + bottom: true + left: !(Config?.options.overview.enable ?? true) + right: !(Config?.options.overview.enable ?? 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(); + } + + ColumnLayout { + id: columnLayout + visible: GlobalStates.overviewOpen + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + } + + 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"); + } + } + + Item { + height: 1 // Prevent Wayland protocol error + width: 1 // Prevent Wayland protocol error + } + + SearchWidget { + id: searchWidget + Layout.alignment: Qt.AlignHCenter + onSearchingTextChanged: text => { + root.searchingText = searchingText; + } + } + + Loader { + id: overviewLoader + 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 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: "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/configs/quickshell/modules/overview/Overview.qml.template b/configs/quickshell/modules/overview/Overview.qml.template new file mode 100644 index 0000000..9c44b52 --- /dev/null +++ b/configs/quickshell/modules/overview/Overview.qml.template @@ -0,0 +1,160 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland + +Rectangle { + id: overview + + property bool visible: false + property var workspaces: [] + property var windows: [] + + color: "@BACKGROUND_COLOR@" + radius: 12 + + // Window previews with drag-and-drop + GridView { + id: windowGrid + anchors.fill: parent + anchors.margins: 20 + + cellWidth: 300 + cellHeight: 200 + + model: overview.windows + + delegate: Rectangle { + width: windowGrid.cellWidth - 10 + height: windowGrid.cellHeight - 10 + + color: "@SURFACE_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: 1 + + // Window preview + Image { + id: windowPreview + anchors.fill: parent + anchors.margins: 4 + source: modelData.preview || "" + fillMode: Image.PreserveAspectFit + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 30 + color: "@SURFACE_VARIANT_COLOR@" + radius: 4 + + Text { + anchors.centerIn: parent + text: modelData.title || "Unknown" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + + // Drag and drop functionality + MouseArea { + anchors.fill: parent + drag.target: parent + + onClicked: { + // Focus window + HyprlandIpc.dispatch("focuswindow", "address:" + modelData.address) + overview.visible = false + } + + onReleased: { + // Handle workspace drop + var workspace = getWorkspaceAt(parent.x, parent.y) + if (workspace) { + HyprlandIpc.dispatch("movetoworkspace", workspace + ",address:" + modelData.address) + } + } + } + } + } + + // Workspace indicators + Row { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.margins: 20 + spacing: 10 + + Repeater { + model: overview.workspaces + + Rectangle { + width: 40 + height: 8 + radius: 4 + color: modelData.active ? "@PRIMARY_COLOR@" : "@OUTLINE_COLOR@" + + MouseArea { + anchors.fill: parent + onClicked: { + HyprlandIpc.dispatch("workspace", modelData.id) + overview.visible = false + } + } + } + } + } + + // Search functionality + Rectangle { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.margins: 20 + + width: 400 + height: 40 + radius: 20 + color: "@SURFACE_COLOR@" + border.color: "@OUTLINE_COLOR@" + + TextInput { + id: searchInput + anchors.fill: parent + anchors.margins: 15 + + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 14 + placeholderText: "Search applications, calculate, or run commands..." + + onTextChanged: { + // Implement search logic + performSearch(text) + } + + Keys.onReturnPressed: { + // Execute search result + executeSearchResult() + } + } + } + + function performSearch(query) { + // Implementation for search functionality + // - Application search + // - Calculator + // - Command execution + // - Directory navigation + } + + function executeSearchResult() { + // Execute the selected search result + } + + function getWorkspaceAt(x, y) { + // Determine workspace based on drop position + return null + } +} diff --git a/configs/quickshell/modules/overview/OverviewWidget.qml b/configs/quickshell/modules/overview/OverviewWidget.qml new file mode 100644 index 0000000..550d72c --- /dev/null +++ b/configs/quickshell/modules/overview/OverviewWidget.qml @@ -0,0 +1,268 @@ +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.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 workspaceNumberMargin: 80 + property real workspaceNumberSize: Math.min(workspaceImplicitHeight, workspaceImplicitWidth) * 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: Appearance.rounding.screenRounding * root.scale + padding + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { // Workspaces + id: workspaceColumnLayout + + z: root.workspaceZ + anchors.centerIn: parent + spacing: workspaceSpacing + Repeater { + model: Config.options.overview.rows + delegate: RowLayout { + id: row + property int rowIndex: index + spacing: workspaceSpacing + + Repeater { // Workspace repeater + model: Config.options.overview.columns + Rectangle { // Workspace + id: workspace + property int colIndex: index + property int workspaceValue: root.workspaceGroup * workspacesShown + rowIndex * Config.options.overview.columns + colIndex + 1 + property color defaultWorkspaceColor: Appearance.colors.colLayer1 // TODO: reconsider this color for a cleaner look + 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 + radius: Appearance.rounding.screenRounding * root.scale + border.width: 2 + border.color: hoveredWhileDragging ? hoveredBorderColor : "transparent" + + StyledText { + anchors.centerIn: parent + text: workspaceValue + font.pixelSize: root.workspaceNumberSize * root.scale + font.weight: Font.DemiBold + color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, 0.8) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + MouseArea { + id: workspaceArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: { + if (root.draggingTargetWorkspace === -1) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`workspace ${workspaceValue}`) + } + } + } + + DropArea { + anchors.fill: parent + onEntered: { + root.draggingTargetWorkspace = workspaceValue + if (root.draggingFromWorkspace == root.draggingTargetWorkspace) return; + hoveredWhileDragging = true + } + onExited: { + hoveredWhileDragging = false + if (root.draggingTargetWorkspace == 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) + const inMonitor = root.monitor.id === win.monitor + return inWorkspaceGroup && inMonitor; + }) + } + } + delegate: OverviewWindow { + id: window + required property var modelData + property var address: `0x${modelData.HyprlandToplevel.address}` + windowData: windowByAddress[address] + toplevel: modelData + monitorData: HyprlandData.monitors[monitorId] + scale: root.scale + availableWorkspaceWidth: root.workspaceImplicitWidth + availableWorkspaceHeight: root.workspaceImplicitHeight + + property int monitorId: windowData?.monitor + property var monitor: HyprlandData.monitors[monitorId] + + property bool atInitPosition: (initX == x && initY == y) + + 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 + + Timer { + id: updateWindowPosition + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + running: false + onTriggered: { + window.x = Math.round(Math.max((windowData?.at[0] - (monitor?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + xOffset) + window.y = Math.round(Math.max((windowData?.at[1] - (monitor?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + yOffset) + } + } + + z: atInitPosition ? root.windowZ : root.windowDraggingZ + Drag.hotSpot.x: targetWindowWidth / 2 + Drag.hotSpot.y: targetWindowHeight / 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 { + window.x = window.initX + window.y = window.initY + } + } + 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 + content: `${windowData.title}\n[${windowData.class}] ${windowData.xwayland ? "[XWayland] " : ""}\n` + } + } + } + } + + Rectangle { // Focused workspace indicator + id: focusedWorkspaceIndicator + property int activeWorkspaceInGroup: monitor.activeWorkspace?.id - (root.workspaceGroup * root.workspacesShown) + property int activeWorkspaceRowIndex: Math.floor((activeWorkspaceInGroup - 1) / Config.options.overview.columns) + property int activeWorkspaceColIndex: (activeWorkspaceInGroup - 1) % Config.options.overview.columns + x: (root.workspaceImplicitWidth + workspaceSpacing) * activeWorkspaceColIndex + y: (root.workspaceImplicitHeight + workspaceSpacing) * activeWorkspaceRowIndex + z: root.windowZ + width: root.workspaceImplicitWidth + height: root.workspaceImplicitHeight + color: "transparent" + radius: Appearance.rounding.screenRounding * root.scale + 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) + } + } + } + } +} diff --git a/configs/quickshell/modules/overview/OverviewWindow.qml b/configs/quickshell/modules/overview/OverviewWindow.qml new file mode 100644 index 0000000..8029ec4 --- /dev/null +++ b/configs/quickshell/modules/overview/OverviewWindow.qml @@ -0,0 +1,114 @@ +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 + +Item { // Window + id: root + property var toplevel + property var windowData + property var monitorData + property var scale + property var availableWorkspaceWidth + property var availableWorkspaceHeight + property bool restrictToWorkspace: true + property real initX: Math.max((windowData?.at[0] - (monitorData?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + xOffset + property real initY: Math.max((windowData?.at[1] - (monitorData?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + yOffset + property real xOffset: 0 + property real yOffset: 0 + + property var targetWindowWidth: windowData?.size[0] * scale + property var targetWindowHeight: windowData?.size[1] * scale + property bool hovered: false + property bool pressed: false + + property var iconToWindowRatio: 0.35 + property var xwaylandIndicatorToIconRatio: 0.35 + property var iconToWindowRatioCompact: 0.6 + property var 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: windowData?.size[0] * root.scale + height: windowData?.size[1] * root.scale + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: Appearance.rounding.windowRounding * root.scale + } + } + + 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 + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.windowRounding * root.scale + 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.7) + border.width : 1 + } + + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.font.pixelSize.smaller * 0.5 + + Image { + id: windowIcon + 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 Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / root.monitorData.scale; + } + // 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) + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/overview/SearchItem.qml b/configs/quickshell/modules/overview/SearchItem.qml new file mode 100644 index 0000000..921fc8b --- /dev/null +++ b/configs/quickshell/modules/overview/SearchItem.qml @@ -0,0 +1,267 @@ +// pragma NativeMethodBehavior: AcceptThisObject +import qs +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 + property string bigText: entry?.bigText ?? "" + property string materialSymbol: entry?.materialSymbol ?? "" + property string cliphistRawString: entry?.cliphistRawString ?? "" + + visible: root.entryShown + property int horizontalMargin: 10 + property int buttonHorizontalPadding: 10 + property int buttonVerticalPadding: 5 + 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.colSecondaryContainerActive : + ((root.hovered || root.focus) ? Appearance.colors.colSecondaryContainerHover : + ColorUtils.transparentize(Appearance.colors.colSecondaryContainer, 1)) + colBackgroundHover: Appearance.colors.colSecondaryContainerHover + colRipple: Appearance.colors.colSecondaryContainerActive + + 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: { + root.itemExecute() + GlobalStates.overviewOpen = false + } + Keys.onPressed: (event) => { + 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 && /^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(root.cliphistRawString) + sourceComponent: CliphistImage { + Layout.fillWidth: true + entry: root.cliphistRawString + maxWidth: contentColumn.width + maxHeight: 140 + } + } + } + + // Action text + StyledText { + Layout.fillWidth: false + visible: (root.hovered || root.focus) + id: clickAction + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: root.itemClickActionName + } + + RowLayout { + spacing: 4 + Repeater { + model: (root.entry.actions ?? []).slice(0, 4) + delegate: RippleButton { + id: actionButton + required property var modelData + implicitHeight: 34 + implicitWidth: 34 + + contentItem: Item { + id: actionContentItem + anchors.centerIn: parent + Loader { + anchors.centerIn: parent + active: !(actionButton.modelData.icon && actionButton.modelData.icon !== "") + sourceComponent: MaterialSymbol { + text: "video_settings" + font.pixelSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onSurface + } + } + Loader { + anchors.centerIn: parent + active: actionButton.modelData.icon && actionButton.modelData.icon !== "" + sourceComponent: IconImage { + source: Quickshell.iconPath(actionButton.modelData.icon) + implicitSize: 20 + } + } + } + + onClicked: modelData.execute() + + StyledToolTip { + content: modelData.name + } + } + } + } + + } +} diff --git a/configs/quickshell/modules/overview/SearchWidget.qml b/configs/quickshell/modules/overview/SearchWidget.qml new file mode 100644 index 0000000..51100d2 --- /dev/null +++ b/configs/quickshell/modules/overview/SearchWidget.qml @@ -0,0 +1,423 @@ +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 + +Item { // Wrapper + id: root + readonly property string xdgConfigHome: Directories.config + property string searchingText: "" + property bool showResults: searchingText != "" + property real searchBarHeight: searchBar.height + Appearance.sizes.elevationMargin * 2 + implicitWidth: searchWidgetContent.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: searchWidgetContent.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property string mathResult: "" + + function disableExpandAnimation() { + searchWidthBehavior.enabled = false; + } + + function cancelSearch() { + searchInput.selectAll(); + root.searchingText = ""; + searchWidthBehavior.enabled = true; + } + + function setSearchingText(text) { + searchInput.text = text; + root.searchingText = text; + } + + property var searchActions: [ + { + action: "dark", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]); + } + }, + { + action: "light", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]); + } + }, + { + action: "wall", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath]); + } + }, + { + action: "konachanwall", + execute: () => { + Quickshell.execDetached([Quickshell.shellPath("scripts/colors/random_konachan_wall.sh")]); + } + }, + { + action: "accentcolor", + execute: args => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--noswitch", "--color", ...(args != '' ? [`${args}`] : [])]); + } + }, + { + action: "todo", + execute: args => { + Todo.addTask(args); + } + }, + ] + + function focusFirstItem() { + appResults.currentIndex = 0; + } + + Timer { + id: nonAppResultsTimer + interval: Config.options.search.nonAppResultDelay + onTriggered: { + mathProcess.calculateExpression(root.searchingText); + } + } + + 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 (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + if (event.modifiers & Qt.ControlModifier) { + // Delete word before cursor + let text = searchInput.text; + let pos = 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; + searchInput.text = text.slice(0, pos - deleteLen) + text.slice(pos); + searchInput.cursorPosition = pos - deleteLen; + } + } else { + // Delete character before cursor if any + if (searchInput.cursorPosition > 0) { + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition - 1) + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition -= 1; + } + } + // Always move cursor to end after programmatic edit + searchInput.cursorPosition = 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.text.charCodeAt(0) >= 0x20) // ignore control chars like Backspace, Tab, etc. + { + if (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + // Insert the character at the cursor position + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition) + event.text + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition += 1; + event.accepted = true; + } + } + } + + StyledRectangularShadow { + target: searchWidgetContent + } + Rectangle { // Background + id: searchWidgetContent + anchors.centerIn: parent + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + radius: Appearance.rounding.large + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 0 + + // clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: searchWidgetContent.width + height: searchWidgetContent.width + radius: searchWidgetContent.radius + } + } + + RowLayout { + id: searchBar + spacing: 5 + MaterialSymbol { + id: searchIcon + Layout.leftMargin: 15 + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onSurface + text: root.searchingText.startsWith(Config.options.search.prefix.clipboard) ? 'content_paste_search' : 'search' + } + TextField { // Search box + id: searchInput + + focus: GlobalStates.overviewOpen + Layout.rightMargin: 15 + padding: 15 + renderType: Text.NativeRendering + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: Translation.tr("Search, calculate or run") + placeholderTextColor: Appearance.m3colors.m3outline + implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth + + Behavior on implicitWidth { + id: searchWidthBehavior + enabled: false + 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(); + } + } + } + + background: null + + cursorDelegate: Rectangle { + width: 1 + color: searchInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + } + + Rectangle { + // Separator + visible: root.showResults + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + StyledListView { // 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 + add: null + remove: null + + onFocusChanged: { + if (focus) + appResults.currentIndex = 1; + } + + Connections { + target: root + function onSearchingTextChanged() { + if (appResults.count > 0) + appResults.currentIndex = 0; + } + } + + model: ScriptModel { + id: model + onValuesChanged: { + root.focusFirstItem(); + } + 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 = root.searchingText.slice(Config.options.search.prefix.clipboard.length); + return Cliphist.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: `#${entry.match(/^\s*(\S+)/)?.[1] || ""}`, + execute: () => { + Cliphist.copy(entry) + }, + actions: [ + { + name: "Delete", + icon: "delete", + execute: () => { + Cliphist.deleteEntry(entry); + } + } + ] + }; + }).filter(Boolean); + } + if (root.searchingText.startsWith(Config.options.search.prefix.emojis)) { + // Clipboard + const searchString = root.searchingText.slice(Config.options.search.prefix.emojis.length); + return Emojis.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + bigText: entry.match(/^\s*(\S+)/)?.[1] || "", + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: "Emoji", + execute: () => { + Quickshell.clipboardText = entry.match(/^\s*(\S+)/)?.[1]; + } + }; + }).filter(Boolean); + } + + ////////////////// Init /////////////////// + nonAppResultsTimer.restart(); + const mathResultObject = { + name: root.mathResult, + clickActionName: Translation.tr("Copy"), + type: Translation.tr("Math result"), + fontType: "monospace", + materialSymbol: 'calculate', + execute: () => { + Quickshell.clipboardText = root.mathResult; + } + }; + const commandResultObject = { + name: searchingText.replace("file://", ""), + clickActionName: Translation.tr("Run"), + type: Translation.tr("Run command"), + fontType: "monospace", + materialSymbol: 'terminal', + execute: () => { + const cleanedCommand = root.searchingText.replace("file://", ""); + Quickshell.execDetached(["bash", "-c", searchingText.startsWith('sudo') ? `${Config.options.apps.terminal} fish -C '${cleanedCommand}'` : cleanedCommand]); + } + }; + 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 { + 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); + + let result = []; + + //////////////// Apps ////////////////// + result = result.concat(AppSearch.fuzzyQuery(root.searchingText).map(entry => { + entry.clickActionName = Translation.tr("Launch"); + entry.type = Translation.tr("App"); + return entry; + })); + + ////////// Launcher actions //////////// + result = result.concat(launcherActionObjects); + + /////////// Math result & command ////////// + const startsWithNumber = /^\d/.test(root.searchingText); + if (startsWithNumber) { + result.push(mathResultObject); + result.push(commandResultObject); + } else { + result.push(commandResultObject); + result.push(mathResultObject); + } + + ///////////////// Web search //////////////// + result.push({ + name: root.searchingText, + clickActionName: Translation.tr("Search"), + type: Translation.tr("Search the web"), + materialSymbol: 'travel_explore', + execute: () => { + let url = Config.options.search.engineBaseUrl + root.searchingText; + for (let site of Config.options.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + }); + + 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: root.searchingText.startsWith(Config.options.search.prefix.clipboard) ? root.searchingText.slice(Config.options.search.prefix.clipboard.length) : root.searchingText + } + } + } + } +} diff --git a/configs/quickshell/modules/screenCorners/ScreenCorners.qml b/configs/quickshell/modules/screenCorners/ScreenCorners.qml new file mode 100644 index 0000000..5bfed5c --- /dev/null +++ b/configs/quickshell/modules/screenCorners/ScreenCorners.qml @@ -0,0 +1,66 @@ +import qs.modules.common +import qs.modules.common.widgets +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 + + component CornerPanelWindow: PanelWindow { + id: cornerPanelWindow + visible: (Config.options.appearance.fakeScreenRounding === 1 || (Config.options.appearance.fakeScreenRounding === 2 && !activeWindow?.fullscreen)) + property var corner + + exclusionMode: ExclusionMode.Ignore + mask: Region { + item: null + } + WlrLayershell.namespace: "quickshell:screenCorners" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: cornerPanelWindow.corner === RoundCorner.CornerEnum.TopLeft || cornerPanelWindow.corner === RoundCorner.CornerEnum.TopRight + left: cornerPanelWindow.corner === RoundCorner.CornerEnum.TopLeft || cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomLeft + bottom: cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomLeft || cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomRight + right: cornerPanelWindow.corner === RoundCorner.CornerEnum.TopRight || cornerPanelWindow.corner === RoundCorner.CornerEnum.BottomRight + } + + implicitWidth: cornerWidget.implicitWidth + implicitHeight: cornerWidget.implicitHeight + RoundCorner { + id: cornerWidget + size: Appearance.rounding.screenRounding + corner: cornerPanelWindow.corner + } + } + + Variants { + model: Quickshell.screens + + Scope { + required property var modelData + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopLeft + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopRight + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomLeft + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomRight + } + } + } +} diff --git a/configs/quickshell/modules/screenCorners/ScreenCorners.qml.template b/configs/quickshell/modules/screenCorners/ScreenCorners.qml.template new file mode 100644 index 0000000..515950e --- /dev/null +++ b/configs/quickshell/modules/screenCorners/ScreenCorners.qml.template @@ -0,0 +1,162 @@ +import QtQuick +import Quickshell + +ShellRoot { + // Top-left corner - Overview + PanelWindow { + id: topLeftCorner + anchors { + top: true + left: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + // Trigger overview + triggerCornerAction("top-left") + } + } + } + } + + // Top-right corner - Brightness control + PanelWindow { + id: topRightCorner + anchors { + top: true + right: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onWheel: { + // Brightness control + var delta = wheel.angleDelta.y > 0 ? 5 : -5 + adjustBrightness(delta) + } + + onEntered: { + // Show brightness OSD + showBrightnessOSD() + } + } + } + } + + // Bottom-left corner - Sidebar + PanelWindow { + id: bottomLeftCorner + anchors { + bottom: true + left: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + // Toggle left sidebar + triggerCornerAction("bottom-left") + } + } + } + } + + // Bottom-right corner - Session menu + PanelWindow { + id: bottomRightCorner + anchors { + bottom: true + right: true + } + width: 1 + height: 1 + + Rectangle { + width: 20 + height: 20 + color: "transparent" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + // Show session menu + triggerCornerAction("bottom-right") + } + } + } + } + + function triggerCornerAction(corner) { + switch(corner) { + case "top-left": + // Show overview + Process.start("quickshell", ["-c", "overview"]) + break + case "top-right": + // Show brightness OSD + showBrightnessOSD() + break + case "bottom-left": + // Toggle sidebar + Process.start("quickshell", ["-c", "toggle-sidebar"]) + break + case "bottom-right": + // Show session menu + Process.start("quickshell", ["-c", "session-menu"]) + break + } + } + + function adjustBrightness(delta) { + // Brightness adjustment implementation + var currentBrightness = getCurrentBrightness() + var newBrightness = Math.max(0, Math.min(100, currentBrightness + delta)) + setBrightness(newBrightness) + showBrightnessOSD() + } + + function getCurrentBrightness() { + // Get current brightness (placeholder) + return 50 + } + + function setBrightness(value) { + // Set brightness (placeholder) + Process.start("brightnessctl", ["set", value + "%"]) + } + + function showBrightnessOSD() { + // Show brightness OSD (placeholder) + Process.start("quickshell", ["-c", "brightness-osd"]) + } +} diff --git a/configs/quickshell/modules/session/Session.qml b/configs/quickshell/modules/session/Session.qml new file mode 100644 index 0000000..6df45cd --- /dev/null +++ b/configs/quickshell/modules/session/Session.qml @@ -0,0 +1,317 @@ +import qs.modules.common +import qs +import qs.services +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 closeAllWindows() { + HyprlandData.windowList.map(w => w.pid).forEach((pid) => { + Quickshell.execDetached(["kill", pid]); + }); + } + + function detectRunningStuff() { + packageManagerRunning = false; + downloadRunning = false; + detectPackageManagerProc.running = false; + detectPackageManagerProc.running = true; + detectDownloadProc.running = false; + detectDownloadProc.running = true; + } + + Process { + id: detectPackageManagerProc + command: ["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 + color: ColorUtils.transparentize(Appearance.m3colors.m3background, 0.3) + + 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 + font.pixelSize: Appearance.font.pixelSize.title + font.weight: Font.DemiBold + 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: { Quickshell.execDetached(["loginctl", "lock-session"]); 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: { Quickshell.execDetached(["bash", "-c", "systemctl suspend || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["pkill", "Hyprland"]); 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: { Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLogout + KeyNavigation.down: sessionFirmwareReboot + } + + SessionActionButton { + id: sessionHibernate + buttonIcon: "downloading" + buttonText: Translation.tr("Hibernate") + onClicked: { Quickshell.execDetached(["bash", "-c", `systemctl hibernate || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["bash", "-c", `systemctl poweroff || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["bash", "-c", `reboot || loginctl 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: { root.closeAllWindows(); Quickshell.execDetached(["bash", "-c", `systemctl reboot --firmware-setup || loginctl reboot --firmware-setup`]); 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/configs/quickshell/modules/session/SessionActionButton.qml b/configs/quickshell/modules/session/SessionActionButton.qml new file mode 100644 index 0000000..199f2ab --- /dev/null +++ b/configs/quickshell/modules/session/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 { + content: buttonText + } + +} diff --git a/configs/quickshell/modules/session/SessionManager.qml.template b/configs/quickshell/modules/session/SessionManager.qml.template new file mode 100644 index 0000000..39ad724 --- /dev/null +++ b/configs/quickshell/modules/session/SessionManager.qml.template @@ -0,0 +1,264 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: sessionManager + + property bool visible: false + + width: 300 + height: 200 + color: "@SURFACE_COLOR@" + radius: 12 + border.color: "@OUTLINE_COLOR@" + + // Fade in/out animation + opacity: visible ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + GridLayout { + anchors.centerIn: parent + columns: 2 + rowSpacing: 15 + columnSpacing: 15 + + // Lock + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "๐Ÿ”’" + font.pixelSize: 24 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Lock" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + executeCommand("hyprlock") + sessionManager.visible = false + } + } + + // Logout + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "๐Ÿšช" + font.pixelSize: 24 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Logout" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + executeCommand("hyprctl dispatch exit") + sessionManager.visible = false + } + } + + // Reboot + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "๐Ÿ”„" + font.pixelSize: 24 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Reboot" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + showConfirmDialog("reboot") + } + } + + // Shutdown + Button { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + + background: Rectangle { + color: "@ERROR_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + border.width: parent.hovered ? 2 : 1 + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + } + + contentItem: Column { + spacing: 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "โป" + font.pixelSize: 24 + color: "@ON_ERROR_COLOR@" + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Shutdown" + color: "@ON_ERROR_COLOR@" + font.pixelSize: 12 + } + } + + onClicked: { + showConfirmDialog("shutdown") + } + } + } + + // Confirmation dialog + Rectangle { + id: confirmDialog + anchors.centerIn: parent + width: 250 + height: 120 + color: "@SURFACE_VARIANT_COLOR@" + radius: 8 + border.color: "@OUTLINE_COLOR@" + visible: false + + property string action: "" + + Column { + anchors.centerIn: parent + spacing: 15 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Are you sure?" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 10 + + Button { + text: "Cancel" + onClicked: { + confirmDialog.visible = false + } + } + + Button { + text: "Confirm" + background: Rectangle { + color: "@ERROR_COLOR@" + radius: 4 + } + onClicked: { + if (confirmDialog.action === "reboot") { + executeCommand("systemctl reboot") + } else if (confirmDialog.action === "shutdown") { + executeCommand("systemctl poweroff") + } + confirmDialog.visible = false + sessionManager.visible = false + } + } + } + } + } + + function executeCommand(command) { + // Execute system command + Qt.callLater(function() { + Process.start(command.split(" ")[0], command.split(" ").slice(1)) + }) + } + + function showConfirmDialog(action) { + confirmDialog.action = action + confirmDialog.visible = true + } + + // Close on click outside + MouseArea { + anchors.fill: parent + onClicked: { + if (!confirmDialog.visible) { + sessionManager.visible = false + } + } + } +} diff --git a/configs/quickshell/modules/settings/About.qml b/configs/quickshell/modules/settings/About.qml new file mode 100644 index 0000000..f9369c8 --- /dev/null +++ b/configs/quickshell/modules/settings/About.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + 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 { + 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/configs/quickshell/modules/settings/AdvancedConfig.qml b/configs/quickshell/modules/settings/AdvancedConfig.qml new file mode 100644 index 0000000..a40f191 --- /dev/null +++ b/configs/quickshell/modules/settings/AdvancedConfig.qml @@ -0,0 +1,45 @@ +import QtQuick +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + title: Translation.tr("Color generation") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Shell & utilities") + checked: Config.options.appearance.wallpaperTheming.enableAppsAndShell + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableAppsAndShell = checked; + } + } + ConfigSwitch { + text: Translation.tr("Qt apps") + checked: Config.options.appearance.wallpaperTheming.enableQtApps + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableQtApps = checked; + } + StyledToolTip { + content: Translation.tr("Shell & utilities theming must also be enabled") + } + } + ConfigSwitch { + text: Translation.tr("Terminal") + checked: Config.options.appearance.wallpaperTheming.enableTerminal + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableTerminal = checked; + } + StyledToolTip { + content: Translation.tr("Shell & utilities theming must also be enabled") + } + } + + } + } +} diff --git a/configs/quickshell/modules/settings/InterfaceConfig.qml b/configs/quickshell/modules/settings/InterfaceConfig.qml new file mode 100644 index 0000000..4d86fff --- /dev/null +++ b/configs/quickshell/modules/settings/InterfaceConfig.qml @@ -0,0 +1,424 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + ContentSection { + title: Translation.tr("Policies") + + ConfigRow { + ColumnLayout { + // Weeb policy + ContentSubsectionLabel { + text: Translation.tr("Weeb") + } + ConfigSelectionArray { + currentValue: Config.options.policies.weeb + configOptionName: "policies.weeb" + onSelected: newValue => { + Config.options.policies.weeb = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + value: 0 + }, + { + displayName: Translation.tr("Yes"), + value: 1 + }, + { + displayName: Translation.tr("Closet"), + value: 2 + } + ] + } + } + + ColumnLayout { + // AI policy + ContentSubsectionLabel { + text: Translation.tr("AI") + } + ConfigSelectionArray { + currentValue: Config.options.policies.ai + configOptionName: "policies.ai" + onSelected: newValue => { + Config.options.policies.ai = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + value: 0 + }, + { + displayName: Translation.tr("Yes"), + value: 1 + }, + { + displayName: Translation.tr("Local only"), + value: 2 + } + ] + } + } + } + } + + ContentSection { + title: Translation.tr("Bar") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + configOptionName: "bar.cornerStyle" + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; + } + options: [ + { + displayName: Translation.tr("Hug"), + value: 0 + }, + { + displayName: Translation.tr("Float"), + value: 1 + }, + { + displayName: Translation.tr("Plain rectangle"), + value: 2 + } + ] + } + + ContentSubsection { + title: Translation.tr("Overall appearance") + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr('Borderless') + checked: Config.options.bar.borderless + onCheckedChanged: { + Config.options.bar.borderless = checked; + } + } + ConfigSwitch { + text: Translation.tr('Show background') + checked: Config.options.bar.showBackground + onCheckedChanged: { + Config.options.bar.showBackground = checked; + } + StyledToolTip { + content: Translation.tr("Note: turning off can hurt readability") + } + } + } + } + + ContentSubsection { + title: Translation.tr("Buttons") + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Screen snip") + checked: Config.options.bar.utilButtons.showScreenSnip + onCheckedChanged: { + Config.options.bar.utilButtons.showScreenSnip = checked; + } + } + ConfigSwitch { + text: Translation.tr("Color picker") + checked: Config.options.bar.utilButtons.showColorPicker + onCheckedChanged: { + Config.options.bar.utilButtons.showColorPicker = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Mic toggle") + checked: Config.options.bar.utilButtons.showMicToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showMicToggle = checked; + } + } + ConfigSwitch { + text: Translation.tr("Keyboard toggle") + checked: Config.options.bar.utilButtons.showKeyboardToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showKeyboardToggle = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Dark/Light toggle") + checked: Config.options.bar.utilButtons.showDarkModeToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showDarkModeToggle = checked; + } + } + ConfigSwitch { + text: Translation.tr("Performance Profile toggle") + checked: Config.options.bar.utilButtons.showPerformanceProfileToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showPerformanceProfileToggle = checked; + } + } + } + } + + ContentSubsection { + title: Translation.tr("Workspaces") + tooltip: Translation.tr("Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr('Show app icons') + checked: Config.options.bar.workspaces.showAppIcons + onCheckedChanged: { + Config.options.bar.workspaces.showAppIcons = checked; + } + } + ConfigSwitch { + text: Translation.tr('Tint app icons') + checked: Config.options.bar.workspaces.monochromeIcons + onCheckedChanged: { + Config.options.bar.workspaces.monochromeIcons = checked; + } + } + } + ConfigSwitch { + text: Translation.tr('Always show numbers') + checked: Config.options.bar.workspaces.alwaysShowNumbers + onCheckedChanged: { + Config.options.bar.workspaces.alwaysShowNumbers = checked; + } + } + ConfigSpinBox { + 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 { + 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("Tray") + + ConfigSwitch { + text: Translation.tr('Tint icons') + checked: Config.options.bar.tray.monochromeIcons + onCheckedChanged: { + Config.options.bar.tray.monochromeIcons = checked; + } + } + } + + ContentSubsection { + title: Translation.tr("Weather") + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.bar.weather.enable + onCheckedChanged: { + Config.options.bar.weather.enable = checked; + } + } + } + } + + ContentSection { + title: Translation.tr("Battery") + + ConfigRow { + uniform: true + ConfigSpinBox { + text: Translation.tr("Low warning") + value: Config.options.battery.low + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.low = value; + } + } + ConfigSpinBox { + text: Translation.tr("Critical warning") + value: Config.options.battery.critical + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.critical = value; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Automatic suspend") + checked: Config.options.battery.automaticSuspend + onCheckedChanged: { + Config.options.battery.automaticSuspend = checked; + } + StyledToolTip { + content: Translation.tr("Automatically suspends the system when battery is low") + } + } + ConfigSpinBox { + text: Translation.tr("Suspend at") + value: Config.options.battery.suspend + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.suspend = value; + } + } + } + } + + ContentSection { + title: Translation.tr("Dock") + + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.dock.enable + onCheckedChanged: { + Config.options.dock.enable = checked; + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Hover to reveal") + checked: Config.options.dock.hoverToReveal + onCheckedChanged: { + Config.options.dock.hoverToReveal = checked; + } + } + ConfigSwitch { + text: Translation.tr("Pinned on startup") + checked: Config.options.dock.pinnedOnStartup + onCheckedChanged: { + Config.options.dock.pinnedOnStartup = checked; + } + } + } + ConfigSwitch { + text: Translation.tr("Tint app icons") + checked: Config.options.dock.monochromeIcons + onCheckedChanged: { + Config.options.dock.monochromeIcons = checked; + } + } + } + + ContentSection { + title: Translation.tr("Sidebars") + ConfigSwitch { + text: Translation.tr('Keep right sidebar loaded') + checked: Config.options.sidebar.keepRightSidebarLoaded + onCheckedChanged: { + Config.options.sidebar.keepRightSidebarLoaded = checked; + } + StyledToolTip { + content: 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 different kernel might help with this delay") + } + } + } + + ContentSection { + title: Translation.tr("On-screen display") + ConfigSpinBox { + text: Translation.tr("Timeout (ms)") + value: Config.options.osd.timeout + from: 100 + to: 3000 + stepSize: 100 + onValueChanged: { + Config.options.osd.timeout = value; + } + } + } + + ContentSection { + title: Translation.tr("Overview") + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.overview.enable + onCheckedChanged: { + Config.options.overview.enable = checked; + } + } + ConfigSpinBox { + 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 { + text: Translation.tr("Rows") + value: Config.options.overview.rows + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.rows = value; + } + } + ConfigSpinBox { + text: Translation.tr("Columns") + value: Config.options.overview.columns + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.columns = value; + } + } + } + } + + ContentSection { + title: Translation.tr("Screenshot tool") + + ConfigSwitch { + text: Translation.tr('Show regions of potential interest') + checked: Config.options.screenshotTool.showContentRegions + onCheckedChanged: { + Config.options.screenshotTool.showContentRegions = checked; + } + StyledToolTip { + content: Translation.tr("Such regions 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.") + } + } + } +} diff --git a/configs/quickshell/modules/settings/ServicesConfig.qml b/configs/quickshell/modules/settings/ServicesConfig.qml new file mode 100644 index 0000000..02b3a3d --- /dev/null +++ b/configs/quickshell/modules/settings/ServicesConfig.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + title: Translation.tr("Audio") + + ConfigSwitch { + text: Translation.tr("Earbang protection") + checked: Config.options.audio.protection.enable + onCheckedChanged: { + Config.options.audio.protection.enable = checked; + } + StyledToolTip { + content: Translation.tr("Prevents abrupt increments and restricts volume limit") + } + } + ConfigRow { + // uniform: true + ConfigSpinBox { + 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 { + text: Translation.tr("Volume limit") + value: Config.options.audio.protection.maxAllowed + from: 0 + to: 100 + stepSize: 2 + onValueChanged: { + Config.options.audio.protection.maxAllowed = value; + } + } + } + } + ContentSection { + title: Translation.tr("AI") + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("System prompt") + text: Config.options.ai.systemPrompt + wrapMode: TextEdit.Wrap + onTextChanged: { + Qt.callLater(() => { + Config.options.ai.systemPrompt = text; + }); + } + } + } + + ContentSection { + title: Translation.tr("Battery") + + ConfigRow { + uniform: true + ConfigSpinBox { + text: Translation.tr("Low warning") + value: Config.options.battery.low + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.low = value; + } + } + ConfigSpinBox { + text: Translation.tr("Critical warning") + value: Config.options.battery.critical + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.critical = value; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Automatic suspend") + checked: Config.options.battery.automaticSuspend + onCheckedChanged: { + Config.options.battery.automaticSuspend = checked; + } + StyledToolTip { + content: Translation.tr("Automatically suspends the system when battery is low") + } + } + ConfigSpinBox { + text: Translation.tr("Suspend at") + value: Config.options.battery.suspend + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.suspend = value; + } + } + } + } + + ContentSection { + title: Translation.tr("Networking") + MaterialTextField { + 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 { + title: Translation.tr("Resources") + ConfigSpinBox { + text: Translation.tr("Polling interval (ms)") + value: Config.options.resources.updateInterval + from: 100 + to: 10000 + stepSize: 100 + onValueChanged: { + Config.options.resources.updateInterval = value; + } + } + } + + ContentSection { + 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 { + content: 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 + + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Action") + text: Config.options.search.prefix.action + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.action = text; + } + } + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Clipboard") + text: Config.options.search.prefix.clipboard + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.clipboard = text; + } + } + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Emojis") + text: Config.options.search.prefix.emojis + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.emojis = text; + } + } + } + } + ContentSubsection { + title: Translation.tr("Web search") + MaterialTextField { + Layout.fillWidth: true + placeholderText: Translation.tr("Base URL") + text: Config.options.search.engineBaseUrl + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.engineBaseUrl = text; + } + } + } + } + + ContentSection { + title: Translation.tr("Time") + + ContentSubsection { + title: Translation.tr("Format") + tooltip: "" + + ConfigSelectionArray { + currentValue: Config.options.time.format + configOptionName: "time.format" + onSelected: newValue => { + 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" + }, + ] + } + } + } +} diff --git a/configs/quickshell/modules/settings/Settings.qml b/configs/quickshell/modules/settings/Settings.qml new file mode 100644 index 0000000..9b5d931 --- /dev/null +++ b/configs/quickshell/modules/settings/Settings.qml @@ -0,0 +1,215 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +// Main Settings Window +// Integrates transparency settings and other configuration options + +ApplicationWindow { + id: settingsWindow + + title: "dots-hyprland Settings" + width: 500 + height: 700 + visible: false + + color: "#1e1e2e" + + // Make window float and center it + flags: Qt.Window | Qt.WindowStaysOnTopHint + + Component.onCompleted: { + // Center the window + x = (Screen.width - width) / 2 + y = (Screen.height - height) / 2 + } + + TabBar { + id: tabBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 50 + + background: Rectangle { + color: "#313244" + } + + TabButton { + text: "Effects" + width: implicitWidth + + background: Rectangle { + color: parent.checked ? "#45475a" : "transparent" + radius: 4 + } + + contentItem: Text { + text: parent.text + color: "#cdd6f4" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + TabButton { + text: "Appearance" + width: implicitWidth + + background: Rectangle { + color: parent.checked ? "#45475a" : "transparent" + radius: 4 + } + + contentItem: Text { + text: parent.text + color: "#cdd6f4" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + TabButton { + text: "Keybinds" + width: implicitWidth + + background: Rectangle { + color: parent.checked ? "#45475a" : "transparent" + radius: 4 + } + + contentItem: Text { + text: parent.text + color: "#cdd6f4" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + + StackLayout { + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + + currentIndex: tabBar.currentIndex + + // Effects Tab (Transparency & Blur) + Item { + TransparencyUI { + anchors.fill: parent + color: "transparent" + border.width: 0 + } + } + + // Appearance Tab (Future: themes, colors, etc.) + Item { + Rectangle { + anchors.fill: parent + color: "#313244" + radius: 8 + + Text { + anchors.centerIn: parent + text: "Appearance settings\n(Coming soon)" + color: "#a6adc8" + horizontalAlignment: Text.AlignHCenter + } + } + } + + // Keybinds Tab (Future: keybind customization) + Item { + Rectangle { + anchors.fill: parent + color: "#313244" + radius: 8 + + Text { + anchors.centerIn: parent + text: "Keybind settings\n(Coming soon)" + color: "#a6adc8" + horizontalAlignment: Text.AlignHCenter + } + } + } + } + + // Close button + Button { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 10 + + width: 30 + height: 30 + + text: "ร—" + + background: Rectangle { + color: parent.hovered ? "#f38ba8" : "#45475a" + radius: 15 + } + + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 16 + font.bold: true + } + + onClicked: settingsWindow.close() + } + + // IPC Handler for external control + IpcHandler { + target: "settings" + + function show() { + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + + function hide() { + settingsWindow.hide() + } + + function toggle() { + if (settingsWindow.visible) { + settingsWindow.hide() + } else { + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + } + + function showEffects() { + tabBar.currentIndex = 0 + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + + function showAppearance() { + tabBar.currentIndex = 1 + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + + function showKeybinds() { + tabBar.currentIndex = 2 + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() + } + } +} diff --git a/configs/quickshell/modules/settings/StyleConfig.qml b/configs/quickshell/modules/settings/StyleConfig.qml new file mode 100644 index 0000000..6eabfab --- /dev/null +++ b/configs/quickshell/modules/settings/StyleConfig.qml @@ -0,0 +1,245 @@ +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.common.functions + +ContentPage { + baseWidth: lightDarkButtonGroup.implicitWidth + forceWidth: true + + Process { + id: konachanWallProc + property string status: "" + command: ["bash", "-c", FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/random_konachan_wall.sh`)] + stdout: SplitParser { + onRead: data => { + console.log(`Konachan wall proc output: ${data}`); + konachanWallProc.status = data.trim(); + } + } + } + + ContentSection { + title: Translation.tr("Colors & Wallpaper") + + // Light/Dark mode preference + ButtonGroup { + id: lightDarkButtonGroup + Layout.fillWidth: true + LightDarkPreferenceButton { + dark: false + } + LightDarkPreferenceButton { + dark: true + } + } + + // Material palette selection + ContentSubsection { + title: Translation.tr("Material palette") + ConfigSelectionArray { + currentValue: Config.options.appearance.palette.type + configOptionName: "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")} + ] + } + } + + + // Wallpaper selection + ContentSubsection { + title: Translation.tr("Wallpaper") + RowLayout { + Layout.alignment: Qt.AlignHCenter + RippleButtonWithIcon { + id: rndWallBtn + buttonRadius: Appearance.rounding.small + materialIcon: "wallpaper" + mainText: konachanWallProc.running ? Translation.tr("Be patient...") : Translation.tr("Random: Konachan") + onClicked: { + console.log(konachanWallProc.command.join(" ")) + konachanWallProc.running = true; + } + StyledToolTip { + content: Translation.tr("Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers") + } + } + RippleButtonWithIcon { + materialIcon: "wallpaper" + StyledToolTip { + content: 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" + } + } + } + } + } + } + } + + StyledText { + Layout.topMargin: 5 + Layout.alignment: Qt.AlignHCenter + text: Translation.tr("Alternatively use /dark, /light, /img in the launcher") + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + } + + } + + ContentSection { + title: Translation.tr("Decorations & Effects") + + ContentSubsection { + title: Translation.tr("Transparency") + + ConfigRow { + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.appearance.transparency + onCheckedChanged: { + Config.options.appearance.transparency = checked; + } + StyledToolTip { + content: Translation.tr("Might look ass. Unsupported.") + } + } + } + } + + ContentSubsection { + title: Translation.tr("Fake screen rounding") + + ButtonGroup { + id: fakeScreenRoundingButtonGroup + property int selectedPolicy: Config.options.appearance.fakeScreenRounding + spacing: 2 + SelectionGroupButton { + property int value: 0 + leftmost: true + buttonText: Translation.tr("No") + toggled: (fakeScreenRoundingButtonGroup.selectedPolicy === value) + onClicked: { + Config.options.appearance.fakeScreenRounding = value; + } + } + SelectionGroupButton { + property int value: 1 + buttonText: Translation.tr("Yes") + toggled: (fakeScreenRoundingButtonGroup.selectedPolicy === value) + onClicked: { + Config.options.appearance.fakeScreenRounding = value; + } + } + SelectionGroupButton { + property int value: 2 + rightmost: true + buttonText: Translation.tr("When not fullscreen") + toggled: (fakeScreenRoundingButtonGroup.selectedPolicy === value) + onClicked: { + Config.options.appearance.fakeScreenRounding = value; + } + } + } + } + + ContentSubsection { + title: Translation.tr("Shell windows") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Title bar") + checked: Config.options.windows.showTitlebar + onCheckedChanged: { + Config.options.windows.showTitlebar = checked; + } + } + ConfigSwitch { + text: Translation.tr("Center title") + checked: Config.options.windows.centerTitle + onCheckedChanged: { + Config.options.windows.centerTitle = checked; + } + } + } + } + + ContentSubsection { + title: Translation.tr("Wallpaper parallax") + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Depends on workspace") + checked: Config.options.background.parallax.enableWorkspace + onCheckedChanged: { + Config.options.background.parallax.enableWorkspace = checked; + } + } + ConfigSwitch { + text: Translation.tr("Depends on sidebars") + checked: Config.options.background.parallax.enableSidebar + onCheckedChanged: { + Config.options.background.parallax.enableSidebar = checked; + } + } + } + ConfigSpinBox { + text: Translation.tr("Preferred wallpaper zoom (%)") + value: Config.options.background.parallax.workspaceZoom * 100 + from: 100 + to: 150 + stepSize: 1 + onValueChanged: { + console.log(value/100) + Config.options.background.parallax.workspaceZoom = value / 100; + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/settings/TransparencySettings.qml b/configs/quickshell/modules/settings/TransparencySettings.qml new file mode 100644 index 0000000..e267097 --- /dev/null +++ b/configs/quickshell/modules/settings/TransparencySettings.qml @@ -0,0 +1,209 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemdUser + +// Transparency and Blur Settings Module +// Based on AGS configuration from ~/.config/ags/modules/sideright/centermodules/configure.js + +Rectangle { + id: transparencySettings + + property bool globalTransparency: false + property int terminalOpacity: 100 + property bool blurEnabled: false + property bool blurXray: true + property int blurSize: 8 + property int blurPasses: 4 + + // Storage paths (matching AGS structure) + property string colorModeFile: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/ags/user/colormode.txt" + property string terminalTransparencyFile: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/ags/user/generated/terminal/transparency" + + color: "transparent" + + Component.onCompleted: { + loadSettings() + } + + // Load settings from files (AGS compatibility) + function loadSettings() { + // Load global transparency mode + Process.exec("bash", ["-c", `mkdir -p $(dirname "${colorModeFile}")`]) + let colorModeResult = Process.exec("bash", ["-c", `sed -n '2p' "${colorModeFile}" 2>/dev/null || echo "opaque"`]) + globalTransparency = (colorModeResult.stdout.trim() === "transparent") + + // Load terminal opacity + Process.exec("bash", ["-c", `mkdir -p $(dirname "${terminalTransparencyFile}")`]) + let termOpacityResult = Process.exec("bash", ["-c", `cat "${terminalTransparencyFile}" 2>/dev/null || echo "100"`]) + terminalOpacity = parseInt(termOpacityResult.stdout.trim()) || 100 + + // Load Hyprland blur settings + loadHyprlandSettings() + } + + function loadHyprlandSettings() { + // Load blur enabled + let blurResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:enabled"]) + try { + let blurData = JSON.parse(blurResult.stdout) + blurEnabled = blurData.int !== 0 + } catch (e) { + console.log("Failed to load blur enabled setting:", e) + } + + // Load blur xray + let xrayResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:xray"]) + try { + let xrayData = JSON.parse(xrayResult.stdout) + blurXray = xrayData.int !== 0 + } catch (e) { + console.log("Failed to load blur xray setting:", e) + } + + // Load blur size + let sizeResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:size"]) + try { + let sizeData = JSON.parse(sizeResult.stdout) + blurSize = sizeData.int + } catch (e) { + console.log("Failed to load blur size setting:", e) + } + + // Load blur passes + let passesResult = Process.exec("hyprctl", ["getoption", "-j", "decoration:blur:passes"]) + try { + let passesData = JSON.parse(passesResult.stdout) + blurPasses = passesData.int + } catch (e) { + console.log("Failed to load blur passes setting:", e) + } + } + + // Save and apply global transparency + function setGlobalTransparency(enabled) { + globalTransparency = enabled + let mode = enabled ? "transparent" : "opaque" + + // Save to colormode.txt (line 2) + Process.exec("bash", ["-c", `mkdir -p $(dirname "${colorModeFile}") + if [ ! -f "${colorModeFile}" ]; then + echo "dark" > "${colorModeFile}" + echo "${mode}" >> "${colorModeFile}" + else + sed -i "2s/.*/${mode}/" "${colorModeFile}" + fi`]) + + // Apply color changes (equivalent to AGS switchcolor.sh) + applyColorChanges() + } + + // Save and apply terminal opacity + function setTerminalOpacity(opacity) { + terminalOpacity = opacity + + // Save to terminal transparency file + Process.exec("bash", ["-c", `mkdir -p $(dirname "${terminalTransparencyFile}") + echo "${opacity}" > "${terminalTransparencyFile}"`]) + + // Apply terminal colors (equivalent to AGS applycolor.sh term) + applyTerminalColors() + } + + // Apply Hyprland blur settings + function setBlurEnabled(enabled) { + blurEnabled = enabled + Process.exec("hyprctl", ["keyword", "decoration:blur:enabled", enabled ? "1" : "0"]) + } + + function setBlurXray(enabled) { + blurXray = enabled + Process.exec("hyprctl", ["keyword", "decoration:blur:xray", enabled ? "1" : "0"]) + } + + function setBlurSize(size) { + blurSize = size + Process.exec("hyprctl", ["keyword", "decoration:blur:size", size.toString()]) + } + + function setBlurPasses(passes) { + blurPasses = passes + Process.exec("hyprctl", ["keyword", "decoration:blur:passes", passes.toString()]) + } + + // Apply color changes (equivalent to AGS color generation) + function applyColorChanges() { + // This would call the equivalent of AGS color generation scripts + Process.exec("bash", ["-c", ` + # Apply transparency mode to all shell elements + # This is where we'd integrate with the quickshell theming system + echo "Applying transparency mode: ${globalTransparency ? 'transparent' : 'opaque'}" + + # Reload quickshell to apply changes + quickshell ipc call settings reload || true + `]) + } + + // Apply terminal colors (equivalent to AGS applycolor.sh term) + function applyTerminalColors() { + let alpha = terminalOpacity / 100.0 + + Process.exec("bash", ["-c", ` + # Update foot terminal configuration with new opacity + FOOT_CONFIG="$HOME/.config/foot/foot.ini" + if [ -f "$FOOT_CONFIG" ]; then + # Update alpha value in foot.ini + sed -i "s/^alpha=.*/alpha=${alpha}/" "$FOOT_CONFIG" || echo "alpha=${alpha}" >> "$FOOT_CONFIG" + fi + + # Send terminal escape sequence to update running terminals + # This matches the AGS terminal sequences functionality + echo "Applied terminal opacity: ${terminalOpacity}%" + `]) + } + + // IPC Handler for external control + IpcHandler { + target: "transparencySettings" + + function setTransparency(enabled) { + transparencySettings.setGlobalTransparency(enabled) + } + + function setTerminalOpacity(opacity) { + transparencySettings.setTerminalOpacity(opacity) + } + + function setBlur(enabled) { + transparencySettings.setBlurEnabled(enabled) + } + + function setBlurXray(enabled) { + transparencySettings.setBlurXray(enabled) + } + + function setBlurSize(size) { + transparencySettings.setBlurSize(size) + } + + function setBlurPasses(passes) { + transparencySettings.setBlurPasses(passes) + } + + function getSettings() { + return { + globalTransparency: transparencySettings.globalTransparency, + terminalOpacity: transparencySettings.terminalOpacity, + blurEnabled: transparencySettings.blurEnabled, + blurXray: transparencySettings.blurXray, + blurSize: transparencySettings.blurSize, + blurPasses: transparencySettings.blurPasses + } + } + + function reload() { + transparencySettings.loadSettings() + } + } +} diff --git a/configs/quickshell/modules/settings/TransparencyUI.qml b/configs/quickshell/modules/settings/TransparencyUI.qml new file mode 100644 index 0000000..b288f0d --- /dev/null +++ b/configs/quickshell/modules/settings/TransparencyUI.qml @@ -0,0 +1,469 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +// Transparency and Blur UI Controls +// Replicates the AGS configuration UI from configure.js + +Rectangle { + id: transparencyUI + + property alias transparencySettings: settingsLoader.item + + width: 400 + height: 600 + color: "#1e1e2e" + radius: 12 + border.color: "#45475a" + border.width: 1 + + // Load the settings module + Loader { + id: settingsLoader + source: "TransparencySettings.qml" + } + + ScrollView { + anchors.fill: parent + anchors.margins: 20 + + ColumnLayout { + width: parent.width + spacing: 20 + + // Header + Text { + text: "Effects Configuration" + color: "#cdd6f4" + font.pixelSize: 18 + font.bold: true + Layout.fillWidth: true + } + + // Global Transparency Section + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: transparencySection.height + 20 + color: "#313244" + radius: 8 + + ColumnLayout { + id: transparencySection + anchors.fill: parent + anchors.margins: 15 + spacing: 15 + + // Transparency Toggle + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 24 + height: 24 + color: "#89b4fa" + radius: 4 + + Text { + anchors.centerIn: parent + text: "โ—ซ" + color: "white" + font.pixelSize: 14 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Transparency" + color: "#cdd6f4" + font.pixelSize: 14 + font.bold: true + } + + Text { + text: "Make shell elements transparent\nBlur is also recommended if you enable this" + color: "#a6adc8" + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + id: transparencySwitch + checked: transparencySettings ? transparencySettings.globalTransparency : false + + onToggled: { + if (transparencySettings) { + transparencySettings.setGlobalTransparency(checked) + } + } + } + } + + // Terminal Opacity Slider (subcategory) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: terminalOpacitySection.height + 10 + color: "#45475a" + radius: 6 + Layout.leftMargin: 20 + + ColumnLayout { + id: terminalOpacitySection + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#f9e2af" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โ—‹" + color: "black" + font.pixelSize: 12 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Terminal Opacity" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Changes the opacity of the foot terminal" + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Text { + text: (transparencySettings ? transparencySettings.terminalOpacity : 100) + "%" + color: "#cdd6f4" + font.pixelSize: 12 + Layout.preferredWidth: 40 + } + } + + Slider { + id: terminalOpacitySlider + Layout.fillWidth: true + from: 0 + to: 100 + stepSize: 1 + value: transparencySettings ? transparencySettings.terminalOpacity : 100 + + onValueChanged: { + if (transparencySettings && Math.abs(value - transparencySettings.terminalOpacity) > 0.5) { + transparencySettings.setTerminalOpacity(Math.round(value)) + } + } + } + } + } + } + } + + // Blur Section + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: blurSection.height + 20 + color: "#313244" + radius: 8 + + ColumnLayout { + id: blurSection + anchors.fill: parent + anchors.margins: 15 + spacing: 15 + + // Blur Toggle + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 24 + height: 24 + color: "#94e2d5" + radius: 4 + + Text { + anchors.centerIn: parent + text: "โ—" + color: "black" + font.pixelSize: 14 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Blur" + color: "#cdd6f4" + font.pixelSize: 14 + font.bold: true + } + + Text { + text: "Enable blur on transparent elements\nDoesn't affect performance/power consumption unless you have transparent windows." + color: "#a6adc8" + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + id: blurSwitch + checked: transparencySettings ? transparencySettings.blurEnabled : false + + onToggled: { + if (transparencySettings) { + transparencySettings.setBlurEnabled(checked) + } + } + } + } + + // Blur Subcategory + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: blurSubcategory.height + 10 + color: "#45475a" + radius: 6 + Layout.leftMargin: 20 + visible: transparencySettings ? transparencySettings.blurEnabled : false + + ColumnLayout { + id: blurSubcategory + anchors.fill: parent + anchors.margins: 10 + spacing: 15 + + // X-ray Toggle + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#f38ba8" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โšก" + color: "white" + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "X-ray" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Make everything behind a window/layer except the wallpaper not rendered on its blurred surface\nRecommended to improve performance (if you don't abuse transparency/blur)" + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + checked: transparencySettings ? transparencySettings.blurXray : true + + onToggled: { + if (transparencySettings) { + transparencySettings.setBlurXray(checked) + } + } + } + } + + // Blur Size + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#a6e3a1" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โ—Ž" + color: "black" + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Size" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Adjust the blur radius. Generally doesn't affect performance\nHigher = more color spread" + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + SpinBox { + from: 1 + to: 1000 + value: transparencySettings ? transparencySettings.blurSize : 8 + + onValueChanged: { + if (transparencySettings && value !== transparencySettings.blurSize) { + transparencySettings.setBlurSize(value) + } + } + } + } + + // Blur Passes + RowLayout { + Layout.fillWidth: true + + Rectangle { + width: 20 + height: 20 + color: "#cba6f7" + radius: 3 + + Text { + anchors.centerIn: parent + text: "โ†ป" + color: "white" + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Passes" + color: "#cdd6f4" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: "Adjust the number of runs of the blur algorithm\nMore passes = more spread and power consumption\n4 is recommended\n2- would look weird and 6+ would look lame." + color: "#a6adc8" + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + SpinBox { + from: 1 + to: 10 + value: transparencySettings ? transparencySettings.blurPasses : 4 + + onValueChanged: { + if (transparencySettings && value !== transparencySettings.blurPasses) { + transparencySettings.setBlurPasses(value) + } + } + } + } + } + } + } + } + + // Apply/Reset buttons + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 20 + + Button { + text: "Reset to Defaults" + Layout.fillWidth: true + + onClicked: { + if (transparencySettings) { + transparencySettings.setGlobalTransparency(false) + transparencySettings.setTerminalOpacity(100) + transparencySettings.setBlurEnabled(false) + transparencySettings.setBlurXray(true) + transparencySettings.setBlurSize(8) + transparencySettings.setBlurPasses(4) + } + } + } + + Button { + text: "Reload Settings" + Layout.fillWidth: true + + onClicked: { + if (transparencySettings) { + transparencySettings.loadSettings() + } + } + } + } + } + } + + // IPC Handler for external control + IpcHandler { + target: "transparencyUI" + + function show() { + transparencyUI.visible = true + } + + function hide() { + transparencyUI.visible = false + } + + function toggle() { + transparencyUI.visible = !transparencyUI.visible + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/AiChat.qml b/configs/quickshell/modules/sidebarLeft/AiChat.qml new file mode 100644 index 0000000..dcbff89 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/AiChat.qml @@ -0,0 +1,701 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./aiChat/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell + +Item { + id: root + property var inputField: messageInputField + property string commandPrefix: "/" + + property var suggestionQuery: "" + property var suggestionList: [] + + onFocusChanged: (focus) => { + if (focus) { + root.inputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + messageInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + messageListView.contentY = Math.max(0, messageListView.contentY - messageListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + messageListView.contentY = Math.min(messageListView.contentHeight - messageListView.height / 2, messageListView.contentY + messageListView.height / 2) + event.accepted = true + } + } + } + + property var allCommands: [ + { + name: "model", + description: Translation.tr("Choose model"), + execute: (args) => { + Ai.setModel(args[0]); + } + }, + { + name: "tool", + description: Translation.tr("Set the tool to use for the model."), + execute: (args) => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.addMessage(Translation.tr("Usage: %1tool TOOL_NAME").arg(root.commandPrefix), Ai.interfaceRole); + } else { + const tool = args[0]; + const switched = Ai.setTool(tool); + if (switched) { + Ai.addMessage(Translation.tr("Tool set to: %1").arg(tool), Ai.interfaceRole); + } + } + } + }, + { + name: "prompt", + description: Translation.tr("Set the system prompt for the model."), + execute: (args) => { + if (args.length === 0 || args[0] === "get") { + Ai.printPrompt(); + return; + } + Ai.loadPrompt(args.join(" ").trim()); + } + }, + { + name: "key", + description: Translation.tr("Set API key"), + execute: (args) => { + if (args[0] == "get") { + Ai.printApiKey() + } else { + Ai.setApiKey(args[0]); + } + } + }, + { + name: "save", + description: Translation.tr("Save chat"), + execute: (args) => { + const joinedArgs = args.join(" ") + if (joinedArgs.trim().length == 0) { + Ai.addMessage(Translation.tr("Usage: %1save CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole); + return; + } + Ai.saveChat(joinedArgs) + } + }, + { + name: "load", + description: Translation.tr("Load chat"), + execute: (args) => { + const joinedArgs = args.join(" ") + if (joinedArgs.trim().length == 0) { + Ai.addMessage(Translation.tr("Usage: %1load CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole); + return; + } + Ai.loadChat(joinedArgs) + } + }, + { + name: "clear", + description: Translation.tr("Clear chat history"), + execute: () => { + Ai.clearMessages(); + } + }, + { + name: "temp", + description: Translation.tr("Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5."), + execute: (args) => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.printTemperature() + } else { + const temp = parseFloat(args[0]); + Ai.setTemperature(temp); + } + } + }, + { + name: "test", + description: Translation.tr("Markdown test"), + execute: () => { + Ai.addMessage(` + +A longer think block to test revealing animation +OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w< +Mowe uwu wem ipsum! + +## โœ๏ธ Markdown test +### Formatting + +- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- Arch lincox icon + +### Table + +Quickshell vs AGS/Astal + +| | Quickshell | AGS/Astal | +|--------------------------|------------------|-------------------| +| UI Toolkit | Qt | Gtk3/Gtk4 | +| Language | QML | Js/Ts/Lua | +| Reactivity | Implied | Needs declaration | +| Widget placement | Mildly difficult | More intuitive | +| Bluetooth & Wifi support | โŒ | โœ… | +| No-delay keybinds | โœ… | โŒ | +| Development | New APIs | New syntax | + +### Code block + +Just a hello world... + +\`\`\`cpp +#include +// This is intentionally very long to test scrolling +const std::string GREETING = \"UwU\"; +int main(int argc, char* argv[]) { + std::cout << GREETING; +} +\`\`\` + +### LaTeX + + +Inline w/ dollar signs: $\\frac{1}{2} = \\frac{2}{4}$ + +Inline w/ double dollar signs: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ + +Inline w/ backslash and square brackets \\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\] + +Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) +`, + Ai.interfaceRole); + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Ai.addMessage(Translation.tr("Unknown command: ") + command, Ai.interfaceRole); + } + } + else { + Ai.sendUserMessage(inputText); + } + } + + component StatusItem: MouseArea { + id: statusItem + property string icon + property string statusText + property string description + hoverEnabled: true + implicitHeight: statusItemRowLayout.implicitHeight + implicitWidth: statusItemRowLayout.implicitWidth + + RowLayout { + id: statusItemRowLayout + spacing: 0 + MaterialSymbol { + text: statusItem.icon + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colSubtext + } + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: statusItem.statusText + color: Appearance.colors.colSubtext + } + } + + StyledToolTip { + content: statusItem.description + extraVisibleCondition: false + alternativeVisibleCondition: statusItem.containsMouse + } + } + + component StatusSeparator: Rectangle { + implicitWidth: 4 + implicitHeight: 4 + radius: implicitWidth / 2 + color: Appearance.colors.colOutlineVariant + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + RowLayout { // Status + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + StatusItem { + icon: Ai.currentModelHasApiKey ? "key" : "key_off" + statusText: "" + description: Ai.currentModelHasApiKey ? Translation.tr("API key is set\nChange with /key YOUR_API_KEY") : Translation.tr("No API key\nSet it with /key YOUR_API_KEY") + } + StatusSeparator {} + StatusItem { + icon: "device_thermostat" + statusText: Ai.temperature.toFixed(1) + description: Translation.tr("Temperature\nChange with /temp VALUE") + } + StatusSeparator { + visible: Ai.tokenCount.total > 0 + } + StatusItem { + visible: Ai.tokenCount.total > 0 + icon: "token" + statusText: Ai.tokenCount.total + description: Translation.tr("Total token count\nInput: %1\nOutput: %2") + .arg(Ai.tokenCount.input) + .arg(Ai.tokenCount.output) + } + } + + Item { // Messages + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { // Message list + id: messageListView + anchors.fill: parent + spacing: 10 + popin: false + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + add: null // Prevent function calls from being janky + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + model: ScriptModel { + values: Ai.messageIDs.filter(id => { + const message = Ai.messageByID[id]; + return message?.visibleToUser ?? true; + }) + } + delegate: AiMessage { + required property var modelData + required property int index + messageIndex: index + messageData: { + Ai.messageByID[modelData] + } + messageInputField: root.inputField + } + } + + Item { // Placeholder when list is empty + opacity: Ai.messageIDs.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 60 + color: Appearance.m3colors.m3outline + text: "neurology" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.title + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Large language models") + } + StyledText { + id: widgetDescriptionText + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + text: Translation.tr("Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window") + } + } + } + } + + DescriptionBox { + text: root.suggestionList[suggestions.selectedIndex]?.description ?? "" + showArrows: root.suggestionList.length > 1 + } + + FlowButtonGroup { // Suggestions + id: suggestions + visible: root.suggestionList.length > 0 && messageInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: suggestionRepeater + model: { + suggestions.selectedIndex = 0 + return root.suggestionList.slice(0, 10) + } + delegate: ApiCommandButton { + id: commandButton + colBackground: suggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer + bounce: false + contentItem: StyledText { + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignHCenter + text: modelData.displayName ?? modelData.name + } + + onHoveredChanged: { + if (commandButton.hovered) { + suggestions.selectedIndex = index; + } + } + onClicked: { + suggestions.acceptSuggestion(modelData.name) + } + } + } + + function acceptSuggestion(word) { + const words = messageInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = word; + } else { + words.push(word); + } + const updatedText = words.join(" ") + " "; + messageInputField.text = updatedText; + messageInputField.cursorPosition = messageInputField.text.length; + messageInputField.forceActiveFocus(); + } + + function acceptSelectedWord() { + if (suggestions.selectedIndex >= 0 && suggestions.selectedIndex < suggestionRepeater.count) { + const word = root.suggestionList[suggestions.selectedIndex].name; + suggestions.acceptSuggestion(word); + } + } + } + + Rectangle { // Input area + id: inputWrapper + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: messageInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + StyledTextArea { // The actual TextArea + id: messageInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + placeholderText: Translation.tr('Message the model... "%1" for commands').arg(root.commandPrefix) + + background: null + + onTextChanged: { // Handle suggestions + if (messageInputField.text.length === 0) { + root.suggestionQuery = "" + root.suggestionList = [] + return + } else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => { + return { + name: Fuzzy.prepare(model), + obj: model, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = modelResults.map(model => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`, + displayName: `${Ai.models[model.target].name}`, + description: `${Ai.models[model.target].description}`, + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}prompt`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.promptFiles.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = promptFileResults.map(file => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "prompt ") : ""}${file.target}`, + displayName: `${FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target))}`, + description: Translation.tr("Load prompt from %1").arg(file.target), + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}save`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = promptFileResults.map(file => { + const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim() + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "save ") : ""}${chatName}`, + displayName: `${chatName}`, + description: Translation.tr("Save chat to %1").arg(chatName), + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}load`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = promptFileResults.map(file => { + const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim() + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "load ") : ""}${chatName}`, + displayName: `${chatName}`, + description: Translation.tr(`Load chat from %1`).arg(file.target), + } + }) + } else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "" + const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => { + return { + name: Fuzzy.prepare(tool), + obj: tool, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = toolResults.map(tool => { + const toolName = tool.target + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`, + displayName: toolName, + description: Ai.toolDescriptions[toolName], + } + }) + } else if(messageInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = messageInputField.text + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}`, + } + }) + } + } + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + suggestions.acceptSelectedWord(); + event.accepted = true; + } else if (event.key === Qt.Key_Up && suggestions.visible) { + suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down && suggestions.visible) { + suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + messageInputField.insert(messageInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = messageInputField.text + messageInputField.clear() + root.handleInput(inputText) + event.accepted = true + } + } + } + } + + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: messageInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = messageInputField.text + root.handleInput(inputText) + messageInputField.clear() + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + // fill: sendButton.enabled ? 1 : 0 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "send" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 10 + anchors.rightMargin: 5 + spacing: 4 + + property var commandsShown: [ + { + name: "", + sendDirectly: false, + dontAddSpace: true, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + ApiInputBoxIndicator { // Model indicator + icon: "api" + text: Ai.getModel().name + tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL") + .arg(Ai.getModel().name) + .arg(root.commandPrefix) + } + + ApiInputBoxIndicator { // Tool indicator + icon: "service_toolbox" + text: Ai.currentTool.charAt(0).toUpperCase() + Ai.currentTool.slice(1) + tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL") + .arg(Ai.currentTool) + .arg(root.commandPrefix) + } + + Item { Layout.fillWidth: true } + + ButtonGroup { // Command buttons + padding: 0 + + Repeater { // Command buttons + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + messageInputField.text = commandRepresentation + (modelData.dontAddSpace ? "" : " ") + messageInputField.cursorPosition = messageInputField.text.length + messageInputField.forceActiveFocus() + } + if (modelData.name === "clear") { + messageInputField.text = "" + } + } + } + } + } + } + + } + + } + +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/Anime.qml b/configs/quickshell/modules/sidebarLeft/Anime.qml new file mode 100644 index 0000000..a728377 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/Anime.qml @@ -0,0 +1,580 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./anime/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell + +Item { + id: root + property var inputField: tagInputField + readonly property var responses: Booru.responses + property string previewDownloadPath: Directories.booruPreviews + property string downloadPath: Directories.booruDownloads + property string nsfwPath: Directories.booruDownloadsNsfw + property string commandPrefix: "/" + property real scrollOnNewResponse: 100 + property int tagSuggestionDelay: 210 + property var suggestionQuery: "" + property var suggestionList: [] + + Connections { + target: Booru + function onTagSuggestion(query, suggestions) { + root.suggestionQuery = query; + root.suggestionList = suggestions; + } + } + + property var allCommands: [ + { + name: "mode", + description: Translation.tr("Set the current API provider"), + execute: (args) => { + Booru.setProvider(args[0]); + } + }, + { + name: "clear", + description: Translation.tr("Clear the current list of images"), + execute: () => { + Booru.clearResponses(); + } + }, + { + name: "next", + description: Translation.tr("Get the next page of results"), + execute: () => { + if (root.responses.length > 0) { + const lastResponse = root.responses[root.responses.length - 1]; + root.handleInput(`${lastResponse.tags.join(" ")} ${parseInt(lastResponse.page) + 1}`); + } + } + }, + { + name: "safe", + description: Translation.tr("Disable NSFW content"), + execute: () => { + Persistent.states.booru.allowNsfw = false; + } + }, + { + name: "lewd", + description: Translation.tr("Allow NSFW content"), + execute: () => { + Persistent.states.booru.allowNsfw = true; + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Booru.addSystemMessage(Translation.tr("Unknown command: ") + command); + } + } + else if (inputText.trim() == "+") { + if (root.responses.length > 0) { + const lastResponse = root.responses[root.responses.length - 1] + root.handleInput(lastResponse.tags.join(" ") + ` ${parseInt(lastResponse.page) + 1}`); + } + } + else { + // Create tag list + const tagList = inputText.split(/\s+/).filter(tag => tag.length > 0); + let pageIndex = 1; + for (let i = 0; i < tagList.length; ++i) { // Detect page number + if (/^\d+$/.test(tagList[i])) { + pageIndex = parseInt(tagList[i], 10); + tagList.splice(i, 1); + break; + } + } + Booru.makeRequest(tagList, Persistent.states.booru.allowNsfw, Config.options.sidebar.booru.limit, pageIndex); + } + } + + onFocusChanged: (focus) => { + if (focus) { + tagInputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + tagInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + booruResponseListView.contentY = Math.max(0, booruResponseListView.contentY - booruResponseListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + booruResponseListView.contentY = Math.min(booruResponseListView.contentHeight - booruResponseListView.height / 2, booruResponseListView.contentY + booruResponseListView.height / 2) + event.accepted = true + } + } + } + + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { // Booru responses + id: booruResponseListView + anchors.fill: parent + spacing: 10 + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + model: ScriptModel { + values: { + if(root.responses.length > booruResponseListView.lastResponseLength) { + if (booruResponseListView.lastResponseLength > 0 && root.responses[booruResponseListView.lastResponseLength].provider != "system") + booruResponseListView.contentY = booruResponseListView.contentY + root.scrollOnNewResponse + booruResponseListView.lastResponseLength = root.responses.length + } + return root.responses + } + } + delegate: BooruResponse { + responseData: modelData + tagInputField: root.inputField + previewDownloadPath: root.previewDownloadPath + downloadPath: root.downloadPath + nsfwPath: root.nsfwPath + } + } + + Item { // Placeholder when list is empty + opacity: root.responses.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 60 + color: Appearance.m3colors.m3outline + text: "bookmark_heart" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.title + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Anime boorus") + } + } + } + + Item { // Queries awaiting response + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + implicitHeight: pendingBackground.implicitHeight + opacity: Booru.runningRequests > 0 ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + Rectangle { + id: pendingBackground + color: Appearance.m3colors.m3inverseSurface + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: pendingText.implicitHeight + 12 * 2 + radius: Appearance.rounding.verysmall + + StyledText { + id: pendingText + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.m3colors.m3inverseOnSurface + wrapMode: Text.Wrap + text: Translation.tr("%1 queries pending").arg(Booru.runningRequests) + } + } + } + } + + DescriptionBox { // Tag suggestion description + text: root.suggestionList[tagSuggestions.selectedIndex]?.description ?? "" + showArrows: root.suggestionList.length > 1 + } + + FlowButtonGroup { // Tag suggestions + id: tagSuggestions + visible: root.suggestionList.length > 0 && tagInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: tagSuggestionRepeater + model: { + tagSuggestions.selectedIndex = 0 + return root.suggestionList.slice(0, 10) + } + delegate: ApiCommandButton { + id: tagButton + colBackground: tagSuggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer + bounce: false + contentItem: RowLayout { + anchors.centerIn: parent + spacing: 5 + StyledText { + Layout.fillWidth: false + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + horizontalAlignment: Text.AlignRight + text: modelData.displayName ?? modelData.name + } + StyledText { + Layout.fillWidth: false + visible: modelData.count !== undefined + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnSecondaryContainer + horizontalAlignment: Text.AlignLeft + text: modelData.count ?? "" + } + } + + onHoveredChanged: { + if (tagButton.hovered) { + tagSuggestions.selectedIndex = index; + } + } + onClicked: { + tagSuggestions.acceptTag(modelData.name) + } + } + } + + function acceptTag(tag) { + const words = tagInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = tag; + } else { + words.push(tag); + } + const updatedText = words.join(" ") + " "; + tagInputField.text = updatedText; + tagInputField.cursorPosition = tagInputField.text.length; + tagInputField.forceActiveFocus(); + } + + function acceptSelectedTag() { + if (tagSuggestions.selectedIndex >= 0 && tagSuggestions.selectedIndex < tagSuggestionRepeater.count) { + const tag = root.suggestionList[tagSuggestions.selectedIndex].name; + tagSuggestions.acceptTag(tag); + } + } + } + + Rectangle { // Tag input area + id: tagInputContainer + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: tagInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + StyledTextArea { // The actual TextArea + id: tagInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + placeholderText: Translation.tr('Enter tags, or "%1" for commands').arg(root.commandPrefix) + + background: null + + property Timer searchTimer: Timer { // Timer for tag suggestions + interval: root.tagSuggestionDelay + repeat: false + onTriggered: { + const inputText = tagInputField.text + const words = inputText.trim().split(/\s+/); + if (words.length > 0) { + Booru.triggerTagSearch(words[words.length - 1]); + } + } + } + + onTextChanged: { // Handle tag suggestions + if(tagInputField.text.length === 0) { + root.suggestionQuery = "" + root.suggestionList = [] + searchTimer.stop(); + return + } + if(tagInputField.text.startsWith(`${root.commandPrefix}mode`)) { + root.suggestionQuery = tagInputField.text.split(" ")[1] ?? "" + const providerResults = Fuzzy.go(root.suggestionQuery, Booru.providerList.map(provider => { + return { + name: Fuzzy.prepare(provider), + obj: provider, + } + }), { + all: true, + key: "name" + }) + root.suggestionList = providerResults.map(provider => { + return { + name: `${tagInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "mode ") : ""}${provider.target}`, + displayName: `${Booru.providers[provider.target].name}`, + description: `${Booru.providers[provider.target].description}`, + } + }) + searchTimer.stop(); + return + } + if(tagInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = tagInputField.text + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(tagInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}`, + } + }) + searchTimer.stop(); + return + } + searchTimer.restart(); + } + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + tagSuggestions.acceptSelectedTag(); + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + tagSuggestions.selectedIndex = Math.max(0, tagSuggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + tagSuggestions.selectedIndex = Math.min(root.suggestionList.length - 1, tagSuggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + tagInputField.insert(tagInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = tagInputField.text + root.handleInput(inputText) + tagInputField.clear() + event.accepted = true + } + } + } + } + + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: tagInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = tagInputField.text + root.handleInput(inputText) + tagInputField.clear() + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + // fill: sendButton.enabled ? 1 : 0 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "send" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 5 + anchors.rightMargin: 5 + spacing: 5 + + property var commandsShown: [ + { + name: "mode", + sendDirectly: false, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + ApiInputBoxIndicator { // Tool indicator + icon: "api" + text: Booru.providers[Booru.currentProvider].name + tooltipText: Translation.tr("Current API endpoint: %1\nSet it with %2mode PROVIDER") + .arg(Booru.providers[Booru.currentProvider].url) + .arg(root.commandPrefix) + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: "โ€ข" + } + + Item { // NSFW toggle + visible: width > 0 + implicitWidth: switchesRow.implicitWidth + Layout.fillHeight: true + + RowLayout { + id: switchesRow + spacing: 5 + anchors.centerIn: parent + + MouseArea { + hoverEnabled: true + PointingHandInteraction {} + onClicked: { + nsfwSwitch.checked = !nsfwSwitch.checked + } + } + + StyledText { + Layout.fillHeight: true + Layout.leftMargin: 10 + Layout.alignment: Qt.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.smaller + color: nsfwSwitch.enabled ? Appearance.colors.colOnLayer1 : Appearance.m3colors.m3outline + text: Translation.tr("Allow NSFW") + } + StyledSwitch { + id: nsfwSwitch + enabled: Booru.currentProvider !== "zerochan" + scale: 0.6 + Layout.alignment: Qt.AlignVCenter + checked: (Persistent.states.booru.allowNsfw && Booru.currentProvider !== "zerochan") + onCheckedChanged: { + if (!nsfwSwitch.enabled) return; + Persistent.states.booru.allowNsfw = checked; + } + } + } + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + padding: 0 + Repeater { // Command buttons + id: commandRepeater + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + colBackground: Appearance.colors.colLayer2 + + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + tagInputField.text = commandRepresentation + " " + tagInputField.cursorPosition = tagInputField.text.length + tagInputField.forceActiveFocus() + } + if (modelData.name === "clear") { + tagInputField.text = "" + } + } + } + } + } + } + + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/ApiCommandButton.qml b/configs/quickshell/modules/sidebarLeft/ApiCommandButton.qml new file mode 100644 index 0000000..efbde15 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/ApiCommandButton.qml @@ -0,0 +1,26 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +GroupButton { + id: button + property string buttonText + + horizontalPadding: 8 + verticalPadding: 6 + + baseWidth: contentItem.implicitWidth + horizontalPadding * 2 + clickedWidth: baseWidth + 20 + baseHeight: contentItem.implicitHeight + verticalPadding * 2 + buttonRadius: down ? Appearance.rounding.verysmall : Appearance.rounding.small + + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: buttonText + color: Appearance.m3colors.m3onSurface + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/ApiInputBoxIndicator.qml b/configs/quickshell/modules/sidebarLeft/ApiInputBoxIndicator.qml new file mode 100644 index 0000000..13fb81c --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/ApiInputBoxIndicator.qml @@ -0,0 +1,47 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +Item { // Model indicator + id: root + property string icon: "api" + property string text: "" + property string tooltipText: "" + implicitHeight: rowLayout.implicitHeight + 4 * 2 + implicitWidth: rowLayout.implicitWidth + 4 * 2 + + RowLayout { + id: rowLayout + anchors.centerIn: parent + + MaterialSymbol { + text: root.icon + iconSize: Appearance.font.pixelSize.normal + } + StyledText { + id: providerName + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.m3colors.m3onSurface + elide: Text.ElideRight + text: root.text + } + } + + Loader { + active: root.tooltipText?.length > 0 + anchors.fill: parent + sourceComponent: MouseArea { + id: mouseArea + hoverEnabled: true + + StyledToolTip { + id: toolTip + extraVisibleCondition: false + alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered + content: root.tooltipText + } + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/DescriptionBox.qml b/configs/quickshell/modules/sidebarLeft/DescriptionBox.qml new file mode 100644 index 0000000..5287ecc --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/DescriptionBox.qml @@ -0,0 +1,62 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { // Tag suggestion description + id: root + property alias text: tagDescriptionText.text + property bool showArrows: true + property bool showTab: true + + visible: tagDescriptionText.text.length > 0 + Layout.fillWidth: true + implicitHeight: tagDescriptionBackground.implicitHeight + + Rectangle { + id: tagDescriptionBackground + color: Appearance.colors.colLayer2 + anchors.fill: parent + radius: Appearance.rounding.verysmall + implicitHeight: descriptionRow.implicitHeight + 5 * 2 + + RowLayout { + id: descriptionRow + spacing: 4 + anchors { + fill: parent + leftMargin: 10 + rightMargin: 10 + } + + StyledText { + id: tagDescriptionText + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer2 + wrapMode: Text.Wrap + } + KeyboardKey { + visible: root.showArrows + key: "โ†‘" + } + KeyboardKey { + visible: root.showArrows + key: "โ†“" + } + StyledText { + visible: root.showArrows && root.showTab + text: Translation.tr("or") + font.pixelSize: Appearance.font.pixelSize.smaller + } + KeyboardKey { + id: tagDescriptionKey + visible: root.showTab + key: "Tab" + Layout.alignment: Qt.AlignVCenter + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/SidebarLeft.qml b/configs/quickshell/modules/sidebarLeft/SidebarLeft.qml new file mode 100644 index 0000000..0aed7b7 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/SidebarLeft.qml @@ -0,0 +1,202 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property int sidebarPadding: 15 + property bool detach: false + property Component contentComponent: SidebarLeftContent {} + property Item sidebarContent + + Component.onCompleted: { + root.sidebarContent = contentComponent.createObject(null, { + "scopeRoot": root, + }); + sidebarLoader.item.contentParent.children = [root.sidebarContent]; + } + + onDetachChanged: { + if (root.detach) { + sidebarContent.parent = null; // Detach content from sidebar + sidebarLoader.active = false; // Unload sidebar + detachedSidebarLoader.active = true; // Load detached window + detachedSidebarLoader.item.contentParent.children = [sidebarContent]; + } else { + sidebarContent.parent = null; // Detach content from window + detachedSidebarLoader.active = false; // Unload detached window + sidebarLoader.active = true; // Load sidebar + sidebarLoader.item.contentParent.children = [sidebarContent]; + } + } + + Loader { + id: sidebarLoader + active: true + + sourceComponent: PanelWindow { // Window + id: sidebarRoot + visible: GlobalStates.sidebarLeftOpen + + property bool extend: false + property real sidebarWidth: sidebarRoot.extend ? Appearance.sizes.sidebarWidthExtended : Appearance.sizes.sidebarWidth + property var contentParent: sidebarLeftBackground + + function hide() { + GlobalStates.sidebarLeftOpen = false + } + + exclusiveZone: 0 + implicitWidth: Appearance.sizes.sidebarWidthExtended + Appearance.sizes.elevationMargin + WlrLayershell.namespace: "quickshell:sidebarLeft" + // Hyprland 0.49: OnDemand is Exclusive, Exclusive just breaks click-outside-to-close + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + color: "transparent" + + anchors { + top: true + left: true + bottom: true + } + + mask: Region { + item: sidebarLeftBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [ sidebarRoot ] + active: sidebarRoot.visible + onActiveChanged: { // Focus the selected tab + if (active) sidebarLeftBackground.children[0].focusActiveItem() + } + onCleared: () => { + if (!active) sidebarRoot.hide() + } + } + + // Content + StyledRectangularShadow { + target: sidebarLeftBackground + radius: sidebarLeftBackground.radius + } + Rectangle { + id: sidebarLeftBackground + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Appearance.sizes.hyprlandGapsOut + anchors.leftMargin: Appearance.sizes.hyprlandGapsOut + width: sidebarRoot.sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin + height: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sidebarRoot.hide(); + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_O) { + sidebarRoot.extend = !sidebarRoot.extend; + } + else if (event.key === Qt.Key_P) { + root.detach = !root.detach; + } + event.accepted = true; + } + } + } + } + } + + Loader { + id: detachedSidebarLoader + active: false + + sourceComponent: FloatingWindow { + id: detachedSidebarRoot + visible: GlobalStates.sidebarLeftOpen + property var contentParent: detachedSidebarBackground + + Rectangle { + id: detachedSidebarBackground + anchors.fill: parent + color: Appearance.colors.colLayer0 + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_P) { + root.detach = !root.detach; + } + event.accepted = true; + } + } + } + } + } + + IpcHandler { + target: "sidebarLeft" + + function toggle(): void { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen + } + + function close(): void { + GlobalStates.sidebarLeftOpen = false + } + + function open(): void { + GlobalStates.sidebarLeftOpen = true + } + } + + GlobalShortcut { + name: "sidebarLeftToggle" + description: "Toggles left sidebar on press" + + onPressed: { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + } + + GlobalShortcut { + name: "sidebarLeftOpen" + description: "Opens left sidebar on press" + + onPressed: { + GlobalStates.sidebarLeftOpen = true; + } + } + + GlobalShortcut { + name: "sidebarLeftClose" + description: "Closes left sidebar on press" + + onPressed: { + GlobalStates.sidebarLeftOpen = false; + } + } + + GlobalShortcut { + name: "sidebarLeftToggleDetach" + description: "Detach left sidebar into a window/Attach it back" + + onPressed: { + root.detach = !root.detach; + } + } + +} diff --git a/configs/quickshell/modules/sidebarLeft/SidebarLeft.qml.template b/configs/quickshell/modules/sidebarLeft/SidebarLeft.qml.template new file mode 100644 index 0000000..4104059 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/SidebarLeft.qml.template @@ -0,0 +1,241 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: sidebarLeft + + property bool visible: false + + width: 350 + height: Screen.height + color: "@SURFACE_COLOR@" + + // Slide animation + x: visible ? 0 : -width + Behavior on x { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + + ColumnLayout { + width: parent.width + spacing: 15 + + // AI Chat Section + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 300 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "AI Assistant" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + + TextArea { + id: aiChatArea + placeholderText: "Ask me anything..." + color: "@ON_SURFACE_COLOR@" + wrapMode: TextArea.Wrap + readOnly: true + } + } + + TextField { + id: aiInput + Layout.fillWidth: true + placeholderText: "Type your message..." + color: "@ON_SURFACE_COLOR@" + + onAccepted: { + sendAiMessage(text) + text = "" + } + } + } + } + + // Calendar Widget + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 200 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "Calendar" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + // Simple calendar display + Text { + text: new Date().toLocaleDateString() + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 24 + font.bold: true + } + + Text { + text: new Date().toLocaleTimeString() + color: "@ON_SURFACE_VARIANT_COLOR@" + font.pixelSize: 14 + } + } + } + + // Todo List + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 250 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "Todo List" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + + ListView { + id: todoList + model: ListModel { + ListElement { text: "Welcome to dots-hyprland!"; completed: false } + ListElement { text: "Customize your desktop"; completed: false } + ListElement { text: "Explore AI features"; completed: false } + } + + delegate: Row { + width: parent.width + spacing: 10 + + CheckBox { + checked: model.completed + onToggled: model.completed = checked + } + + Text { + text: model.text + color: "@ON_SURFACE_COLOR@" + font.strikeout: model.completed + } + } + } + } + + TextField { + Layout.fillWidth: true + placeholderText: "Add new todo..." + color: "@ON_SURFACE_COLOR@" + + onAccepted: { + if (text.trim() !== "") { + todoList.model.append({text: text, completed: false}) + text = "" + } + } + } + } + } + + // System Information + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 150 + color: "@SURFACE_VARIANT_COLOR@" + radius: 12 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 15 + + Text { + text: "System Info" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 16 + font.bold: true + } + + Text { + text: "CPU: " + getCpuUsage() + "%" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + + Text { + text: "Memory: " + getMemoryUsage() + "%" + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + + Text { + text: "Uptime: " + getUptime() + color: "@ON_SURFACE_COLOR@" + font.pixelSize: 12 + } + } + } + } + } + + function sendAiMessage(message) { + // Send message to AI service + aiChatArea.append("You: " + message) + + // Call AI service (implementation depends on provider) + callAiService(message) + } + + function callAiService(message) { + // Implementation for AI service calls + // This would integrate with the AI module + aiChatArea.append("AI: I received your message: " + message) + } + + function getCpuUsage() { + // Placeholder - would integrate with system monitoring + return Math.floor(Math.random() * 100) + } + + function getMemoryUsage() { + // Placeholder - would integrate with system monitoring + return Math.floor(Math.random() * 100) + } + + function getUptime() { + // Placeholder - would integrate with system monitoring + return "2h 30m" + } +} diff --git a/configs/quickshell/modules/sidebarLeft/SidebarLeftContent.qml b/configs/quickshell/modules/sidebarLeft/SidebarLeftContent.qml new file mode 100644 index 0000000..fc60618 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/SidebarLeftContent.qml @@ -0,0 +1,106 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +Item { + id: root + required property var scopeRoot + anchors.fill: parent + property var tabButtonList: [ + ...(Config.options.policies.ai !== 0 ? [{"icon": "neurology", "name": Translation.tr("Intelligence")}] : []), + {"icon": "translate", "name": Translation.tr("Translator")}, + ...(Config.options.policies.weeb === 1 ? [{"icon": "bookmark_heart", "name": Translation.tr("Anime")}] : []) + ] + property int selectedTab: 0 + + function focusActiveItem() { + swipeView.currentItem.forceActiveFocus() + } + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + event.accepted = true; + } + else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + event.accepted = true; + } + else if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length; + event.accepted = true; + } + else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length; + event.accepted = true; + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: sidebarPadding + + spacing: sidebarPadding + + PrimaryTabBar { // Tab strip + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { // Content pages + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + currentIndex: tabBar.externalTrackedTab + onCurrentIndexChanged: { + tabBar.enableIndicatorAnimation = true + root.selectedTab = currentIndex + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + contentChildren: [ + ...(Config.options.policies.ai !== 0 ? [aiChat.createObject()] : []), + translator.createObject(), + ...(Config.options.policies.weeb === 0 ? [] : [anime.createObject()]) + ] + } + + Component { + id: aiChat + AiChat {} + } + Component { + id: translator + Translator {} + } + Component { + id: anime + Anime {} + } + + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/Translator.qml b/configs/quickshell/modules/sidebarLeft/Translator.qml new file mode 100644 index 0000000..e9e3d6f --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/Translator.qml @@ -0,0 +1,246 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./translator/" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +/** + * Translator widget with the `trans` commandline tool. + */ +Item { + id: root + // Widgets + property var inputField: inputCanvas.inputTextArea + // Widget variables + property bool translationFor: false // Indicates if the translation is for an autocorrected text + property string translatedText: "" + property list languages: [] + // Options + property string targetLanguage: Config.options.language.translator.targetLanguage + property string sourceLanguage: Config.options.language.translator.sourceLanguage + property string hostLanguage: targetLanguage + + property bool showLanguageSelector: false + property bool languageSelectorTarget: false // true for target language, false for source language + + function showLanguageSelectorDialog(isTargetLang: bool) { + root.languageSelectorTarget = isTargetLang; + root.showLanguageSelector = true + } + + onFocusChanged: (focus) => { + if (focus) { + root.inputField.forceActiveFocus() + } + } + + Timer { + id: translateTimer + interval: Config.options.sidebar.translator.delay + repeat: false + onTriggered: () => { + if (root.inputField.text.trim().length > 0) { + // console.log("Translating with command:", translateProc.command); + translateProc.running = false; + translateProc.buffer = ""; // Clear the buffer + translateProc.running = true; // Restart the process + } else { + root.translatedText = ""; + } + } + } + + Process { + id: translateProc + command: ["bash", "-c", `trans -no-theme -no-bidi` + + ` -source '${StringUtils.shellSingleQuoteEscape(root.sourceLanguage)}'` + + ` -target '${StringUtils.shellSingleQuoteEscape(root.targetLanguage)}'` + + ` -no-ansi '${StringUtils.shellSingleQuoteEscape(root.inputField.text.trim())}'`] + property string buffer: "" + stdout: SplitParser { + onRead: data => { + translateProc.buffer += data + "\n"; + } + } + onExited: (exitCode, exitStatus) => { + // 1. Split into sections by double newlines + const sections = translateProc.buffer.trim().split(/\n\s*\n/); + // console.log("BUFFER:", translateProc.buffer); + // console.log("SECTIONS:", sections); + + // 2. Extract relevant data + root.translatedText = sections.length > 1 ? sections[1].trim() : ""; + } + } + + Process { + id: getLanguagesProc + command: ["trans", "-list-languages", "-no-bidi"] + property list bufferList: ["auto"] + running: true + stdout: SplitParser { + onRead: data => { + getLanguagesProc.bufferList.push(data.trim()); + } + } + onExited: (exitCode, exitStatus) => { + // Ensure "auto" is always the first language + let langs = getLanguagesProc.bufferList + .filter(lang => lang.trim().length > 0 && lang !== "auto") + .sort((a, b) => a.localeCompare(b)); + langs.unshift("auto"); + root.languages = langs; + getLanguagesProc.bufferList = []; // Clear the buffer + } + } + + ColumnLayout { + anchors.fill: parent + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: contentColumn.implicitHeight + + ColumnLayout { + id: contentColumn + anchors.fill: parent + + LanguageSelectorButton { // Target language button + id: targetLanguageButton + displayText: root.targetLanguage + onClicked: { + root.showLanguageSelectorDialog(true); + } + } + + TextCanvas { // Content translation + id: outputCanvas + isInput: false + placeholderText: Translation.tr("Translation goes here...") + property bool hasTranslation: (root.translatedText.trim().length > 0) + text: hasTranslation ? root.translatedText : "" + GroupButton { + id: copyButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: outputCanvas.displayedText.trim().length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "content_copy" + color: copyButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + Quickshell.clipboardText = outputCanvas.displayedText + } + } + GroupButton { + id: searchButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: outputCanvas.displayedText.trim().length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "travel_explore" + color: searchButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + let url = Config.options.search.engineBaseUrl + outputCanvas.displayedText; + for (let site of Config.options.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + } + } + + } + } + + LanguageSelectorButton { // Source language button + id: sourceLanguageButton + displayText: root.sourceLanguage + onClicked: { + root.showLanguageSelectorDialog(false); + } + } + + TextCanvas { // Content input + id: inputCanvas + isInput: true + placeholderText: Translation.tr("Enter text to translate...") + onInputTextChanged: { + translateTimer.restart(); + } + GroupButton { + id: pasteButton + baseWidth: height + buttonRadius: Appearance.rounding.small + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "content_paste" + color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + root.inputField.text = Quickshell.clipboardText + } + } + GroupButton { + id: deleteButton + baseWidth: height + buttonRadius: Appearance.rounding.small + enabled: inputCanvas.inputTextArea.text.length > 0 + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: "close" + color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + } + onClicked: { + root.inputField.text = "" + } + } + } + } + + Loader { + anchors.fill: parent + active: root.showLanguageSelector + visible: root.showLanguageSelector + z: 9999 + sourceComponent: SelectionDialog { + id: languageSelectorDialog + titleText: Translation.tr("Select Language") + items: root.languages + defaultChoice: root.languageSelectorTarget ? root.targetLanguage : root.sourceLanguage + onCanceled: () => { + root.showLanguageSelector = false; + } + onSelected: (result) => { + root.showLanguageSelector = false; + if (!result || result.length === 0) return; // No selection made + + if (root.languageSelectorTarget) { + root.targetLanguage = result; + Config.options.language.translator.targetLanguage = result; // Save to config + } else { + root.sourceLanguage = result; + Config.options.language.translator.sourceLanguage = result; // Save to config + } + + translateTimer.restart(); // Restart translation after language change + } + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/configs/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml new file mode 100644 index 0000000..d2b72d1 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -0,0 +1,302 @@ +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 + +Rectangle { + id: root + property int messageIndex + property var messageData + property var messageInputField + + property real messagePadding: 7 + property real contentSpacing: 3 + + property bool enableMouseSelection: false + property bool renderMarkdown: true + property bool editing: false + + property list messageBlocks: StringUtils.splitMarkdownBlocks(root.messageData?.content) + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2 + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + function saveMessage() { + if (!root.editing) return; + // Get all Loader children (each represents a segment) + const segments = messageContentColumnLayout.children + .map(child => child.segment) + .filter(segment => (segment)); + + // Reconstruct markdown + const newContent = segments.map(segment => { + if (segment.type === "code") { + const lang = segment.lang ? segment.lang : ""; + // Remove trailing newlines + const code = segment.content.replace(/\n+$/, ""); + return "```" + lang + "\n" + code + "\n```"; + } else { + return segment.content; + } + }).join(""); + + root.editing = false + root.messageData.content = newContent; + } + + Keys.onPressed: (event) => { + if ( // Prevent de-select + event.key === Qt.Key_Control || + event.key == Qt.Key_Shift || + event.key == Qt.Key_Alt || + event.key == Qt.Key_Meta + ) { + event.accepted = true + } + // Ctrl + S to save + if ((event.key === Qt.Key_S) && event.modifiers == Qt.ControlModifier) { + root.saveMessage(); + event.accepted = true; + } + } + + ColumnLayout { // Main layout of the whole thing + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: messagePadding + spacing: root.contentSpacing + + RowLayout { // Header + spacing: 15 + Layout.fillWidth: true + + Rectangle { // Name + id: nameWrapper + color: Appearance.colors.colSecondaryContainer + // color: "transparent" + radius: Appearance.rounding.small + implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30) + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + RowLayout { + id: nameRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 7 + + Item { + Layout.alignment: Qt.AlignVCenter + Layout.fillHeight: true + implicitWidth: messageData?.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth + implicitHeight: messageData?.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight + + CustomIcon { + id: modelIcon + anchors.centerIn: parent + visible: messageData?.role == 'assistant' && Ai.models[messageData?.model].icon + width: Appearance.font.pixelSize.large + height: Appearance.font.pixelSize.large + source: messageData?.role == 'assistant' ? Ai.models[messageData?.model].icon : + messageData?.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic' + + colorize: true + color: Appearance.m3colors.m3onSecondaryContainer + } + + MaterialSymbol { + id: roleIcon + anchors.centerIn: parent + visible: !modelIcon.visible + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData?.role == 'user' ? 'person' : + messageData?.role == 'interface' ? 'settings' : + messageData?.role == 'assistant' ? 'neurology' : + 'computer' + } + } + + StyledText { + id: providerName + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData?.role == 'assistant' ? Ai.models[messageData?.model].name : + (messageData?.role == 'user' && SystemInfo.username) ? SystemInfo.username : + Translation.tr("Interface") + } + } + } + + Button { // Not visible to model + id: modelVisibilityIndicator + visible: messageData?.role == 'interface' + implicitWidth: 16 + implicitHeight: 30 + Layout.alignment: Qt.AlignVCenter + + background: Item + + MaterialSymbol { + id: notVisibleToModelText + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + text: "visibility_off" + } + StyledToolTip { + content: Translation.tr("Not visible to model") + } + } + + ButtonGroup { + spacing: 5 + + AiMessageControlButton { + id: copyButton + buttonIcon: activated ? "inventory" : "content_copy" + + onClicked: { + Quickshell.clipboardText = root.messageData?.content + copyButton.activated = true + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyButton.activated = false + } + } + + StyledToolTip { + content: Translation.tr("Copy") + } + } + AiMessageControlButton { + id: editButton + activated: root.editing + enabled: root.messageData?.done ?? false + buttonIcon: "edit" + onClicked: { + root.editing = !root.editing + if (!root.editing) { // Save changes + root.saveMessage() + } + } + StyledToolTip { + content: root.editing ? Translation.tr("Save") : Translation.tr("Edit") + } + } + AiMessageControlButton { + id: toggleMarkdownButton + activated: !root.renderMarkdown + buttonIcon: "code" + onClicked: { + root.renderMarkdown = !root.renderMarkdown + } + StyledToolTip { + content: Translation.tr("View Markdown source") + } + } + AiMessageControlButton { + id: deleteButton + buttonIcon: "close" + onClicked: { + Ai.removeMessage(root.messageIndex) + } + StyledToolTip { + content: Translation.tr("Delete") + } + } + } + } + + ColumnLayout { // Message content + id: messageContentColumnLayout + + spacing: 0 + Repeater { + model: root.messageBlocks.length + delegate: Loader { + required property int index + property var thisBlock: root.messageBlocks[index] + Layout.fillWidth: true + // property var segment: thisBlock + property var segmentContent: thisBlock.content + property var segmentLang: thisBlock.lang + property var messageData: root.messageData + property var editing: root.editing + property var renderMarkdown: root.renderMarkdown + property var enableMouseSelection: root.enableMouseSelection + property bool thinking: root.messageData?.thinking ?? true + property bool done: root.messageData?.done ?? false + property bool completed: thisBlock.completed ?? false + + source: thisBlock.type === "code" ? "MessageCodeBlock.qml" : + thisBlock.type === "think" ? "MessageThinkBlock.qml" : + "MessageTextBlock.qml" + + } + } + } + + Flow { // Annotations + visible: root.messageData?.annotationSources?.length > 0 + spacing: 5 + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + Repeater { + model: ScriptModel { + values: root.messageData?.annotationSources || [] + } + delegate: AnnotationSourceButton { + required property var modelData + displayText: modelData.text + url: modelData.url + } + } + } + + Flow { // Search queries + visible: root.messageData?.searchQueries?.length > 0 + spacing: 5 + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + Repeater { + model: ScriptModel { + values: root.messageData?.searchQueries || [] + } + delegate: SearchQueryButton { + required property var modelData + query: modelData + } + } + } + + } +} + diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/AiMessageControlButton.qml b/configs/quickshell/modules/sidebarLeft/aiChat/AiMessageControlButton.qml new file mode 100644 index 0000000..64fc772 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/AiMessageControlButton.qml @@ -0,0 +1,26 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick + +GroupButton { + id: button + property string buttonIcon + property bool activated: false + toggled: activated + + baseWidth: height + + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + text: buttonIcon + color: button.activated ? Appearance.m3colors.m3onPrimary : + button.enabled ? Appearance.m3colors.m3onSurface : + Appearance.colors.colOnLayer1Inactive + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml b/configs/quickshell/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml new file mode 100644 index 0000000..75687e4 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml @@ -0,0 +1,52 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland + +RippleButton { + id: root + property string displayText + property string url + + property real faviconSize: 20 + implicitHeight: 30 + leftPadding: (implicitHeight - faviconSize) / 2 + rightPadding: 10 + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + PointingHandInteraction {} + onClicked: { + if (url) { + Qt.openUrlExternally(url) + GlobalStates.sidebarLeftOpen = false + } + } + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 5 + Favicon { + url: root.url + size: root.faviconSize + displayText: root.displayText + } + StyledText { + id: text + horizontalAlignment: Text.AlignHCenter + text: displayText + color: Appearance.m3colors.m3onSurface + } + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/configs/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml new file mode 100644 index 0000000..f8b0bac --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -0,0 +1,297 @@ +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.Controls +import QtQuick.Layouts +import Quickshell +import org.kde.syntaxhighlighting + +ColumnLayout { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property var segmentContent: parent?.segmentContent ?? ({}) + property var segmentLang: parent?.segmentLang ?? "txt" + property bool isCommandRequest: segmentLang === "command" + property var displayLang: (isCommandRequest ? "bash" : segmentLang) + property var messageData: parent?.messageData ?? {} + + property real codeBlockBackgroundRounding: Appearance.rounding.small + property real codeBlockHeaderPadding: 3 + property real codeBlockComponentSpacing: 2 + + spacing: codeBlockComponentSpacing + anchors.left: parent.left + anchors.right: parent.right + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: codeBlockBackgroundRounding + topRightRadius: codeBlockBackgroundRounding + bottomLeftRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colSurfaceContainerHighest + implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + codeBlockHeaderPadding * 2 + + RowLayout { // Language and buttons + id: codeBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: codeBlockHeaderPadding + anchors.rightMargin: codeBlockHeaderPadding + spacing: 5 + + StyledText { + id: codeBlockLanguage + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 10 + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.DemiBold + color: Appearance.colors.colOnLayer2 + text: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain" + } + + Item { Layout.fillWidth: true } + + ButtonGroup { + AiMessageControlButton { + id: copyCodeButton + buttonIcon: activated ? "inventory" : "content_copy" + + onClicked: { + Quickshell.clipboardText = segmentContent + copyCodeButton.activated = true + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyCodeButton.activated = false + } + } + StyledToolTip { + content: Translation.tr("Copy code") + } + } + AiMessageControlButton { + id: saveCodeButton + buttonIcon: activated ? "check" : "save" + + onClicked: { + const downloadPath = FileUtils.trimFileProtocol(Directories.downloads) + Quickshell.execDetached(["bash", "-c", + `echo '${StringUtils.shellSingleQuoteEscape(segmentContent)}' > '${downloadPath}/code.${segmentLang || "txt"}'` + ]) + Quickshell.execDetached(["notify-send", + Translation.tr("Code saved to file"), + Translation.tr("Saved to %1").arg(`${downloadPath}/code.${segmentLang || "txt"}`), + "-a", "Shell" + ]) + saveCodeButton.activated = true + saveIconTimer.restart() + } + + Timer { + id: saveIconTimer + interval: 1500 + repeat: false + onTriggered: { + saveCodeButton.activated = false + } + } + StyledToolTip { + content: Translation.tr("Save to Downloads") + } + } + } + } + } + + RowLayout { // Line numbers and code + spacing: codeBlockComponentSpacing + + Rectangle { // Line numbers + implicitWidth: 40 + implicitHeight: lineNumberColumnLayout.implicitHeight + Layout.fillHeight: true + Layout.fillWidth: false + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: codeBlockBackgroundRounding + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colLayer2 + + ColumnLayout { + id: lineNumberColumnLayout + anchors { + left: parent.left + right: parent.right + rightMargin: 5 + top: parent.top + topMargin: 6 + } + spacing: 0 + + Repeater { + model: codeTextArea.text.split("\n").length + Text { + required property int index + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + font.family: Appearance.font.family.monospace + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: index + 1 + } + } + } + } + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: Appearance.rounding.unsharpen + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: codeBlockBackgroundRounding + color: Appearance.colors.colLayer2 + implicitHeight: codeColumnLayout.implicitHeight + + ColumnLayout { + id: codeColumnLayout + anchors.fill: parent + spacing: 0 + ScrollView { + id: codeScrollView + Layout.fillWidth: true + // Layout.fillHeight: true + implicitWidth: parent.width + implicitHeight: codeTextArea.implicitHeight + 1 + contentWidth: codeTextArea.width - 1 + // contentHeight: codeTextArea.contentHeight + clip: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ScrollBar.horizontal: ScrollBar { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + padding: 5 + policy: ScrollBar.AsNeeded + opacity: visualSize == 1 ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + contentItem: Rectangle { + implicitHeight: 6 + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2Active + } + } + + TextArea { // Code + id: codeTextArea + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.monospace + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + // wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + + text: segmentContent + onTextChanged: { + segmentContent = text + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + // Insert 4 spaces at cursor + const cursor = codeTextArea.cursorPosition; + codeTextArea.insert(cursor, " "); + codeTextArea.cursorPosition = cursor + 4; + event.accepted = true; + } else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { + codeTextArea.copy(); + event.accepted = true; + } + } + + SyntaxHighlighter { + id: highlighter + textEdit: codeTextArea + repository: Repository + definition: Repository.definitionForName(root.displayLang || "plaintext") + theme: Appearance.syntaxHighlightingTheme + } + } + } + Loader { + active: root.isCommandRequest && root.messageData.functionPending + visible: active + Layout.fillWidth: true + Layout.margins: 6 + Layout.topMargin: 0 + sourceComponent: RowLayout { + Item { Layout.fillWidth: true } + ButtonGroup { + GroupButton { + contentItem: StyledText { + text: Translation.tr("Reject") + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer2 + } + onClicked: Ai.rejectCommand(root.messageData) + } + GroupButton { + toggled: true + contentItem: StyledText { + text: Translation.tr("Approve") + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnPrimary + } + onClicked: Ai.approveCommand(root.messageData) + } + } + } + } + } + + // MouseArea to block scrolling + // MouseArea { + // id: codeBlockMouseArea + // anchors.fill: parent + // acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton + // cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + // onWheel: (event) => { + // event.accepted = false + // } + // } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml b/configs/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml new file mode 100644 index 0000000..d0d9d64 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml @@ -0,0 +1,142 @@ +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.Controls +import QtQuick.Layouts +import Quickshell.Hyprland + +ColumnLayout { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property list renderedLatexHashes: [] + + property string renderedSegmentContent: "" + + Layout.fillWidth: true + + Timer { + id: renderTimer + interval: 1000 + repeat: false + onTriggered: { + renderLatex() + for (const hash of renderedLatexHashes) { + handleRenderedLatex(hash, true); + } + } + } + + function renderLatex() { + // Regex for $...$, $$...$$, \[...\] + // Note: This is a simple approach and may need refinement for edge cases + let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])|(\\\(([\s\S]+?)\\\))/g; + let match; + while ((match = regex.exec(segmentContent)) !== null) { + let expression = match[1] || match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8]; + if (expression) { + Qt.callLater(() => { + const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim()); + if (!renderedLatexHashes.includes(renderHash)) { + renderedLatexHashes.push(renderHash); + } + }); + } + } + } + + function handleRenderedLatex(hash, force = false) { + if (renderedLatexHashes.includes(hash) || force) { + const imagePath = LatexRenderer.renderedImagePaths[hash]; + const markdownImage = `![latex](${imagePath})`; + + const expression = LatexRenderer.processedExpressions[hash]; + renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage); + } + } + + onDoneChanged: { + renderTimer.restart(); + } + onEditingChanged: { + if (!editing) { + renderLatex() + } else { + // console.log("Editing mode enabled", segmentContent) + textArea.text = segmentContent + } + } + + onSegmentContentChanged: { + // console.log("Segment content changed: " + segmentContent); + renderedSegmentContent = segmentContent; + if (!root.editing && segmentContent) { + root.renderLatex(); + } + } + + onRenderedSegmentContentChanged: { + // console.log("Rendered segment content changed: " + renderedSegmentContent); + if (renderedSegmentContent) { + textArea.text = renderedSegmentContent; + } + } + + // When something finishes rendering + // 1. Check if the hash is in the list + // 2. If it is, replace the expression with the image path + Connections { + target: LatexRenderer + function onRenderFinished(hash, imagePath) { + const expression = LatexRenderer.processedExpressions[hash]; + // console.log("Render finished: " + hash + " " + expression); + handleRenderedLatex(hash); + } + } + + TextArea { + id: textArea + + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.reading + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText + text: Translation.tr("Waiting for response...") + + onTextChanged: { + if (!root.editing) return + segmentContent = text + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.sidebarLeftOpen = false + } + + MouseArea { // Pointing hand for links + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : + (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml b/configs/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml new file mode 100644 index 0000000..326c26d --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml @@ -0,0 +1,173 @@ +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 Qt5Compat.GraphicalEffects + +Item { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property bool completed: parent?.completed ?? false + + property real thinkBlockBackgroundRounding: Appearance.rounding.small + property real thinkBlockHeaderPaddingVertical: 3 + property real thinkBlockHeaderPaddingHorizontal: 10 + property real thinkBlockComponentSpacing: 2 + + property var collapseAnimation: messageTextBlock.implicitHeight > 40 ? Appearance.animation.elementMoveEnter : Appearance.animation.elementMoveFast + property bool collapsed: true /* should be root.completed but its kinda buggy rn so nope */ + + Layout.fillWidth: true + implicitHeight: collapsed ? header.implicitHeight : columnLayout.implicitHeight + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: thinkBlockBackgroundRounding + } + } + + Behavior on implicitHeight { + enabled: root.completed ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + ColumnLayout { + id: columnLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 0 + + Rectangle { // Header background + id: header + color: Appearance.colors.colSurfaceContainerHighest + Layout.fillWidth: true + implicitHeight: thinkBlockTitleBarRowLayout.implicitHeight + thinkBlockHeaderPaddingVertical * 2 + + MouseArea { // Click to reveal + id: headerMouseArea + enabled: root.completed + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + root.collapsed = !root.collapsed + } + } + + RowLayout { // Header content + id: thinkBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: thinkBlockHeaderPaddingHorizontal + anchors.rightMargin: thinkBlockHeaderPaddingHorizontal + spacing: 10 + + MaterialSymbol { + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 3 + text: "linked_services" + } + StyledText { + id: thinkBlockLanguage + Layout.fillWidth: false + Layout.alignment: Qt.AlignLeft + text: root.completed ? Translation.tr("Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4)) + } + Item { Layout.fillWidth: true } + RippleButton { // Expand button + id: expandButton + visible: root.completed + implicitWidth: 22 + implicitHeight: 22 + colBackground: headerMouseArea.containsMouse ? Appearance.colors.colLayer2Hover + : ColorUtils.transparentize(Appearance.colors.colLayer2, 1) + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + onClicked: { root.collapsed = !root.collapsed } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "keyboard_arrow_down" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer2 + rotation: root.collapsed ? 0 : 180 + Behavior on rotation { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } + + } + + } + + } + + Item { + id: content + Layout.fillWidth: true + implicitHeight: collapsed ? 0 : contentBackground.implicitHeight + thinkBlockComponentSpacing + clip: true + + Behavior on implicitHeight { + enabled: root.completed ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + Rectangle { + id: contentBackground + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: messageTextBlock.implicitHeight + color: Appearance.colors.colLayer2 + + // Load data for the message at the correct scope + property bool editing: root.editing + property bool renderMarkdown: root.renderMarkdown + property bool enableMouseSelection: root.enableMouseSelection + property string segmentContent: root.segmentContent + property var messageData: root.messageData + property bool done: root.done + + MessageTextBlock { + id: messageTextBlock + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/aiChat/SearchQueryButton.qml b/configs/quickshell/modules/sidebarLeft/aiChat/SearchQueryButton.qml new file mode 100644 index 0000000..4ad60ac --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/aiChat/SearchQueryButton.qml @@ -0,0 +1,53 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland + +RippleButton { + id: root + property string query + + implicitHeight: 30 + leftPadding: 6 + rightPadding: 10 + buttonRadius: Appearance.rounding.verysmall + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + PointingHandInteraction {} + onClicked: { + let url = Config.options.search.engineBaseUrl + root.query; + for (let site of (Config?.options?.search.excludedSites ?? [])) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + GlobalStates.sidebarLeftOpen = false; + } + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + RowLayout { + id: rowLayout + anchors.centerIn: parent + spacing: 5 + MaterialSymbol { + text: "search" + iconSize: 20 + color: Appearance.m3colors.m3onSurface + } + StyledText { + id: text + horizontalAlignment: Text.AlignHCenter + text: root.query + color: Appearance.m3colors.m3onSurface + } + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/anime/BooruImage.qml b/configs/quickshell/modules/sidebarLeft/anime/BooruImage.qml new file mode 100644 index 0000000..abb7461 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/anime/BooruImage.qml @@ -0,0 +1,190 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQml +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Button { + id: root + property var imageData + property var rowHeight + property bool manualDownload: true + property string previewDownloadPath + property string downloadPath + property string nsfwPath + property string fileName: decodeURIComponent((imageData.file_url).substring((imageData.file_url).lastIndexOf('/') + 1)) + property string filePath: `${root.previewDownloadPath}/${root.fileName}` + property int maxTagStringLineLength: 50 + property real imageRadius: Appearance.rounding.small + + property bool showActions: false + Process { + id: downloadProcess + running: false + command: ["bash", "-c", `[ -f ${root.filePath} ] || curl -sSL '${root.imageData.preview_url ?? root.imageData.sample_url}' -o '${root.filePath}'`] + onExited: (exitCode, exitStatus) => { + imageObject.source = `${previewDownloadPath}/${root.fileName}` + } + } + + Component.onCompleted: { + if (root.manualDownload) { + downloadProcess.running = true + } + } + + StyledToolTip { + content: `${StringUtils.wordWrap(root.imageData.tags, root.maxTagStringLineLength)}` + } + + padding: 0 + implicitWidth: root.rowHeight * modelData.aspect_ratio + implicitHeight: root.rowHeight + + background: Rectangle { + implicitWidth: root.rowHeight * modelData.aspect_ratio + implicitHeight: root.rowHeight + radius: imageRadius + color: Appearance.colors.colLayer2 + } + + contentItem: Item { + anchors.fill: parent + + Image { + id: imageObject + anchors.fill: parent + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + visible: opacity > 0 + opacity: status === Image.Ready ? 1 : 0 + fillMode: Image.PreserveAspectFit + source: modelData.preview_url + sourceSize.width: root.rowHeight * modelData.aspect_ratio + sourceSize.height: root.rowHeight + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + radius: imageRadius + } + } + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + + RippleButton { + id: menuButton + anchors.top: parent.top + anchors.right: parent.right + property real buttonSize: 30 + anchors.margins: Math.max(root.imageRadius - buttonSize / 2, 8) + implicitHeight: buttonSize + implicitWidth: buttonSize + + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.transparentize(Appearance.m3colors.m3surface, 0.3) + colBackgroundHover: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.8), 0.2) + colRipple: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.6), 0.1) + + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSurface + text: "more_vert" + } + + onClicked: { + root.showActions = !root.showActions + } + } + + Loader { + id: contextMenuLoader + active: root.showActions + anchors.top: menuButton.bottom + anchors.right: parent.right + anchors.margins: 8 + + sourceComponent: Item { + width: contextMenu.width + height: contextMenu.height + + StyledRectangularShadow { + target: contextMenu + } + Rectangle { + id: contextMenu + anchors.centerIn: parent + opacity: root.showActions ? 1 : 0 + visible: opacity > 0 + radius: Appearance.rounding.small + color: Appearance.colors.colSurfaceContainer + implicitHeight: contextMenuColumnLayout.implicitHeight + radius * 2 + implicitWidth: contextMenuColumnLayout.implicitWidth + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + ColumnLayout { + id: contextMenuColumnLayout + anchors.centerIn: parent + spacing: 0 + + MenuButton { + id: openFileLinkButton + Layout.fillWidth: true + buttonText: Translation.tr("Open file link") + onClicked: { + root.showActions = false + Hyprland.dispatch("keyword cursor:no_warps true") + Qt.openUrlExternally(root.imageData.file_url) + Hyprland.dispatch("keyword cursor:no_warps false") + } + } + MenuButton { + id: sourceButton + visible: root.imageData.source && root.imageData.source.length > 0 + Layout.fillWidth: true + buttonText: Translation.tr("Go to source (%1)").arg(StringUtils.getDomain(root.imageData.source)) + enabled: root.imageData.source && root.imageData.source.length > 0 + onClicked: { + root.showActions = false + Hyprland.dispatch("keyword cursor:no_warps true") + Qt.openUrlExternally(root.imageData.source) + Hyprland.dispatch("keyword cursor:no_warps false") + } + } + MenuButton { + id: downloadButton + Layout.fillWidth: true + buttonText: Translation.tr("Download") + onClicked: { + root.showActions = false + Quickshell.execDetached(["bash", "-c", + `curl '${root.imageData.file_url}' -o '${root.imageData.is_nsfw ? root.nsfwPath : root.downloadPath}/${root.fileName}' && notify-send '${Translation.tr("Download complete")}' '${root.downloadPath}/${root.fileName}' -a 'Shell'` + ]) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/anime/BooruResponse.qml b/configs/quickshell/modules/sidebarLeft/anime/BooruResponse.qml new file mode 100644 index 0000000..baf4771 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/anime/BooruResponse.qml @@ -0,0 +1,294 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "../" +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Rectangle { + id: root + property var responseData + property var tagInputField + + property string previewDownloadPath + property string downloadPath + property string nsfwPath + + property real availableWidth: parent.width + property real rowTooShortThreshold: 190 + property real imageSpacing: 5 + property real responsePadding: 5 + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.responsePadding * 2 + + Component.onCompleted: { + // Break property bind to prevent aggressive updates + availableWidth = parent.width + } + + Connections { + target: parent + function onWidthChanged() { + updateWidthTimer.restart() + } + } + + Timer { + id: updateWidthTimer + interval: 100 + onTriggered: { + availableWidth = parent.width + } + } + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + ColumnLayout { + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: responsePadding + spacing: root.imageSpacing + + RowLayout { // Header + Rectangle { // Provider name + id: providerNameWrapper + color: Appearance.colors.colSecondaryContainer + radius: Appearance.rounding.small + implicitWidth: providerName.implicitWidth + 10 * 2 + implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: providerName + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSecondaryContainer + text: Booru.providers[root.responseData.provider].name + } + } + Item { Layout.fillWidth: true } + Item { // Page number + visible: root.responseData.page != "" && root.responseData.page > 0 + implicitWidth: Math.max(pageNumber.implicitWidth + 10 * 2, 30) + implicitHeight: pageNumber.implicitHeight + 5 * 2 + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: pageNumber + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnLayer2 + // text: `Page ${root.responseData.page}` + text: Translation.tr("Page %1").arg(root.responseData.page) + } + } + } + + StyledFlickable { // Tag strip + id: tagsFlickable + visible: root.responseData.tags.length > 0 + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: { + return true + } + implicitHeight: tagRowLayout.implicitHeight + // height: tagRowLayout.implicitHeight + contentWidth: tagRowLayout.implicitWidth + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: tagsFlickable.width + height: tagsFlickable.height + radius: Appearance.rounding.small + } + } + + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + RowLayout { + id: tagRowLayout + Layout.alignment: Qt.AlignBottom + + Repeater { + id: tagRepeater + model: root.responseData.tags + + ApiCommandButton { + Layout.fillWidth: false + buttonText: modelData + onClicked: { + if(root.tagInputField.text.length !== 0) root.tagInputField.text += " " + root.tagInputField.text += modelData + } + } + } + + } + } + + StyledText { // Message + id: messageText + Layout.fillWidth: true + visible: root.responseData.message.length > 0 + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: root.responseData.message + wrapMode: Text.WordWrap + Layout.margins: responsePadding + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.sidebarLeftOpen = false + } + PointingHandLinkHover {} + } + + Repeater { + model: ScriptModel { + values: { + // Greedily add images to a row as long as rowHeight >= rowTooShortThreshold + let i = 0; + let rows = []; + const responseList = root.responseData.images; + const minRowHeight = rowTooShortThreshold; + const availableImageWidth = availableWidth - root.imageSpacing - (responsePadding * 2); + + while (i < responseList.length) { + let row = { + height: 0, + images: [], + }; + let j = i; + let combinedAspect = 0; + let rowHeight = 0; + + // Try to add as many images as possible without going below minRowHeight + while (j < responseList.length) { + combinedAspect += responseList[j].aspect_ratio; + // Subtract imageSpacing for each gap between images in the row + let imagesInRow = j - i + 1; + let totalSpacing = root.imageSpacing * (imagesInRow - 1); + let rowAvailableWidth = availableImageWidth - totalSpacing; + rowHeight = rowAvailableWidth / combinedAspect; + if (rowHeight < minRowHeight) { + combinedAspect -= responseList[j].aspect_ratio; + imagesInRow -= 1; + totalSpacing = root.imageSpacing * (imagesInRow - 1); + rowAvailableWidth = availableImageWidth - totalSpacing; + rowHeight = rowAvailableWidth / combinedAspect; + break; + } + j++; + } + + // If we couldn't add any image (shouldn't happen), add at least one + if (j === i) { + row.images.push(responseList[i]); + row.height = availableImageWidth / responseList[i].aspect_ratio; + rows.push(row); + i++; + } else { + for (let k = i; k < j; k++) { + row.images.push(responseList[k]); + } + // Recalculate spacing for the final row + let imagesInRow = j - i; + let totalSpacing = root.imageSpacing * (imagesInRow - 1); + let rowAvailableWidth = availableImageWidth - totalSpacing; + row.height = rowAvailableWidth / combinedAspect; + rows.push(row); + i = j; + } + } + return rows; + } + } + delegate: RowLayout { + id: imageRow + required property var modelData + property var rowHeight: modelData.height + spacing: root.imageSpacing + + Repeater { + model: modelData.images + delegate: BooruImage { + required property var modelData + imageData: modelData + rowHeight: imageRow.rowHeight + imageRadius: imageRow.modelData.images.length == 1 ? 50 : Appearance.rounding.normal + // Download manually to reduce redundant requests or make sure downloading works + // manualDownload: ["danbooru", "waifu.im", "t.alcy.cc"].includes(root.responseData.provider) + previewDownloadPath: root.previewDownloadPath + downloadPath: root.downloadPath + nsfwPath: root.nsfwPath + } + } + } + } + + RippleButton { // Next page button + id: button + property string buttonText + visible: root.responseData.page != "" && root.responseData.page > 0 + + Layout.alignment: Qt.AlignRight + implicitHeight: 30 + leftPadding: 10 + rightPadding: 5 + + onClicked: { + tagInputField.text = `${responseData.tags.join(" ")} ${parseInt(root.responseData.page) + 1}` + tagInputField.accept() + } + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colSurfaceContainerHighest + colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover + colRipple: Appearance.colors.colSurfaceContainerHighestActive + + contentItem: Item { + anchors.fill: parent + implicitHeight: nextPageRow.implicitHeight + implicitWidth: nextPageRow.implicitWidth + + RowLayout { + id: nextPageRow + anchors.centerIn: parent + spacing: 0 + StyledText { + Layout.alignment: Qt.AlignVCenter + verticalAlignment: Text.AlignVCenter + text: "Next page" + color: Appearance.m3colors.m3onSurface + } + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSurface + text: "chevron_right" + } + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarLeft/translator/LanguageSelectorButton.qml b/configs/quickshell/modules/sidebarLeft/translator/LanguageSelectorButton.qml new file mode 100644 index 0000000..f23e3b8 --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/translator/LanguageSelectorButton.qml @@ -0,0 +1,41 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: root + property string displayText: "" + colBackground: Appearance.colors.colLayer2 + + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: contentItem.implicitHeight + verticalPadding * 2 + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: languageRow.implicitWidth + implicitHeight: languageText.implicitHeight + RowLayout { + id: languageRow + anchors.centerIn: parent + spacing: 0 + StyledText { + id: languageText + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 5 + text: root.displayText + color: Appearance.colors.colOnLayer2 + font.pixelSize: Appearance.font.pixelSize.small + } + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.hugeass + text: "arrow_drop_down" + color: Appearance.colors.colOnLayer2 + } + } + } +} diff --git a/configs/quickshell/modules/sidebarLeft/translator/TextCanvas.qml b/configs/quickshell/modules/sidebarLeft/translator/TextCanvas.qml new file mode 100644 index 0000000..c29265c --- /dev/null +++ b/configs/quickshell/modules/sidebarLeft/translator/TextCanvas.qml @@ -0,0 +1,89 @@ +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 + +Rectangle { + id: root + property bool isInput: true // true for input, false for output + property string placeholderText + property string text: "" + property var inputTextArea: isInput ? inputLoader.item : undefined + readonly property string displayedText: isInput ? inputLoader.item.text : + root.text.length > 0 ? outputLoader.item.text : "" + default property alias actionButtons: actions.data + Layout.fillWidth: true + implicitHeight: Math.max(150, inputColumn.implicitHeight) + color: isInput ? Appearance.colors.colLayer1 : Appearance.colors.colSurfaceContainer + radius: Appearance.rounding.normal + border.color: isInput ? Appearance.colors.colOutlineVariant : "transparent" + border.width: isInput ? 1 : 0 + + signal inputTextChanged(); // Signal emitted when text changes + + ColumnLayout { + id: inputColumn + anchors.fill: parent + spacing: 0 + + Loader { + id: inputLoader + active: root.isInput + visible: root.isInput + Layout.fillWidth: true + sourceComponent: StyledTextArea { // Input area + id: inputTextArea + placeholderText: root.placeholderText + wrapMode: TextEdit.Wrap + textFormat: TextEdit.PlainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + padding: 15 + background: null + onTextChanged: root.inputTextChanged() + } + } + + Loader { + id: outputLoader + active: !root.isInput + visible: !root.isInput + Layout.fillWidth: true + sourceComponent: StyledText { // Output area + id: outputTextArea + padding: 15 + wrapMode: Text.Wrap + font.pixelSize: Appearance.font.pixelSize.small + color: root.text.length > 0 ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext + text: root.text.length > 0 ? root.text : root.placeholderText + } + } + + Item { Layout.fillHeight: true } + + RowLayout { // Status row + Layout.fillWidth: true + Layout.margins: 10 + spacing: 10 + + Loader { + active: root.isInput + visible: root.isInput + Layout.leftMargin: 10 + sourceComponent: Text { + text: Translation.tr("%1 characters").arg(inputLoader.item.text.length) + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.smaller + } + } + Item { Layout.fillWidth: true } + ButtonGroup { + id: actions + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/BottomWidgetGroup.qml b/configs/quickshell/modules/sidebarRight/BottomWidgetGroup.qml new file mode 100644 index 0000000..3840116 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/BottomWidgetGroup.qml @@ -0,0 +1,241 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import qs.services +import "./calendar" +import "./todo" +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + clip: true + implicitHeight: collapsed ? collapsedBottomWidgetGroupRow.implicitHeight : bottomWidgetGroupRow.implicitHeight + property int selectedTab: Persistent.states.sidebar.bottomGroup.tab + property bool collapsed: Persistent.states.sidebar.bottomGroup.collapsed + property var tabs: [ + {"type": "calendar", "name": Translation.tr("Calendar"), "icon": "calendar_month", "widget": calendarWidget}, + {"type": "todo", "name": Translation.tr("To Do"), "icon": "done_outline", "widget": todoWidget} + ] + + 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 + onClicked: { + 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 + height: tabStack.height + 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 { + showToggledHighlight: false + toggled: root.selectedTab == index + buttonText: modelData.name + buttonIcon: modelData.icon + onClicked: { + root.selectedTab = index + Persistent.states.sidebar.bottomGroup.tab = index + } + } + } + } + // Collapse button + CalendarHeaderButton { + anchors.left: parent.left + anchors.top: parent.top + forceCircle: true + onClicked: { + root.setCollapsed(true) + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Content area + StackLayout { + id: tabStack + Layout.fillWidth: true + // Take the highest one, because the TODO list has no implicit height. This way the heigth of the calendar is used when it's initially loaded with the TODO list + height: Math.max(...tabStack.children.map(child => child.tabLoader?.implicitHeight || 0)) // TODO: make this less stupid + Layout.alignment: Qt.AlignVCenter + property int realIndex: root.selectedTab + property int animationDuration: Appearance.animation.elementMoveFast.duration * 1.5 + currentIndex: root.selectedTab + + // Switch the tab on halfway of the anim duration + Connections { + target: root + function onSelectedTabChanged() { + delayedStackSwitch.start() + tabStack.realIndex = root.selectedTab + } + } + Timer { + id: delayedStackSwitch + interval: tabStack.animationDuration / 2 + repeat: false + onTriggered: { + tabStack.currentIndex = root.selectedTab + } + } + + Repeater { + model: tabs + Item { // TODO: make behavior on y also act for the item that's switched to + id: tabItem + property int tabIndex: index + property string tabType: modelData.type + property int animDistance: 5 + property var tabLoader: tabLoader + // Opacity: show up only when being animated to + opacity: (tabStack.currentIndex === tabItem.tabIndex && tabStack.realIndex === tabItem.tabIndex) ? 1 : 0 + // Y: starts animating when user selects a different tab + y: (tabStack.realIndex === tabItem.tabIndex) ? 0 : (tabStack.realIndex < tabItem.tabIndex) ? animDistance : -animDistance + Behavior on opacity { NumberAnimation { duration: tabStack.animationDuration / 2; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: tabStack.animationDuration; easing.type: Easing.OutExpo } } + Loader { + id: tabLoader + anchors.fill: parent + sourceComponent: modelData.widget + focus: root.selectedTab === tabItem.tabIndex + } + } + } + } + } + + // Calendar component + Component { + id: calendarWidget + + CalendarWidget { + anchors.centerIn: parent + } + } + + // To Do component + Component { + id: todoWidget + TodoWidget { + anchors.fill: parent + anchors.margins: 5 + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/CenterWidgetGroup.qml b/configs/quickshell/modules/sidebarRight/CenterWidgetGroup.qml new file mode 100644 index 0000000..4aeef71 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/CenterWidgetGroup.qml @@ -0,0 +1,80 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import "./notifications" +import "./volumeMixer" +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + property int selectedTab: 0 + property var tabButtonList: [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}] + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + } + event.accepted = true; + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length + } else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length + } + event.accepted = true; + } + } + + ColumnLayout { + anchors.margins: 5 + anchors.fill: parent + spacing: 0 + + PrimaryTabBar { + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + currentIndex: root.selectedTab + onCurrentIndexChanged: { + tabBar.enableIndicatorAnimation = true + root.selectedTab = currentIndex + } + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + NotificationList {} + VolumeMixer {} + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/SidebarRight.qml b/configs/quickshell/modules/sidebarRight/SidebarRight.qml new file mode 100644 index 0000000..3d2c406 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/SidebarRight.qml @@ -0,0 +1,238 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "./quickToggles/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property int sidebarWidth: Appearance.sizes.sidebarWidth + property int sidebarPadding: 12 + property string settingsQmlPath: Quickshell.shellPath("settings.qml") + + 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: Item { + 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 + + RowLayout { + Layout.fillHeight: false + spacing: 10 + Layout.margins: 10 + Layout.topMargin: 5 + Layout.bottomMargin: 0 + + CustomIcon { + id: distroIcon + width: 25 + height: 25 + source: SystemInfo.distroIcon + colorize: true + color: Appearance.colors.colOnLayer0 + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Up %1").arg(DateTime.uptime) + textFormat: Text.MarkdownText + } + + Item { + Layout.fillWidth: true + } + + ButtonGroup { + QuickToggleButton { + toggled: false + buttonIcon: "restart_alt" + onClicked: { + Hyprland.dispatch("reload") + Quickshell.reload(true) + } + StyledToolTip { + content: Translation.tr("Reload Hyprland & Quickshell") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "settings" + onClicked: { + GlobalStates.sidebarRightOpen = false + Quickshell.execDetached(["qs", "-p", root.settingsQmlPath]) + } + StyledToolTip { + content: Translation.tr("Settings") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "power_settings_new" + onClicked: { + GlobalStates.sessionOpen = true + } + StyledToolTip { + content: Translation.tr("Session") + } + } + } + } + + ButtonGroup { + Layout.alignment: Qt.AlignHCenter + spacing: 5 + padding: 5 + color: Appearance.colors.colLayer1 + + NetworkToggle {} + BluetoothToggle {} + NightLight {} + GameMode {} + IdleInhibitor {} + EasyEffectsToggle {} + CloudflareWarp {} + } + + // Center widget group + CenterWidgetGroup { + focus: sidebarRoot.visible + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + } + + BottomWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + } + } + } + } + + + } + + IpcHandler { + target: "sidebarRight" + + function toggle(): void { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + + function close(): void { + GlobalStates.sidebarRightOpen = false; + } + + function open(): void { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "sidebarRightToggle" + description: "Toggles right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightOpen" + description: "Opens right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightClose" + description: "Closes right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = false; + } + } + +} diff --git a/configs/quickshell/modules/sidebarRight/calendar/CalendarDayButton.qml b/configs/quickshell/modules/sidebarRight/calendar/CalendarDayButton.qml new file mode 100644 index 0000000..ab1aca5 --- /dev/null +++ b/configs/quickshell/modules/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/configs/quickshell/modules/sidebarRight/calendar/CalendarHeaderButton.qml b/configs/quickshell/modules/sidebarRight/calendar/CalendarHeaderButton.qml new file mode 100644 index 0000000..6b5e5aa --- /dev/null +++ b/configs/quickshell/modules/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 { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/calendar/CalendarWidget.qml b/configs/quickshell/modules/sidebarRight/calendar/CalendarWidget.qml new file mode 100644 index 0000000..3af804e --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/calendar/CalendarWidget.qml @@ -0,0 +1,123 @@ +import qs.modules.common +import qs +import qs.modules.common.widgets +import "./calendar_layout.js" as CalendarLayout +import QtQuick +import QtQuick.Controls +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") + onClicked: { + monthShift = 0; + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: false + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + monthShift--; + } + contentItem: MaterialSymbol { + text: "chevron_left" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + 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/configs/quickshell/modules/sidebarRight/calendar/calendar_layout.js b/configs/quickshell/modules/sidebarRight/calendar/calendar_layout.js new file mode 100644 index 0000000..7f750b4 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/calendar/calendar_layout.js @@ -0,0 +1,115 @@ +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/configs/quickshell/modules/sidebarRight/notifications/NotificationList.qml b/configs/quickshell/modules/sidebarRight/notifications/NotificationList.qml new file mode 100644 index 0000000..882829b --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/notifications/NotificationList.qml @@ -0,0 +1,118 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +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 + Item { + anchors.fill: listview + + visible: opacity > 0 + opacity: (Notifications.list.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "notifications_active" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("No notifications") + } + } + } + + Item { + id: statusRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + Layout.fillWidth: true + implicitHeight: Math.max( + controls.implicitHeight, + statusText.implicitHeight + ) + + StyledText { + id: statusText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("%1 notifications").arg(Notifications.list.length) + + opacity: Notifications.list.length > 0 ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + ButtonGroup { + id: controls + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 5 + + NotificationStatusButton { + buttonIcon: "notifications_paused" + buttonText: Translation.tr("Silent") + toggled: Notifications.silent + onClicked: () => { + Notifications.silent = !Notifications.silent; + } + } + NotificationStatusButton { + buttonIcon: "clear_all" + buttonText: Translation.tr("Clear") + onClicked: () => { + Notifications.discardAllNotifications() + } + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/notifications/NotificationStatusButton.qml b/configs/quickshell/modules/sidebarRight/notifications/NotificationStatusButton.qml new file mode 100644 index 0000000..d6001a3 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/notifications/NotificationStatusButton.qml @@ -0,0 +1,43 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +GroupButton { + id: button + property string buttonText: "" + property string buttonIcon: "" + + baseWidth: content.implicitWidth + 10 * 2 + baseHeight: 30 + + 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 { + text: buttonIcon + iconSize: Appearance.font.pixelSize.large + color: button.colText + } + StyledText { + text: buttonText + font.pixelSize: Appearance.font.pixelSize.small + color: button.colText + } + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/BluetoothToggle.qml b/configs/quickshell/modules/sidebarRight/quickToggles/BluetoothToggle.qml new file mode 100644 index 0000000..5122bf0 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/BluetoothToggle.qml @@ -0,0 +1,36 @@ +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.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Bluetooth.bluetoothEnabled + buttonIcon: Bluetooth.bluetoothConnected ? "bluetooth_connected" : Bluetooth.bluetoothEnabled ? "bluetooth" : "bluetooth_disabled" + onClicked: { + toggleBluetooth.running = true + } + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.bluetooth}`]) + GlobalStates.sidebarRightOpen = false + } + Process { + id: toggleBluetooth + command: ["bash", "-c", `bluetoothctl power ${Bluetooth.bluetoothEnabled ? "off" : "on"}`] + onRunningChanged: { + if(!running) { + Bluetooth.update() + } + } + } + StyledToolTip { + content: Translation.tr("%1 | Right-click to configure").arg( + (Bluetooth.bluetoothEnabled && Bluetooth.bluetoothDeviceName.length > 0) ? + Bluetooth.bluetoothDeviceName : Translation.tr("Bluetooth")) + + } +} diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/CloudflareWarp.qml b/configs/quickshell/modules/sidebarRight/quickToggles/CloudflareWarp.qml new file mode 100644 index 0000000..39416ab --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/CloudflareWarp.qml @@ -0,0 +1,92 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +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 { + content: Translation.tr("Cloudflare WARP (1.1.1.1)") + } +} diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml b/configs/quickshell/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml new file mode 100644 index 0000000..5fbef04 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml @@ -0,0 +1,49 @@ +import qs.modules.common.widgets +import qs +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +QuickToggleButton { + id: root + toggled: false + visible: false + buttonIcon: "instant_mix" + + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["pkill", "easyeffects"]) + } else { + root.toggled = true + Quickshell.execDetached(["easyeffects", "--gapplication-service"]) + } + } + + altAction: () => { + Quickshell.execDetached(["easyeffects"]) + GlobalStates.sidebarRightOpen = false + } + + Process { + id: fetchAvailability + running: true + command: ["bash", "-c", "command -v easyeffects"] + onExited: (exitCode, exitStatus) => { + root.visible = exitCode === 0 + } + } + + Process { + id: fetchActiveState + running: true + command: ["pidof", "easyeffects"] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode === 0 + } + } + + StyledToolTip { + content: Translation.tr("EasyEffects | Right-click to configure") + } +} diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/GameMode.qml b/configs/quickshell/modules/sidebarRight/quickToggles/GameMode.qml new file mode 100644 index 0000000..1907080 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/GameMode.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +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 { + content: Translation.tr("Game mode") + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/IdleInhibitor.qml b/configs/quickshell/modules/sidebarRight/quickToggles/IdleInhibitor.qml new file mode 100644 index 0000000..949842d --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/IdleInhibitor.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import Quickshell.Io +import Quickshell + +QuickToggleButton { + id: root + toggled: false + buttonIcon: "coffee" + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["pkill", "wayland-idle"]) // pkill doesn't accept too long names + } else { + root.toggled = true + Quickshell.execDetached([`${Directories.scriptPath}/wayland-idle-inhibitor.py`]) + } + } + Process { + id: fetchActiveState + running: true + command: ["pidof", "wayland-idle-inhibitor.py"] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode === 0 + } + } + StyledToolTip { + content: Translation.tr("Keep system awake") + } +} diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/NetworkToggle.qml b/configs/quickshell/modules/sidebarRight/quickToggles/NetworkToggle.qml new file mode 100644 index 0000000..7492eae --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/NetworkToggle.qml @@ -0,0 +1,34 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "../" +import qs +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Network.networkName.length > 0 && Network.networkName != "lo" + buttonIcon: Network.materialSymbol + onClicked: { + toggleNetwork.running = true + } + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Network.ethernet ? Config.options.apps.networkEthernet : Config.options.apps.network}`]) + GlobalStates.sidebarRightOpen = false + } + Process { + id: toggleNetwork + command: ["bash", "-c", "nmcli radio wifi | grep -q enabled && nmcli radio wifi off || nmcli radio wifi on"] + onRunningChanged: { + if(!running) { + Network.update() + } + } + } + StyledToolTip { + content: Translation.tr("%1 | Right-click to configure").arg(Network.networkName) + } +} diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/NightLight.qml b/configs/quickshell/modules/sidebarRight/quickToggles/NightLight.qml new file mode 100644 index 0000000..f026512 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/NightLight.qml @@ -0,0 +1,28 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.widgets +import qs +import qs.services +import Quickshell.Io + +QuickToggleButton { + id: nightLightButton + property bool enabled: Hyprsunset.active + toggled: enabled + 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 { + content: Translation.tr("Night Light | Right-click to toggle Auto mode") + } +} diff --git a/configs/quickshell/modules/sidebarRight/quickToggles/QuickToggleButton.qml b/configs/quickshell/modules/sidebarRight/quickToggles/QuickToggleButton.qml new file mode 100644 index 0000000..25a53de --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/quickToggles/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: 20 + 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/configs/quickshell/modules/sidebarRight/todo/TaskList.qml b/configs/quickshell/modules/sidebarRight/todo/TaskList.qml new file mode 100644 index 0000000..4f6a430 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/todo/TaskList.qml @@ -0,0 +1,180 @@ +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 + + StyledFlickable { + id: flickable + anchors.fill: parent + contentHeight: columnLayout.height + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: flickable.width + height: flickable.height + radius: Appearance.rounding.small + } + } + + ColumnLayout { + id: columnLayout + width: parent.width + spacing: 0 + Repeater { + model: ScriptModel { + values: taskList + } + delegate: Item { + id: todoItem + property bool pendingDoneToggle: false + property bool pendingDelete: false + property bool enableHeightAnimation: false + + Layout.fillWidth: true + implicitHeight: todoItemRectangle.implicitHeight + todoListItemSpacing + height: implicitHeight + 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 + } + } + + function startAction() { + enableHeightAnimation = true + todoItem.implicitHeight = 0 + actionTimer.start() + } + + Timer { + id: actionTimer + interval: Appearance.animation.elementMoveFast.duration + repeat: false + onTriggered: { + if (todoItem.pendingDelete) { + Todo.deleteItem(modelData.originalIndex) + } else if (todoItem.pendingDoneToggle) { + if (!modelData.done) Todo.markDone(modelData.originalIndex) + else Todo.markUnfinished(modelData.originalIndex) + } + } + } + + 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 { + Layout.fillWidth: true // Needed for wrapping + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: todoListItemPadding + id: todoContentText + text: modelData.content + wrapMode: Text.Wrap + } + RowLayout { + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: todoListItemPadding + Item { + Layout.fillWidth: true + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDoneToggle = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: modelData.done ? "remove_done" : "check" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDelete = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "delete_forever" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + + } + // Bottom padding + Item { + implicitHeight: listBottomPadding + } + } + } + + 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 + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/todo/TodoItemActionButton.qml b/configs/quickshell/modules/sidebarRight/todo/TodoItemActionButton.qml new file mode 100644 index 0000000..b0a6e7b --- /dev/null +++ b/configs/quickshell/modules/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 { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/todo/TodoWidget.qml b/configs/quickshell/modules/sidebarRight/todo/TodoWidget.qml new file mode 100644 index 0000000..50c659a --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/todo/TodoWidget.qml @@ -0,0 +1,294 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property int currentTab: 0 + 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) { + currentTab = Math.min(currentTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + currentTab = Math.max(currentTab - 1, 0) + } + 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 + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: currentTab + onCurrentIndexChanged: currentTab = currentIndex + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + selected: (index == currentTab) + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + property bool enableIndicatorAnimation: false + Connections { + target: root + function onCurrentTabChanged() { + tabIndicator.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem.children[0].children[tabBar.currentIndex].tabContentWidth + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: Appearance.colors.colPrimary + radius: Appearance.rounding.full + + Behavior on x { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: currentTab + onCurrentIndexChanged: { + tabIndicator.enableIndicatorAnimation = true + currentTab = 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 + + contentItem: MaterialSymbol { + text: "add" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onPrimaryContainer + } + } + + 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 + root.currentTab = 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/configs/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml b/configs/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml new file mode 100644 index 0000000..a1e589d --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml @@ -0,0 +1,53 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire + +GroupButton { + id: button + required property bool input + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + clickedWidth: baseWidth + 30 + + 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 + } + } + } +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml b/configs/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml new file mode 100644 index 0000000..f9e0118 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml @@ -0,0 +1,282 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + + +Item { + id: root + property bool showDeviceSelector: false + property bool deviceSelectorInput + property int dialogMargins: 16 + property PwNode selectedDevice + readonly property list appPwNodes: Pipewire.nodes.values.filter((node) => { + // return node.type == "21" // Alternative, not as clean + return node.isSink && node.isStream + }) + + function showDeviceSelectorDialog(input: bool) { + root.selectedDevice = null + root.showDeviceSelector = true + root.deviceSelectorInput = input + } + + Keys.onPressed: (event) => { + // Close dialog on pressing Esc if open + if (event.key === Qt.Key_Escape && root.showDeviceSelector) { + root.showDeviceSelector = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + Item { + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { + id: listView + model: root.appPwNodes + clip: true + anchors { + fill: parent + topMargin: 10 + bottomMargin: 10 + } + spacing: 6 + + delegate: VolumeMixerEntry { + // Layout.fillWidth: true + anchors { + left: parent.left + right: parent.right + leftMargin: 10 + rightMargin: 10 + } + required property var modelData + node: modelData + } + } + + // Placeholder when list is empty + Item { + anchors.fill: listView + + visible: opacity > 0 + opacity: (root.appPwNodes.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "brand_awareness" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("No audio source") + } + } + } + } + + // Separator + Rectangle { + color: Appearance.m3colors.m3outlineVariant + implicitHeight: 1 + Layout.fillWidth: true + } + + + // Device selector + ButtonGroup { + id: deviceSelectorRowLayout + Layout.fillWidth: true + Layout.fillHeight: false + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: false + onClicked: root.showDeviceSelectorDialog(input) + } + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: true + onClicked: root.showDeviceSelectorDialog(input) + } + } + } + + // Device selector dialog + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showDeviceSelector ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + 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.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 30 + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: root.deviceSelectorInput ? Translation.tr("Select input device") : Translation.tr("Select output device") + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + StyledFlickable { + id: dialogFlickable + Layout.fillWidth: true + clip: true + implicitHeight: Math.min(scrimOverlay.height - dialogMargins * 8 - dialogTitle.height - dialogButtonsRowLayout.height, devicesColumnLayout.implicitHeight) + + contentHeight: devicesColumnLayout.implicitHeight + + ColumnLayout { + id: devicesColumnLayout + anchors.fill: parent + Layout.fillWidth: true + spacing: 0 + + Repeater { + model: ScriptModel { + values: Pipewire.nodes.values.filter(node => { + return !node.isStream && node.isSink !== root.deviceSelectorInput && node.audio + }) + } + + // This could and should be refractored, but all data becomes null when passed wtf + delegate: StyledRadioButton { + id: radioButton + required property var modelData + Layout.leftMargin: root.dialogMargins + Layout.rightMargin: root.dialogMargins + Layout.fillWidth: true + + description: modelData.description + checked: modelData.id === Pipewire.defaultAudioSink?.id + + Connections { + target: root + function onShowDeviceSelectorChanged() { + if(!root.showDeviceSelector) return; + radioButton.checked = (modelData.id === Pipewire.defaultAudioSink?.id) + } + } + + onCheckedChanged: { + if (checked) { + root.selectedDevice = modelData + } + } + } + } + Item { + implicitHeight: dialogMargins + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: { + root.showDeviceSelector = false + } + } + DialogButton { + buttonText: Translation.tr("OK") + onClicked: { + root.showDeviceSelector = false + if (root.selectedDevice) { + if (root.deviceSelectorInput) { + Pipewire.preferredDefaultAudioSource = root.selectedDevice + } else { + Pipewire.preferredDefaultAudioSink = root.selectedDevice + } + } + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/configs/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml b/configs/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml new file mode 100644 index 0000000..5ee3980 --- /dev/null +++ b/configs/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml @@ -0,0 +1,63 @@ +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: [node] + } + + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 6 + + Image { + property real size: slider.height * 0.9 + 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 = root.node.properties["application.name"] ?? (root.node.description != "" ? root.node.description : root.node.name); + const media = root.node.properties["media.name"]; + return media != undefined ? `${app} โ€ข ${media}` : app; + } + } + + StyledSlider { + id: slider + value: root.node.audio.volume + onValueChanged: root.node.audio.volume = value + } + } + } +} diff --git a/configs/quickshell/screenshot.qml b/configs/quickshell/screenshot.qml new file mode 100644 index 0000000..7cd46bc --- /dev/null +++ b/configs/quickshell/screenshot.qml @@ -0,0 +1,553 @@ +//@ 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 it smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +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.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +ShellRoot { + id: root + property string screenshotDir: Directories.screenshotTemp + property color overlayColor: "#77111111" + property color genericContentColor: Qt.alpha(root.overlayColor, 0.9) + property color genericContentForeground: "#ddffffff" + property color selectionBorderColor: "#ddf1f1f1" + property color selectionFillColor: "#33ffffff" + property color windowBorderColor: "#dda0c0da" + property color windowFillColor: "#22a0c0da" + property color imageBorderColor: "#ddf1d1ff" + property color imageFillColor: "#33f1d1ff" + property color onBorderColor: "#ff000000" + property real standardRounding: 4 + readonly property var windows: HyprlandData.windowList + readonly property var layers: HyprlandData.layers + readonly property real falsePositivePreventionRatio: 0.5 + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme(); + } + + component TargetRegion: Rectangle { + id: regionRect + 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 ? 3 : 1 + radius: root.standardRounding + + Rectangle { + id: regionLabelBackground + property real verticalPadding: 5 + property real horizontalPadding: 10 + radius: 10 + color: root.genericContentColor + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + anchors { + top: parent.top + left: parent.left + topMargin: regionRect.textPadding + leftMargin: regionRect.textPadding + } + implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2 + implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2 + RowLayout { + id: regionInfoRow + anchors.centerIn: parent + spacing: 8 + + Loader { + id: regionIconLoader + active: regionRect.showIcon + visible: active + sourceComponent: IconImage { + implicitSize: Appearance.font.pixelSize.larger + source: Quickshell.iconPath(AppSearch.guessIcon(regionRect.text), "image-missing") + } + } + + StyledText { + id: regionText + text: regionRect.text + color: root.genericContentForeground + } + } + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: panelWindow + required property var modelData + readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(modelData) + 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-${modelData.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 var mouseButton: null + property var imageRegions: [] + readonly property list windowRegions: filterWindowRegionsByLayers( + root.windows.filter(w => w.workspace.id === panelWindow.activeWorkspaceId), + panelWindow.layerRegions + ).map(window => { + return { + at: [window.at[0] - panelWindow.monitorOffsetX, window.at[1] - panelWindow.monitorOffsetY], + size: [window.size[0], window.size[1]], + class: window.class, + title: window.title, + } + }) + readonly property list layerRegions: { + const layersOfThisMonitor = root.layers[panelWindow.hyprlandMonitor.name] + const topLayers = layersOfThisMonitor.levels["2"] + const nonBarTopLayers = topLayers + .filter(layer => !(layer.namespace.includes(":bar") || 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] - panelWindow.monitorOffsetX, layer.at[1] - panelWindow.monitorOffsetY], + size: layer.size, + namespace: layer.namespace, + } + }); + return offsetAdjustedLayers; + } + + property real targetedRegionX: -1 + property real targetedRegionY: -1 + property real targetedRegionWidth: 0 + property real targetedRegionHeight: 0 + + 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); + } + + function updateTargetedRegion(x, y) { + // Image regions + const clickedRegion = panelWindow.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) { + panelWindow.targetedRegionX = clickedRegion.at[0]; + panelWindow.targetedRegionY = clickedRegion.at[1]; + panelWindow.targetedRegionWidth = clickedRegion.size[0]; + panelWindow.targetedRegionHeight = clickedRegion.size[1]; + return; + } + + // Layer regions + const clickedLayer = panelWindow.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) { + panelWindow.targetedRegionX = clickedLayer.at[0]; + panelWindow.targetedRegionY = clickedLayer.at[1]; + panelWindow.targetedRegionWidth = clickedLayer.size[0]; + panelWindow.targetedRegionHeight = clickedLayer.size[1]; + return; + } + + // Window regions + const clickedWindow = panelWindow.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) { + panelWindow.targetedRegionX = clickedWindow.at[0]; + panelWindow.targetedRegionY = clickedWindow.at[1]; + panelWindow.targetedRegionWidth = clickedWindow.size[0]; + panelWindow.targetedRegionHeight = clickedWindow.size[1]; + return; + } + + panelWindow.targetedRegionX = -1; + panelWindow.targetedRegionY = -1; + panelWindow.targetedRegionWidth = 0; + panelWindow.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) + + visible: false + screen: modelData + WlrLayershell.namespace: "quickshell:screenshot" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + anchors { + left: true + right: true + top: true + bottom: true + } + + Process { + id: screenshotProcess + running: true + command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(modelData.name)}' '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}'`] + onExited: (exitCode, exitStatus) => { + panelWindow.visible = true; + imageDetectionProcess.running = true; + } + } + + Process { + id: imageDetectionProcess + command: ["bash", "-c", `${Directories.scriptPath}/images/find_regions.py ` + + `--hyprctl ` + + `--image '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}' ` + + `--max-width ${Math.round(panelWindow.screen.width * root.falsePositivePreventionRatio)} ` + + `--max-height ${Math.round(panelWindow.screen.height * root.falsePositivePreventionRatio)} `] + stdout: StdioCollector { + id: imageDimensionCollector + onStreamFinished: { + imageRegions = filterImageRegions( + JSON.parse(imageDimensionCollector.text), + panelWindow.windowRegions + ); + } + } + } + + Process { + id: snipProc + function snip() { + if (panelWindow.regionWidth <= 0 || panelWindow.regionHeight <= 0) { + console.warn("Invalid region size, skipping snip."); + Qt.quit(); + } + snipProc.startDetached(); + Qt.quit(); + } + command: ["bash", "-c", + `magick ${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)} ` + + `-crop ${panelWindow.regionWidth * panelWindow.monitorScale}x${panelWindow.regionHeight * panelWindow.monitorScale}+${panelWindow.regionX * panelWindow.monitorScale}+${panelWindow.regionY * panelWindow.monitorScale} - ` + + `| ${panelWindow.mouseButton === Qt.LeftButton ? "wl-copy" : "swappy -f -"}`] + } + + ScreencopyView { + anchors.fill: parent + live: false + captureSource: modelData + + focus: panelWindow.visible + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + Qt.quit(); + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.CrossCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + + // Controls + onPressed: mouse => { + panelWindow.dragStartX = mouse.x; + panelWindow.dragStartY = mouse.y; + panelWindow.draggingX = mouse.x; + panelWindow.draggingY = mouse.y; + panelWindow.dragging = true; + panelWindow.mouseButton = mouse.button; + } + onReleased: mouse => { + // Detect if it was a click + + // Image regions + if (panelWindow.draggingX === panelWindow.dragStartX && panelWindow.draggingY === panelWindow.dragStartY) { + if (panelWindow.targetedRegionX >= 0 && panelWindow.targetedRegionY >= 0) { + panelWindow.regionX = panelWindow.targetedRegionX; + panelWindow.regionY = panelWindow.targetedRegionY; + panelWindow.regionWidth = panelWindow.targetedRegionWidth; + panelWindow.regionHeight = panelWindow.targetedRegionHeight; + } + } + snipProc.snip(); + } + onPositionChanged: mouse => { + if (panelWindow.dragging) { + panelWindow.draggingX = mouse.x; + panelWindow.draggingY = mouse.y; + panelWindow.dragDiffX = mouse.x - panelWindow.dragStartX; + panelWindow.dragDiffY = mouse.y - panelWindow.dragStartY; + } + panelWindow.updateTargetedRegion(mouse.x, mouse.y); + } + + // Overlay to darken screen + Rectangle { // Base + id: overlayRect + z: 0 + anchors.fill: parent + color: root.overlayColor + layer.enabled: true + } + Rectangle { + // TODO: Make this mask the base instead of just overlaying a border + z: 1 + anchors { + left: parent.left + top: parent.top + leftMargin: panelWindow.regionX + topMargin: panelWindow.regionY + } + width: panelWindow.regionWidth + height: panelWindow.regionHeight + color: "transparent" + border.color: root.selectionBorderColor + border.width: 2 + radius: root.standardRounding + } + + // Instructions + Rectangle { + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: (Appearance.sizes.barHeight - implicitHeight) / 2 + } + + opacity: panelWindow.dragging ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + color: root.genericContentColor + radius: 10 + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + implicitWidth: instructionsRow.implicitWidth + 10 * 2 + implicitHeight: instructionsRow.implicitHeight + 5 * 2 + + RowLayout { + id: instructionsRow + anchors.centerIn: parent + Item { + Layout.fillHeight: true + implicitWidth: screenshotRegionIcon.implicitWidth + MaterialSymbol { + id: screenshotRegionIcon + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + text: "screenshot_region" + color: root.genericContentForeground + } + } + StyledText { + text: Translation.tr("Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit") + color: root.genericContentForeground + } + } + } + + // Window regions + Repeater { + model: ScriptModel { + values: panelWindow.windowRegions + } + delegate: TargetRegion { + z: 2 + required property var modelData + showIcon: true + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: `${modelData.class}` + radius: Appearance.rounding.windowRounding + } + } + + // Layer regions + Repeater { + model: ScriptModel { + values: panelWindow.layerRegions + } + delegate: TargetRegion { + z: 3 + required property var modelData + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: `${modelData.namespace}` + radius: Appearance.rounding.windowRounding + } + } + + // Image regions + Repeater { + model: ScriptModel { + values: Config.options.screenshotTool.showContentRegions ? panelWindow.imageRegions : [] + } + delegate: TargetRegion { + z: 4 + required property var modelData + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.imageBorderColor + fillColor: targeted ? root.imageFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: "Content region" + } + } + } + } + } + } +} diff --git a/configs/quickshell/scripts/ai/show-installed-ollama-models.sh b/configs/quickshell/scripts/ai/show-installed-ollama-models.sh new file mode 100755 index 0000000..e56ac76 --- /dev/null +++ b/configs/quickshell/scripts/ai/show-installed-ollama-models.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Get the list, skip the header, and extract the first column (model names) +model_names=$(ollama list | tail -n +2 | awk '{print $1}') + +# Build a JSON array +json_array="[" +for name in $model_names; do + json_array+="\"$name\"," +done + +# Remove trailing comma and close the array +json_array="${json_array%,}]" + +# Output the JSON array +echo "$json_array" diff --git a/configs/quickshell/scripts/cava/raw_output_config.txt b/configs/quickshell/scripts/cava/raw_output_config.txt new file mode 100644 index 0000000..7760e4e --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/scripts/colors/applycolor.sh b/configs/quickshell/scripts/colors/applycolor.sh new file mode 100755 index 0000000..a539cf8 --- /dev/null +++ b/configs/quickshell/scripts/colors/applycolor.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +term_alpha=60 + +# Check transparency setting and adjust term_alpha accordingly +if [ -f "$STATE_DIR/user/generated/terminal/transparency" ]; then + transparency_mode=$(cat "$STATE_DIR/user/generated/terminal/transparency") + if [ "$transparency_mode" = "opaque" ]; then + term_alpha=100 + else + # For transparent mode, use the opacity setting from config or default to 80 + if [ -f "$STATE_DIR/user/generated/terminal/opacity" ]; then + term_alpha=$(cat "$STATE_DIR/user/generated/terminal/opacity") + else + term_alpha=80 + fi + fi +fi +# sleep 0 # idk i wanted some delay or colors dont get applied properly +if [ ! -d "$STATE_DIR"/user/generated ]; then + mkdir -p "$STATE_DIR"/user/generated +fi +cd "$CONFIG_DIR" || exit + +colornames='' +colorstrings='' +colorlist=() +colorvalues=() + +SCSS_FILE="$STATE_DIR/user/generated/material_colors.scss" +JSON_FILE="$STATE_DIR/user/generated/colors.json" + +if [ -s "$SCSS_FILE" ]; then + colornames=$(cut -d: -f1 "$SCSS_FILE") + colorstrings=$(cut -d: -f2 "$SCSS_FILE" | cut -d ' ' -f2 | cut -d ";" -f1) +elif [ -s "$JSON_FILE" ]; then + # Fallback: read from colors.json when scss generation failed + colornames=$(jq -r 'to_entries[] | "$\(.key)"' "$JSON_FILE") + colorstrings=$(jq -r 'to_entries[] | .value' "$JSON_FILE") +fi +IFS=$'\n' +colorlist=($colornames) # Array of color names +colorvalues=($colorstrings) # Array of color values +export colorlist colorvalues + +apply_term() { + # Check if terminal escape sequence template exists + if [ ! -f "$SCRIPT_DIR/terminal/sequences.txt" ]; then + echo "Template file not found for Terminal. Skipping that." + return + fi + # Copy template + mkdir -p "$STATE_DIR"/user/generated/terminal + cp "$SCRIPT_DIR/terminal/sequences.txt" "$STATE_DIR"/user/generated/terminal/sequences.txt + # Apply colors + for i in "${!colorlist[@]}"; do + sed -i "s/${colorlist[$i]} #/${colorvalues[$i]#\#}/g" "$STATE_DIR"/user/generated/terminal/sequences.txt + done + + sed -i "s/\$alpha/$term_alpha/g" "$STATE_DIR/user/generated/terminal/sequences.txt" + + # Send escape sequences to all terminals + for file in /dev/pts/*; do + if [[ $file =~ ^/dev/pts/[0-9]+$ ]]; then + cat "$STATE_DIR"/user/generated/terminal/sequences.txt >"$file" 2>/dev/null & + fi + done + wait +} + +apply_qt() { + sh "$CONFIG_DIR/scripts/kvantum/materialQT.sh" # generate kvantum theme + python "$CONFIG_DIR/scripts/kvantum/changeAdwColors.py" # apply config colors +} + +apply_foot() { + # Check if foot template exists + if [ ! -f "$SCRIPT_DIR/foot/foot.ini" ]; then + echo "Template file not found for Foot. Skipping that." + return + fi + + # Copy template + mkdir -p "$STATE_DIR/user/generated/foot" + cp "$SCRIPT_DIR/foot/foot.ini" "$STATE_DIR/user/generated/foot/foot.ini" + + # Apply colors (skip non-color variables like $darkmode, $transparent) + # Sort by variable name length (longest first) to avoid partial replacement issues + # e.g., $term10 must be replaced before $term1 to avoid "AC72FF0" malformed colors + filtered_indices=() + for i in "${!colorlist[@]}"; do + # Skip variables that don't start with color names or contain special values + if [[ "${colorlist[$i]}" == *"darkmode"* ]] || [[ "${colorlist[$i]}" == *"transparent"* ]] || [[ "${colorlist[$i]}" == *"palette"* ]]; then + continue + fi + filtered_indices+=($i) + done + + # Sort indices by variable name length (longest first) + IFS=$'\n' sorted_indices=($(for idx in "${filtered_indices[@]}"; do + echo "${#colorlist[$idx]} $idx" + done | sort -rn | cut -d' ' -f2)) + + for i in "${sorted_indices[@]}"; do + # Escape the $ in the color name for sed + color_name="${colorlist[$i]//$/\\$}" + # Remove # prefix from color value for foot compatibility + color_value="${colorvalues[$i]}" + color_value="${color_value#\#}" # Remove leading # if present + sed -i "s/${color_name}/${color_value}/g" "$STATE_DIR/user/generated/foot/foot.ini" + done + + # After all color replacements, ensure no # prefixes remain in color values + # This handles any edge cases where # prefixes weren't removed properly + sed -i 's/=\s*#\([0-9A-Fa-f]\{6\}\)/=\1/g' "$STATE_DIR/user/generated/foot/foot.ini" + + # Convert term_alpha percentage to decimal for foot (e.g., 70 -> 0.7) + foot_alpha=$(echo "scale=2; $term_alpha / 100" | bc) + # Use line number replacement to avoid sed pattern issues + sed -i "/^alpha=/c\\alpha=$foot_alpha" "$STATE_DIR/user/generated/foot/foot.ini" + + # Copy to actual config location + mkdir -p "$XDG_CONFIG_HOME/foot" + cp "$STATE_DIR/user/generated/foot/foot.ini" "$XDG_CONFIG_HOME/foot/foot.ini" +} + +apply_fuzzel() { + # Check if fuzzel template exists + if [ ! -f "$SCRIPT_DIR/fuzzel/fuzzel.ini" ]; then + echo "Template file not found for Fuzzel. Skipping that." + return + fi + + # Copy template + mkdir -p "$STATE_DIR/user/generated/fuzzel" + cp "$SCRIPT_DIR/fuzzel/fuzzel.ini" "$STATE_DIR/user/generated/fuzzel/fuzzel.ini" + + # Apply colors (skip non-color variables like $darkmode, $transparent) + # Sort by variable name length (longest first) to avoid partial replacement issues + filtered_indices=() + for i in "${!colorlist[@]}"; do + # Skip variables that don't start with color names or contain special values + if [[ "${colorlist[$i]}" == *"darkmode"* ]] || [[ "${colorlist[$i]}" == *"transparent"* ]] || [[ "${colorlist[$i]}" == *"palette"* ]]; then + continue + fi + filtered_indices+=($i) + done + + # Sort indices by variable name length (longest first) + IFS=$'\n' sorted_indices=($(for idx in "${filtered_indices[@]}"; do + echo "${#colorlist[$idx]} $idx" + done | sort -rn | cut -d' ' -f2)) + + for i in "${sorted_indices[@]}"; do + # Escape the $ in the color name for sed + color_name="${colorlist[$i]//$/\\$}" + # Keep # prefix for fuzzel (unlike foot) + color_value="${colorvalues[$i]}" + sed -i "s/${color_name}/${color_value}/g" "$STATE_DIR/user/generated/fuzzel/fuzzel.ini" + done + + # Copy to actual config location + mkdir -p "$XDG_CONFIG_HOME/fuzzel" + cp "$STATE_DIR/user/generated/fuzzel/fuzzel.ini" "$XDG_CONFIG_HOME/fuzzel/fuzzel.ini" +} + +# Function to convert hex color to RGB values +dehex() { + local hex="$1" + # Remove # if present + hex="${hex#\#}" + # Convert to RGB + printf "%d, %d, %d" "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}" +} + +apply_wofi() { + # Check if wofi template exists + if [ ! -f "$SCRIPT_DIR/wofi/style.css" ]; then + echo "Template file not found for Wofi colors. Skipping that." + return + fi + + # Copy template + mkdir -p "$XDG_CONFIG_HOME/wofi" + cp "$SCRIPT_DIR/wofi/style.css" "$XDG_CONFIG_HOME/wofi/style_new.css" + chmod +w "$XDG_CONFIG_HOME/wofi/style_new.css" + + # Apply colors (skip non-color variables like $darkmode, $transparent) + # Sort by variable name length (longest first) to avoid partial replacement issues + filtered_indices=() + for i in "${!colorlist[@]}"; do + # Skip variables that don't start with color names or contain special values + if [[ "${colorlist[$i]}" == *"darkmode"* ]] || [[ "${colorlist[$i]}" == *"transparent"* ]] || [[ "${colorlist[$i]}" == *"palette"* ]]; then + continue + fi + filtered_indices+=($i) + done + + # Sort indices by variable name length (longest first) + IFS=$'\n' sorted_indices=($(for idx in "${filtered_indices[@]}"; do + echo "${#colorlist[$idx]} $idx" + done | sort -rn | cut -d' ' -f2)) + + # Apply hex colors (without # prefix) - use {{ $variable }} syntax + for i in "${sorted_indices[@]}"; do + # Remove $ prefix for the template pattern + color_name="${colorlist[$i]#\$}" + # Remove # prefix for wofi + color_value="${colorvalues[$i]}" + color_value="${color_value#\#}" + sed -i "s/{{ \$${color_name} }}/${color_value}/g" "$XDG_CONFIG_HOME/wofi/style_new.css" + done + + # Apply RGB colors - use {{ $variable-rgb }} syntax + for i in "${sorted_indices[@]}"; do + # Remove $ prefix for the template pattern + color_name="${colorlist[$i]#\$}" + # Convert to RGB + dehexed=$(dehex "${colorvalues[$i]}") + sed -i "s/{{ \$${color_name}-rgb }}/${dehexed}/g" "$XDG_CONFIG_HOME/wofi/style_new.css" + done + + mv "$XDG_CONFIG_HOME/wofi/style_new.css" "$XDG_CONFIG_HOME/wofi/style.css" +} + +# Check if terminal theming is enabled in config +CONFIG_FILE="$XDG_CONFIG_HOME/illogical-impulse/config.json" +if [ -f "$CONFIG_FILE" ]; then + enable_terminal=$(jq -r '.appearance.wallpaperTheming.enableTerminal' "$CONFIG_FILE") + if [ "$enable_terminal" = "true" ]; then + apply_term + apply_foot + apply_fuzzel + apply_wofi + fi +else + echo "Config file not found at $CONFIG_FILE. Applying terminal theming by default." + apply_term + apply_foot + apply_fuzzel + apply_wofi +fi + +# apply_qt & # Qt theming is already handled by kde-material-colors diff --git a/configs/quickshell/scripts/colors/generate_colors_material.py b/configs/quickshell/scripts/colors/generate_colors_material.py new file mode 100755 index 0000000..db6b166 --- /dev/null +++ b/configs/quickshell/scripts/colors/generate_colors_material.py @@ -0,0 +1,181 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" +import argparse +import math +import json +from PIL import Image +from materialyoucolor.quantize import QuantizeCelebi +from materialyoucolor.score.score import Score +from materialyoucolor.hct import Hct +from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors +from materialyoucolor.utils.color_utils import (rgba_from_argb, argb_from_rgb, argb_from_rgba) +from materialyoucolor.utils.math_utils import (sanitize_degrees_double, difference_degrees, rotation_direction) + +parser = argparse.ArgumentParser(description='Color generation script') +parser.add_argument('--path', type=str, default=None, help='generate colorscheme from image') +parser.add_argument('--size', type=int , default=128 , help='bitmap image size') +parser.add_argument('--color', type=str, default=None, help='generate colorscheme from color') +parser.add_argument('--mode', type=str, choices=['dark', 'light'], default='dark', help='dark or light mode') +parser.add_argument('--scheme', type=str, default='vibrant', help='material scheme to use') +parser.add_argument('--smart', action='store_true', default=False, help='decide scheme type based on image color') +parser.add_argument('--transparency', type=str, choices=['opaque', 'transparent'], default='opaque', help='enable transparency') +parser.add_argument('--termscheme', type=str, default=None, help='JSON file containg the terminal scheme for generating term colors') +parser.add_argument('--harmony', type=float , default=0.8, help='(0-1) Color hue shift towards accent') +parser.add_argument('--harmonize_threshold', type=float , default=100, help='(0-180) Max threshold angle to limit color hue shift') +parser.add_argument('--term_fg_boost', type=float , default=0.35, help='Make terminal foreground more different from the background') +parser.add_argument('--blend_bg_fg', action='store_true', default=False, help='Shift terminal background or foreground towards accent') +parser.add_argument('--cache', type=str, default=None, help='file path to store the generated color') +parser.add_argument('--debug', action='store_true', default=False, help='debug mode') +args = parser.parse_args() + +rgba_to_hex = lambda rgba: "#{:02X}{:02X}{:02X}".format(rgba[0], rgba[1], rgba[2]) +argb_to_hex = lambda argb: "#{:02X}{:02X}{:02X}".format(*map(round, rgba_from_argb(argb))) +hex_to_argb = lambda hex_code: argb_from_rgb(int(hex_code[1:3], 16), int(hex_code[3:5], 16), int(hex_code[5:], 16)) +display_color = lambda rgba : "\x1B[38;2;{};{};{}m{}\x1B[0m".format(rgba[0], rgba[1], rgba[2], "\x1b[7m \x1b[7m") + +def calculate_optimal_size (width: int, height: int, bitmap_size: int) -> (int, int): + image_area = width * height; + bitmap_area = bitmap_size ** 2 + scale = math.sqrt(bitmap_area/image_area) if image_area > bitmap_area else 1 + new_width = round(width * scale) + new_height = round(height * scale) + if new_width == 0: + new_width = 1 + if new_height == 0: + new_height = 1 + return new_width, new_height + +def harmonize (design_color: int, source_color: int, threshold: float = 35, harmony: float = 0.5) -> int: + from_hct = Hct.from_int(design_color) + to_hct = Hct.from_int(source_color) + difference_degrees_ = difference_degrees(from_hct.hue, to_hct.hue) + rotation_degrees = min(difference_degrees_ * harmony, threshold) + output_hue = sanitize_degrees_double( + from_hct.hue + rotation_degrees * rotation_direction(from_hct.hue, to_hct.hue) + ) + return Hct.from_hct(output_hue, from_hct.chroma, from_hct.tone).to_int() + +def boost_chroma_tone (argb: int, chroma: float = 1, tone: float = 1) -> int: + hct = Hct.from_int(argb) + return Hct.from_hct(hct.hue, hct.chroma * chroma, hct.tone * tone).to_int() + +darkmode = (args.mode == 'dark') +transparent = (args.transparency == 'transparent') + +if args.path is not None: + image = Image.open(args.path) + + if image.format == "GIF": + image.seek(1) + + if image.mode in ["L", "P"]: + image = image.convert('RGB') + wsize, hsize = image.size + wsize_new, hsize_new = calculate_optimal_size(wsize, hsize, args.size) + if wsize_new < wsize or hsize_new < hsize: + image = image.resize((wsize_new, hsize_new), Image.Resampling.BICUBIC) + colors = QuantizeCelebi(list(image.getdata()), 128) + argb = Score.score(colors)[0] + + if args.cache is not None: + with open(args.cache, 'w') as file: + file.write(argb_to_hex(argb)) + hct = Hct.from_int(argb) + if(args.smart): + if(hct.chroma < 20): + args.scheme = 'neutral' +elif args.color is not None: + argb = hex_to_argb(args.color) + hct = Hct.from_int(argb) + +if args.scheme == 'scheme-fruit-salad': + from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad as Scheme +elif args.scheme == 'scheme-expressive': + from materialyoucolor.scheme.scheme_expressive import SchemeExpressive as Scheme +elif args.scheme == 'scheme-monochrome': + from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome as Scheme +elif args.scheme == 'scheme-rainbow': + from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow as Scheme +elif args.scheme == 'scheme-tonal-spot': + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme +elif args.scheme == 'scheme-neutral': + from materialyoucolor.scheme.scheme_neutral import SchemeNeutral as Scheme +elif args.scheme == 'scheme-fidelity': + from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity as Scheme +elif args.scheme == 'scheme-content': + from materialyoucolor.scheme.scheme_content import SchemeContent as Scheme +elif args.scheme == 'scheme-vibrant': + from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant as Scheme +else: + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme +# Generate +scheme = Scheme(hct, darkmode, 0.0) + +material_colors = {} +term_colors = {} + +for color in vars(MaterialDynamicColors).keys(): + color_name = getattr(MaterialDynamicColors, color) + if hasattr(color_name, "get_hct"): + rgba = color_name.get_hct(scheme).to_rgba() + material_colors[color] = rgba_to_hex(rgba) + +# Extended material +if darkmode == True: + material_colors['success'] = '#B5CCBA' + material_colors['onSuccess'] = '#213528' + material_colors['successContainer'] = '#374B3E' + material_colors['onSuccessContainer'] = '#D1E9D6' +else: + material_colors['success'] = '#4F6354' + material_colors['onSuccess'] = '#FFFFFF' + material_colors['successContainer'] = '#D1E8D5' + material_colors['onSuccessContainer'] = '#0C1F13' + +# Terminal Colors +if args.termscheme is not None: + with open(args.termscheme, 'r') as f: + json_termscheme = f.read() + term_source_colors = json.loads(json_termscheme)['dark' if darkmode else 'light'] + + primary_color_argb = hex_to_argb(material_colors['primary_paletteKeyColor']) + for color, val in term_source_colors.items(): + if(args.scheme == 'monochrome') : + term_colors[color] = val + continue + if args.blend_bg_fg and color == "term0": + harmonized = boost_chroma_tone(hex_to_argb(material_colors['surfaceContainerLow']), 1.2, 0.95) + elif args.blend_bg_fg and color == "term15": + harmonized = boost_chroma_tone(hex_to_argb(material_colors['onSurface']), 3, 1) + else: + harmonized = harmonize(hex_to_argb(val), primary_color_argb, args.harmonize_threshold, args.harmony) + harmonized = boost_chroma_tone(harmonized, 1, 1 + (args.term_fg_boost * (1 if darkmode else -1))) + term_colors[color] = argb_to_hex(harmonized) + +if args.debug == False: + print(f"$darkmode: {darkmode};") + print(f"$transparent: {transparent};") + for color, code in material_colors.items(): + print(f"${color}: {code};") + for color, code in term_colors.items(): + print(f"${color}: {code};") +else: + if args.path is not None: + print('\n--------------Image properties-----------------') + print(f"Image size: {wsize} x {hsize}") + print(f"Resized image: {wsize_new} x {hsize_new}") + print('\n---------------Selected color------------------') + print(f"Dark mode: {darkmode}") + print(f"Scheme: {args.scheme}") + print(f"Accent color: {display_color(rgba_from_argb(argb))} {argb_to_hex(argb)}") + print(f"HCT: {hct.hue:.2f} {hct.chroma:.2f} {hct.tone:.2f}") + print('\n---------------Material colors-----------------') + for color, code in material_colors.items(): + rgba = rgba_from_argb(hex_to_argb(code)) + print(f"{color.ljust(32)} : {display_color(rgba)} {code}") + print('\n----------Harmonize terminal colors------------') + for color, code in term_colors.items(): + rgba = rgba_from_argb(hex_to_argb(code)) + code_source = term_source_colors[color] + rgba_source = rgba_from_argb(hex_to_argb(code_source)) + print(f"{color.ljust(6)} : {display_color(rgba_source)} {code_source} --> {display_color(rgba)} {code}") + print('-----------------------------------------------') diff --git a/configs/quickshell/scripts/colors/random_konachan_wall.sh b/configs/quickshell/scripts/colors/random_konachan_wall.sh new file mode 100755 index 0000000..a4685da --- /dev/null +++ b/configs/quickshell/scripts/colors/random_konachan_wall.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +get_pictures_dir() { + if command -v xdg-user-dir &> /dev/null; then + xdg-user-dir PICTURES + return + fi + + local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" + if [ -f "$config_file" ]; then + local pictures_path + pictures_path=$(source "$config_file" >/dev/null 2>&1; echo "$XDG_PICTURES_DIR") + echo "${pictures_path/#\$HOME/$HOME}" + return + fi + + echo "$HOME/Pictures" +} + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +PICTURES_DIR=$(get_pictures_dir) +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mkdir -p "$PICTURES_DIR/Wallpapers" +page=$((1 + RANDOM % 1000)); +response=$(curl "https://konachan.net/post.json?tags=rating%3Asafe&limit=1&page=$page") +link=$(echo "$response" | jq '.[0].file_url' -r); +ext=$(echo "$link" | awk -F. '{print $NF}') +downloadPath="$PICTURES_DIR/Wallpapers/konachan_random_image.$ext" +illogicalImpulseConfigPath="$HOME/.config/illogical-impulse/config.json" +currentWallpaperPath=$(jq -r '.background.wallpaperPath' $illogicalImpulseConfigPath) +if [ "$downloadPath" == "$currentWallpaperPath" ]; then + downloadPath="$PICTURES_DIR/Wallpapers/konachan_random_image-1.$ext" +fi +curl "$link" -o "$downloadPath" +"$SCRIPT_DIR/switchwall.sh" --image "$downloadPath" diff --git a/configs/quickshell/scripts/colors/scheme_for_image.py b/configs/quickshell/scripts/colors/scheme_for_image.py new file mode 100755 index 0000000..8aa0ccb --- /dev/null +++ b/configs/quickshell/scripts/colors/scheme_for_image.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import sys +import cv2 +import numpy as np + +# Allowed scheme types +SCHEMES = [ + "scheme-content", + "scheme-expressive", + "scheme-fidelity", + "scheme-fruit-salad", + "scheme-monochrome", + "scheme-neutral", + "scheme-rainbow", + "scheme-tonal-spot" +] + +def image_colorfulness(image): + # Based on Hasler and Sรผsstrunk's colorfulness metric + (B, G, R) = cv2.split(image.astype("float")) + rg = np.absolute(R - G) + yb = np.absolute(0.5 * (R + G) - B) + std_rg = np.std(rg) + std_yb = np.std(yb) + mean_rg = np.mean(rg) + mean_yb = np.mean(yb) + colorfulness = np.sqrt(std_rg ** 2 + std_yb ** 2) + (0.3 * np.sqrt(mean_rg ** 2 + mean_yb ** 2)) + return colorfulness + +# scheme-content respects the image's colors very well, but it might +# look too saturated, so we only use it for not very colorful images to be safe +def pick_scheme(colorfulness): + if colorfulness < 10: + # return "scheme-monochrome" + return "scheme-content" + elif colorfulness < 20: + return "scheme-content" + elif colorfulness < 50: + return "scheme-neutral" + else: + return "scheme-tonal-spot" + +def main(): + colorfulness_mode = False + args = sys.argv[1:] + if '--colorfulness' in args: + colorfulness_mode = True + args.remove('--colorfulness') + if len(args) < 1: + print("scheme-tonal-spot") + sys.exit(1) + img_path = args[0] + img = cv2.imread(img_path) + if img is None: + print("scheme-tonal-spot") + sys.exit(1) + colorfulness = image_colorfulness(img) + if colorfulness_mode: + print(f"{colorfulness}") + else: + scheme = pick_scheme(colorfulness) + print(scheme) + +if __name__ == "__main__": + main() diff --git a/configs/quickshell/scripts/colors/switchwall.sh b/configs/quickshell/scripts/colors/switchwall.sh new file mode 100755 index 0000000..31db37b --- /dev/null +++ b/configs/quickshell/scripts/colors/switchwall.sh @@ -0,0 +1,403 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHELL_CONFIG_FILE="$XDG_CONFIG_HOME/illogical-impulse/config.json" +MATUGEN_DIR="$XDG_CONFIG_HOME/matugen" +terminalscheme="$SCRIPT_DIR/terminal/scheme-base.json" + +handle_kde_material_you_colors() { + # Check if Qt app theming is enabled in config + if [ -f "$SHELL_CONFIG_FILE" ]; then + enable_qt_apps=$(jq -r '.appearance.wallpaperTheming.enableQtApps' "$SHELL_CONFIG_FILE") + if [ "$enable_qt_apps" == "false" ]; then + return + fi + fi + + # Map $type_flag to allowed scheme variants for kde-material-you-colors-wrapper.sh + local kde_scheme_variant="" + case "$type_flag" in + scheme-content|scheme-expressive|scheme-fidelity|scheme-fruit-salad|scheme-monochrome|scheme-neutral|scheme-rainbow|scheme-tonal-spot) + kde_scheme_variant="$type_flag" + ;; + *) + kde_scheme_variant="scheme-tonal-spot" # default + ;; + esac + "$XDG_CONFIG_HOME"/matugen/templates/kde/kde-material-you-colors-wrapper.sh --scheme-variant "$kde_scheme_variant" +} + +pre_process() { + local mode_flag="$1" + # Set GNOME color-scheme if mode_flag is dark or light + if [[ "$mode_flag" == "dark" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3-dark' + elif [[ "$mode_flag" == "light" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-light' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3' + fi + + if [ ! -d "$CACHE_DIR"/user/generated ]; then + mkdir -p "$CACHE_DIR"/user/generated + fi +} + +post_process() { + local screen_width="$1" + local screen_height="$2" + local wallpaper_path="$3" + + + handle_kde_material_you_colors & + + # Determine the largest region on the wallpaper that's sufficiently un-busy to put widgets in + # if [ ! -f "$MATUGEN_DIR/scripts/least_busy_region.py" ]; then + # echo "Error: least_busy_region.py script not found in $MATUGEN_DIR/scripts/" + # else + # "$MATUGEN_DIR/scripts/least_busy_region.py" \ + # --screen-width "$screen_width" --screen-height "$screen_height" \ + # --width 300 --height 200 \ + # "$wallpaper_path" > "$STATE_DIR"/user/generated/wallpaper/least_busy_region.json + # fi +} + +check_and_prompt_upscale() { + local img="$1" + min_width_desired="$(hyprctl monitors -j | jq '([.[].width] | max)' | xargs)" # max monitor width + min_height_desired="$(hyprctl monitors -j | jq '([.[].height] | max)' | xargs)" # max monitor height + + if command -v identify &>/dev/null && [ -f "$img" ]; then + local img_width img_height + if is_video "$img"; then # Not check resolution for videos, just let em pass + img_width=$min_width_desired + img_height=$min_height_desired + else + img_width=$(identify -format "%w" "$img" 2>/dev/null) + img_height=$(identify -format "%h" "$img" 2>/dev/null) + fi + if [[ "$img_width" -lt "$min_width_desired" || "$img_height" -lt "$min_height_desired" ]]; then + action=$(notify-send "Upscale?" \ + "Image resolution (${img_width}x${img_height}) is lower than screen resolution (${min_width_desired}x${min_height_desired})" \ + -A "open_upscayl=Open Upscayl"\ + -a "Wallpaper switcher") + if [[ "$action" == "open_upscayl" ]]; then + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + else + action2=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_upscayl=Install Upscayl (Arch)" \ + "Install Upscayl?" \ + "yay -S upscayl-bin") + if [[ "$action2" == "install_upscayl" ]]; then + kitty -1 yay -S upscayl-bin + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + fi + fi + fi + fi + fi + fi +} + +CUSTOM_DIR="$XDG_CONFIG_HOME/hypr/custom" +RESTORE_SCRIPT_DIR="$CUSTOM_DIR/scripts" +RESTORE_SCRIPT="$RESTORE_SCRIPT_DIR/__restore_video_wallpaper.sh" +THUMBNAIL_DIR="$RESTORE_SCRIPT_DIR/mpvpaper_thumbnails" +VIDEO_OPTS="no-audio loop hwdec=auto scale=bilinear interpolation=no video-sync=display-resample panscan=1.0 video-scale-x=1.0 video-scale-y=1.0 video-align-x=0.5 video-align-y=0.5 load-scripts=no" + +is_video() { + local extension="${1##*.}" + [[ "$extension" == "mp4" || "$extension" == "webm" || "$extension" == "mkv" || "$extension" == "avi" || "$extension" == "mov" ]] && return 0 || return 1 +} + +kill_existing_mpvpaper() { + pkill -f -9 mpvpaper || true +} + +create_restore_script() { + local video_path=$1 + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# Generated by switchwall.sh - Don't modify it by yourself. +# Time: $(date) + +pkill -f -9 mpvpaper + +for monitor in \$(hyprctl monitors -j | jq -r '.[] | .name'); do + mpvpaper -o "$VIDEO_OPTS" "\$monitor" "$video_path" & + sleep 0.1 +done +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" + chmod +x "$RESTORE_SCRIPT" +} + +remove_restore() { + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# The content of this script will be generated by switchwall.sh - Don't modify it by yourself. +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" +} + +set_wallpaper_path() { + local path="$1" + if [ -f "$SHELL_CONFIG_FILE" ]; then + jq --arg path "$path" '.background.wallpaperPath = $path' "$SHELL_CONFIG_FILE" > "$SHELL_CONFIG_FILE.tmp" && mv "$SHELL_CONFIG_FILE.tmp" "$SHELL_CONFIG_FILE" + fi +} + +set_thumbnail_path() { + local path="$1" + if [ -f "$SHELL_CONFIG_FILE" ]; then + jq --arg path "$path" '.background.thumbnailPath = $path' "$SHELL_CONFIG_FILE" > "$SHELL_CONFIG_FILE.tmp" && mv "$SHELL_CONFIG_FILE.tmp" "$SHELL_CONFIG_FILE" + fi +} + +switch() { + imgpath="$1" + mode_flag="$2" + type_flag="$3" + color_flag="$4" + color="$5" + read scale screenx screeny screensizey < <(hyprctl monitors -j | jq '.[] | select(.focused) | .scale, .x, .y, .height' | xargs) + cursorposx=$(hyprctl cursorpos -j | jq '.x' 2>/dev/null) || cursorposx=960 + cursorposx=$(bc <<< "scale=0; ($cursorposx - $screenx) * $scale / 1") + cursorposy=$(hyprctl cursorpos -j | jq '.y' 2>/dev/null) || cursorposy=540 + cursorposy=$(bc <<< "scale=0; ($cursorposy - $screeny) * $scale / 1") + cursorposy_inverted=$((screensizey - cursorposy)) + + if [[ "$color_flag" == "1" ]]; then + matugen_args=(color hex "$color") + generate_colors_material_args=(--color "$color") + else + if [[ -z "$imgpath" ]]; then + echo 'Aborted' + exit 0 + fi + + check_and_prompt_upscale "$imgpath" & + kill_existing_mpvpaper + + if is_video "$imgpath"; then + mkdir -p "$THUMBNAIL_DIR" + + missing_deps=() + if ! command -v mpvpaper &> /dev/null; then + missing_deps+=("mpvpaper") + fi + if ! command -v ffmpeg &> /dev/null; then + missing_deps+=("ffmpeg") + fi + if [ ${#missing_deps[@]} -gt 0 ]; then + echo "Missing deps: ${missing_deps[*]}" + echo "Arch: sudo pacman -S ${missing_deps[*]}" + action=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_arch=Install (Arch)" \ + "Can't switch to video wallpaper" \ + "Missing dependencies: ${missing_deps[*]}") + if [[ "$action" == "install_arch" ]]; then + kitty -1 sudo pacman -S "${missing_deps[*]}" + if command -v mpvpaper &>/dev/null && command -v ffmpeg &>/dev/null; then + notify-send 'Wallpaper switcher' 'Alright, try again!' -a "Wallpaper switcher" + fi + fi + exit 0 + fi + + # Set wallpaper path + set_wallpaper_path "$imgpath" + + # Set video wallpaper + local video_path="$imgpath" + monitors=$(hyprctl monitors -j | jq -r '.[] | .name') + for monitor in $monitors; do + mpvpaper -o "$VIDEO_OPTS" "$monitor" "$video_path" & + sleep 0.1 + done + + # Extract first frame for color generation + thumbnail="$THUMBNAIL_DIR/$(basename "$imgpath").jpg" + ffmpeg -y -i "$imgpath" -vframes 1 "$thumbnail" 2>/dev/null + + # Set thumbnail path + set_thumbnail_path "$thumbnail" + + if [ -f "$thumbnail" ]; then + matugen_args=(image "$thumbnail") + generate_colors_material_args=(--path "$thumbnail") + create_restore_script "$video_path" + else + echo "Cannot create image to colorgen" + remove_restore + exit 1 + fi + else + matugen_args=(image "$imgpath") + generate_colors_material_args=(--path "$imgpath") + # Update wallpaper path in config + set_wallpaper_path "$imgpath" + remove_restore + fi + fi + + # Determine mode if not set + if [[ -z "$mode_flag" ]]; then + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + mode_flag="dark" + else + mode_flag="light" + fi + fi + + [[ -n "$mode_flag" ]] && matugen_args+=(--mode "$mode_flag") && generate_colors_material_args+=(--mode "$mode_flag") + [[ -n "$type_flag" ]] && matugen_args+=(--type "$type_flag") && generate_colors_material_args+=(--scheme "$type_flag") + generate_colors_material_args+=(--termscheme "$terminalscheme" --blend_bg_fg) + generate_colors_material_args+=(--cache "$STATE_DIR/user/generated/color.txt") + + pre_process "$mode_flag" + + # Check if app and shell theming is enabled in config + if [ -f "$SHELL_CONFIG_FILE" ]; then + enable_apps_shell=$(jq -r '.appearance.wallpaperTheming.enableAppsAndShell' "$SHELL_CONFIG_FILE") + if [ "$enable_apps_shell" == "false" ]; then + echo "App and shell theming disabled, skipping matugen and color generation" + return + fi + fi + + matugen "${matugen_args[@]}" + source "$(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate" + python3 "$SCRIPT_DIR/generate_colors_material.py" "${generate_colors_material_args[@]}" \ + > "$STATE_DIR"/user/generated/material_colors.scss + "$SCRIPT_DIR"/applycolor.sh + deactivate + + # Pass screen width, height, and wallpaper path to post_process + max_width_desired="$(hyprctl monitors -j | jq '([.[].width] | min)' | xargs)" + max_height_desired="$(hyprctl monitors -j | jq '([.[].height] | min)' | xargs)" + post_process "$max_width_desired" "$max_height_desired" "$imgpath" +} + +main() { + imgpath="" + mode_flag="" + type_flag="" + color_flag="" + color="" + noswitch_flag="" + + get_type_from_config() { + jq -r '.appearance.palette.type' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "auto" + } + + detect_scheme_type_from_image() { + local img="$1" + "$SCRIPT_DIR"/scheme_for_image.py "$img" 2>/dev/null | tr -d '\n' + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + mode_flag="$2" + shift 2 + ;; + --type) + type_flag="$2" + shift 2 + ;; + --color) + color_flag="1" + if [[ "$2" =~ ^#?[A-Fa-f0-9]{6}$ ]]; then + color="$2" + shift 2 + else + color=$(hyprpicker --no-fancy) + shift + fi + ;; + --image) + imgpath="$2" + shift 2 + ;; + --noswitch) + noswitch_flag="1" + imgpath=$(jq -r '.background.wallpaperPath' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "") + shift + ;; + *) + if [[ -z "$imgpath" ]]; then + imgpath="$1" + fi + shift + ;; + esac + done + + # If type_flag is not set, get it from config + if [[ -z "$type_flag" ]]; then + type_flag="$(get_type_from_config)" + fi + + # Validate type_flag (allow 'auto' as well) + allowed_types=(scheme-content scheme-expressive scheme-fidelity scheme-fruit-salad scheme-monochrome scheme-neutral scheme-rainbow scheme-tonal-spot auto) + valid_type=0 + for t in "${allowed_types[@]}"; do + if [[ "$type_flag" == "$t" ]]; then + valid_type=1 + break + fi + done + if [[ $valid_type -eq 0 ]]; then + echo "[switchwall.sh] Warning: Invalid type '$type_flag', defaulting to 'auto'" >&2 + type_flag="auto" + fi + + # Only prompt for wallpaper if not using --color and not using --noswitch and no imgpath set + if [[ -z "$imgpath" && -z "$color_flag" && -z "$noswitch_flag" ]]; then + cd "$(xdg-user-dir PICTURES)/Wallpapers/showcase" 2>/dev/null || cd "$(xdg-user-dir PICTURES)/Wallpapers" 2>/dev/null || cd "$(xdg-user-dir PICTURES)" || return 1 + imgpath="$(kdialog --getopenfilename . --title 'Choose wallpaper')" + fi + + # If type_flag is 'auto', detect scheme type from image (after imgpath is set) + if [[ "$type_flag" == "auto" ]]; then + if [[ -n "$imgpath" && -f "$imgpath" ]]; then + detected_type="$(detect_scheme_type_from_image "$imgpath")" + # Only use detected_type if it's valid + valid_detected=0 + for t in "${allowed_types[@]}"; do + if [[ "$detected_type" == "$t" && "$detected_type" != "auto" ]]; then + valid_detected=1 + break + fi + done + if [[ $valid_detected -eq 1 ]]; then + type_flag="$detected_type" + else + echo "[switchwall] Warning: Could not auto-detect a valid scheme, defaulting to 'scheme-tonal-spot'" >&2 + type_flag="scheme-tonal-spot" + fi + else + echo "[switchwall] Warning: No image to auto-detect scheme from, defaulting to 'scheme-tonal-spot'" >&2 + type_flag="scheme-tonal-spot" + fi + fi + + switch "$imgpath" "$mode_flag" "$type_flag" "$color_flag" "$color" +} + +main "$@" diff --git a/configs/quickshell/scripts/colors/terminal/scheme-base.json b/configs/quickshell/scripts/colors/terminal/scheme-base.json new file mode 100644 index 0000000..e4b78e7 --- /dev/null +++ b/configs/quickshell/scripts/colors/terminal/scheme-base.json @@ -0,0 +1,38 @@ +{ + "dark": { + "term0" : "#282828", + "term1" : "#CC241D", + "term2" : "#98971A", + "term3" : "#D79921", + "term4" : "#458588", + "term5" : "#B16286", + "term6" : "#689D6A", + "term7" : "#A89984", + "term8" : "#928374", + "term9" : "#FB4934", + "term10" : "#B8BB26", + "term11" : "#FABD2F", + "term12" : "#83A598", + "term13" : "#D3869B", + "term14" : "#8EC07C", + "term15" : "#EBDBB2" + }, + "light": { + "term0" : "#FDF9F3", + "term1" : "#FF6188", + "term2" : "#A9DC76", + "term3" : "#FC9867", + "term4" : "#FFD866", + "term5" : "#F47FD4", + "term6" : "#78DCE8", + "term7" : "#333034", + "term8" : "#121212", + "term9" : "#FF6188", + "term10" : "#A9DC76", + "term11" : "#FC9867", + "term12" : "#FFD866", + "term13" : "#F47FD4", + "term14" : "#78DCE8", + "term15" : "#333034" + } +} diff --git a/configs/quickshell/scripts/colors/terminal/sequences.txt b/configs/quickshell/scripts/colors/terminal/sequences.txt new file mode 100644 index 0000000..9745958 --- /dev/null +++ b/configs/quickshell/scripts/colors/terminal/sequences.txt @@ -0,0 +1 @@ +]4;0;#$term0 #\]1;0;#$term0 #\]4;1;#$term1 #\]4;2;#$term2 #\]4;3;#$term3 #\]4;4;#$term4 #\]4;5;#$term5 #\]4;6;#$term6 #\]4;7;#$term7 #\]4;8;#$term8 #\]4;9;#$term9 #\]4;10;#$term10 #\]4;11;#$term11 #\]4;12;#$term12 #\]4;13;#$term13 #\]4;14;#$term14 #\]4;15;#$term15 #\]10;#$term7 #\]11;[100]#$term0 #\]12;#$term7 #\]13;#$term7 #\]17;#$term7 #\]19;#$term0 #\]4;232;#$term7 #\]4;256;#$term7 #\]708;[100]#$term0 #\]11;#$term0 #\ \ No newline at end of file diff --git a/configs/quickshell/scripts/hyprland/get_keybinds.py b/configs/quickshell/scripts/hyprland/get_keybinds.py new file mode 100755 index 0000000..559ba8a --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/scripts/images/find_regions.py b/configs/quickshell/scripts/images/find_regions.py new file mode 100755 index 0000000..fe68a4d --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/scripts/images/least_busy_region.py b/configs/quickshell/scripts/images/least_busy_region.py new file mode 100755 index 0000000..2b1d104 --- /dev/null +++ b/configs/quickshell/scripts/images/least_busy_region.py @@ -0,0 +1,341 @@ +#!/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): + 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 + # 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_var = None + min_coords = (0, 0) + 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 + for y in range(y_start, max(y_end, y_start+1), stride): + for x in range(x_start, max(x_end, x_start+1), stride): + x1, y1 = x, y + x2, y2 = x + region_width - 1, y + region_height - 1 + 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) + 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 + 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 + # 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 + max_size = min(h, int(w / aspect_ratio)) if aspect_ratio >= 1.0 else min(int(h * aspect_ratio), w) + best = None + best_size = min_size + while min_size <= max_size: + mid = (min_size + max_size) // 2 + if aspect_ratio >= 1.0: + region_h = mid + region_w = int(mid * aspect_ratio) + else: + region_w = mid + region_h = int(mid / aspect_ratio) + if region_w > w or region_h > h: + max_size = mid - 1 + continue + found = False + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_w - horizontal_padding + 1 + y_end = h - region_h - vertical_padding + 1 + for y in range(y_start, max(y_end, y_start+1), stride): + for x in range(x_start, max(x_end, x_start+1), stride): + x1, y1 = x, y + x2, y2 = x + region_w - 1, y + region_h - 1 + 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: + best_size = mid + 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(f"Saved output image with rectangle at {output_path}") + +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(f"Saved output image with largest region at {output_path}") + +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)] + return [int(x) for x in 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") + 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 + ) + 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/configs/quickshell/scripts/kvantum/adwsvg.py b/configs/quickshell/scripts/kvantum/adwsvg.py new file mode 100644 index 0000000..10ce1d1 --- /dev/null +++ b/configs/quickshell/scripts/kvantum/adwsvg.py @@ -0,0 +1,79 @@ +import re +import os + +def read_scss(file_path): + """Reads an SCSS file and returns a dictionary of color variables.""" + colors = {} + with open(file_path, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line.strip()) + if match: + variable_name, color = match.groups() + colors[variable_name] = color + return colors + +def update_svg_colors(svg_path, old_to_new_colors, output_path): + """ + Updates the colors in an SVG file based on the provided color map. + + :param svg_path: Path to the SVG file. + :param old_to_new_colors: Dictionary mapping old colors to new colors. + :param output_path: Path to save the updated SVG file. + """ + # Read the SVG content + with open(svg_path, 'r') as file: + svg_content = file.read() + + # Replace old colors with new colors + for old_color, new_color in old_to_new_colors.items(): + svg_content = re.sub(old_color, new_color, svg_content, flags=re.IGNORECASE) + + # Write the updated SVG content to the output file + with open(output_path, 'w') as file: + file.write(svg_content) + + print(f"SVG colors have been updated and saved to {output_path}!") + +def main(): + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "Colloid.svg") + output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") + + # Read colors from the SCSS file + color_data = read_scss(scss_file) + + # Specify the old colors and map them to new colors from the SCSS file + old_to_new_colors = { + #'#cccccc': color_data['surfaceDim'], # Map old SVG color to new SCSS color + #'#666666': color_data['surfaceDim'], + '#3c84f7': color_data['primary'], + #'#5a5a5a': color_data['neutral_paletteKeyColor'], + '#000000': color_data['shadow'], + '#f04a50': color_data['error'], + '#4285f4': color_data['primaryFixedDim'], + '#f2f2f2': color_data['background'], + #'#dfdfdf': color_data['surfaceContainerLow'], + '#ffffff': color_data['background'], + '#1e1e1e': color_data['onPrimaryFixed'], + #'#b6b6b6': color_data['surfaceContainer'], + '#333': color_data['inverseSurface'], + '#212121': color_data['onSecondaryFixed'], + '#5b9bf8': color_data['secondaryContainer'], + '#26272a': color_data['term7'], + #'#b3b3b3': color_data['surfaceBright'], + #'#b74aff': color_data['tertiary'], + #'#989898': color_data['surfaceContainerHighest'], + #'#c1c1c1': color_data['surfaceContainerHigh'], + '#444444': color_data['onBackground'], + '#333333': color_data['onPrimaryFixed'], + } + + # Update the SVG colors + update_svg_colors(svg_path, old_to_new_colors, output_path) + +if __name__ == "__main__": + main() + diff --git a/configs/quickshell/scripts/kvantum/adwsvgDark.py b/configs/quickshell/scripts/kvantum/adwsvgDark.py new file mode 100644 index 0000000..9fb0977 --- /dev/null +++ b/configs/quickshell/scripts/kvantum/adwsvgDark.py @@ -0,0 +1,87 @@ +import re +import os + +def read_scss(file_path): + """Reads an SCSS file and returns a dictionary of color variables.""" + colors = {} + with open(file_path, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line.strip()) + if match: + variable_name, color = match.groups() + colors[variable_name] = color + return colors + +def update_svg_colors(svg_path, old_to_new_colors, output_path): + """ + Updates the colors in an SVG file based on the provided color map. + + :param svg_path: Path to the SVG file. + :param old_to_new_colors: Dictionary mapping old colors to new colors. + :param output_path: Path to save the updated SVG file. + """ + # Read the SVG content + with open(svg_path, 'r') as file: + svg_content = file.read() + + # Replace old colors with new colors + for old_color, new_color in old_to_new_colors.items(): + svg_content = re.sub(old_color, new_color, svg_content, flags=re.IGNORECASE) + + # Write the updated SVG content to the output file + with open(output_path, 'w') as file: + file.write(svg_content) + + print(f"SVG colors have been updated and saved to {output_path}!") + +def main(): + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "ColloidDark.svg") + output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") + + # Read colors from the SCSS file + color_data = read_scss(scss_file) + + # Specify the old colors and map them to new colors from the SCSS file + old_to_new_colors = { + #'#525252': color_data['surfaceDim'], # Map old SVG color to new SCSS color + #'#666666': color_data['surfaceDim'], + '#31363b': color_data['background'], + #'#eff0f1': color_data['neutral_paletteKeyColor'], + '#000000': color_data['shadow'], + '#5b9bf8': color_data['primary'], + '#93cee9': color_data['onSecondaryContainer'], + '#3daee9': color_data['secondary'], + #'#fff': color_data['term10'], + #'#5a5a5a': color_data['surfaceVariant'], + #'#acb1bc': color_data['onPrimaryFixed'], + '#ffffff': color_data['term11'], + '#5a616e': color_data['surfaceVariant'], + '#f04a50': color_data['error'], + '#4285f4': color_data['secondary'], + '#242424': color_data['background'], + '#2c2c2c': color_data['background'], + #'#dfdfdf': color_data['onSurfaceVariant'], + #'#646464': color_data['surfaceContainerHighest'], + #'#989898': color_data['surfaceContainerHigh'], + #'#c1c1c1': color_data['primaryFixedDim'], + '#1e1e1e': color_data['background'], + '#3c3c3c': color_data['background'], + '#26272a': color_data['surfaceBright'], + '#000000': color_data['shadow'], + '#b74aff': color_data['tertiary'], + #'#b6b6b6': color_data['onSurfaceVariant'], + '#1a1a1a': color_data['background'], + '#333': color_data['term0'], + '#212121': color_data['background'], + } + + # Update the SVG colors + update_svg_colors(svg_path, old_to_new_colors, output_path) + +if __name__ == "__main__": + main() + diff --git a/configs/quickshell/scripts/kvantum/changeAdwColors.py b/configs/quickshell/scripts/kvantum/changeAdwColors.py new file mode 100644 index 0000000..26d067a --- /dev/null +++ b/configs/quickshell/scripts/kvantum/changeAdwColors.py @@ -0,0 +1,71 @@ +import re +import os + +def get_colors_from_scss(scss_file): + colors = {} + with open(scss_file, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line) + if match: + colors[match.group(1)] = match.group(2) + return colors + +def update_config_colors(config_file, colors, mappings): + with open(config_file, 'r') as file: + config_content = file.read() + + for key, variable in mappings.items(): + if variable in colors: + color = colors[variable] + pattern = rf'({key}=)#?\w+\b' + new_line = f'\\1{color}' + if re.search(pattern, config_content): + config_content = re.sub(pattern, new_line, config_content) + else: + config_content += f"\n{key}={color}" + + with open(config_file, 'w') as file: + file.write(config_content) + +if __name__ == "__main__": + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + config_file = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.kvconfig") + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + + # Define your mappings here + mappings = { + 'window.color': 'background', + 'base.color': 'background', + 'alt.base.color': 'background', + 'button.color': 'surfaceContainer', + 'light.color': 'surfaceContainerLow', + 'mid.light.color': 'surfaceContainer', + 'dark.color': 'surfaceContainerHighest', + 'mid.color': 'surfaceContainerHigh', + 'highlight.color': 'primary', + 'inactive.highlight.color': 'primary', + 'text.color': 'onBackground', + 'window.text.color': 'onBackground', + 'button.text.color': 'onBackground', + 'disabled.text.color': 'onBackground', + 'tooltip.text.color': 'onBackground', + 'highlight.text.color': 'onSurface', + 'link.color': 'tertiary', + 'link.visited.color': 'tertiaryFixed', + 'progress.indicator.text.color': 'onBackground', + 'text.normal.color': 'onBackground', + 'text.focus.color': 'onBackground', + 'text.press.color': 'onsecondarycontainer', + 'text.toggle.color': 'onsecondarycontainer', + 'text.disabled.color': 'surfaceDim', + + + # Add more mappings as needed + } + + colors = get_colors_from_scss(scss_file) + update_config_colors(config_file, colors, mappings) + print("Config colors updated successfully!") + diff --git a/configs/quickshell/scripts/kvantum/materialQT.sh b/configs/quickshell/scripts/kvantum/materialQT.sh new file mode 100755 index 0000000..a049c55 --- /dev/null +++ b/configs/quickshell/scripts/kvantum/materialQT.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +get_light_dark() { + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + echo "dark" + else + echo "light" + fi +} + +apply_qt() { + # Check if the theme exists + FOLDER_PATH="$XDG_CONFIG_HOME/Kvantum/Colloid/" + + if [ ! -d "$FOLDER_PATH" ]; then + # Send a notification + notify-send "Colloid-kde theme required" " The folder '$FOLDER_PATH' does not exist." + exit 1 # Exit the function if the folder does not exist + fi + + lightdark=$(get_light_dark) + if [ "$lightdark" = "light" ]; then + # apply ligght colors + cp "$XDG_CONFIG_HOME/Kvantum/Colloid/Colloid.kvconfig" "$XDG_CONFIG_HOME/Kvantum/MaterialAdw/MaterialAdw.kvconfig" + python "$CONFIG_DIR/scripts/kvantum/adwsvg.py" + + else + #apply dark colors + cp "$XDG_CONFIG_HOME/Kvantum/Colloid/ColloidDark.kvconfig" "$XDG_CONFIG_HOME/Kvantum/MaterialAdw/MaterialAdw.kvconfig" + python "$CONFIG_DIR/scripts/kvantum/adwsvgDark.py" + fi +} + +apply_qt diff --git a/configs/quickshell/scripts/wayland-idle-inhibitor.py b/configs/quickshell/scripts/wayland-idle-inhibitor.py new file mode 100755 index 0000000..9bdaabb --- /dev/null +++ b/configs/quickshell/scripts/wayland-idle-inhibitor.py @@ -0,0 +1,86 @@ +#!/usr/bin/env -S\_/bin/sh\_-xc\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" + +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + +import sys +from dataclasses import dataclass +from signal import SIGINT, SIGTERM, signal +from threading import Event +import setproctitle + +from pywayland.client.display import Display +from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( + ZwpIdleInhibitManagerV1, +) +from pywayland.protocol.wayland.wl_compositor import WlCompositor +from pywayland.protocol.wayland.wl_registry import WlRegistryProxy +from pywayland.protocol.wayland.wl_surface import WlSurface + + +@dataclass +class GlobalRegistry: + surface: WlSurface | None = None + inhibit_manager: ZwpIdleInhibitManagerV1 | None = None + + +def handle_registry_global( + wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int +) -> None: + global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry() + + if iface_name == "wl_compositor": + compositor = wl_registry.bind(id_num, WlCompositor, version) + global_registry.surface = compositor.create_surface() # type: ignore + elif iface_name == "zwp_idle_inhibit_manager_v1": + global_registry.inhibit_manager = wl_registry.bind( + id_num, ZwpIdleInhibitManagerV1, version + ) + + +def main() -> None: + done = Event() + signal(SIGINT, lambda _, __: done.set()) + signal(SIGTERM, lambda _, __: done.set()) + + global_registry = GlobalRegistry() + + display = Display() + display.connect() + + registry = display.get_registry() # type: ignore + registry.user_data = global_registry + registry.dispatcher["global"] = handle_registry_global + + def shutdown() -> None: + display.dispatch() + display.roundtrip() + display.disconnect() + + display.dispatch() + display.roundtrip() + + if global_registry.surface is None or global_registry.inhibit_manager is None: + print("Wayland seems not to support idle_inhibit_unstable_v1 protocol.") + shutdown() + sys.exit(1) + + inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore + global_registry.surface + ) + + display.dispatch() + display.roundtrip() + + print("Inhibiting idle...") + done.wait() + print("Shutting down...") + + inhibitor.destroy() + + shutdown() + + +if __name__ == "__main__": + setproctitle.setproctitle("wayland-idle-inhibitor.py") + main() diff --git a/configs/quickshell/services/Ai.qml b/configs/quickshell/services/Ai.qml new file mode 100644 index 0000000..f265d95 --- /dev/null +++ b/configs/quickshell/services/Ai.qml @@ -0,0 +1,892 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions as CF +import qs.modules.common +import qs +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import "./ai/" + +/** + * Basic service to handle LLM chats. Supports Google's and OpenAI's API formats. + * Supports Gemini and OpenAI models. + * Limitations: + * - For now functions only work with Gemini API format + */ +Singleton { + id: root + + property Component aiMessageComponent: AiMessageData {} + property Component aiModelComponent: AiModel {} + property Component geminiApiStrategy: GeminiApiStrategy {} + property Component openaiApiStrategy: OpenAiApiStrategy {} + property Component mistralApiStrategy: MistralApiStrategy {} + readonly property string interfaceRole: "interface" + readonly property string apiKeyEnvVarName: "API_KEY" + + property string systemPrompt: { + let prompt = Config.options?.ai?.systemPrompt ?? ""; + for (let key in root.promptSubstitutions) { + // prompt = prompt.replaceAll(key, root.promptSubstitutions[key]); + // QML/JS doesn't support replaceAll, so use split/join + prompt = prompt.split(key).join(root.promptSubstitutions[key]); + } + return prompt; + } + // property var messages: [] + property var messageIDs: [] + property var messageByID: ({}) + readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} + readonly property var apiKeysLoaded: KeyringStorage.loaded + readonly property bool currentModelHasApiKey: { + const model = models[currentModelId]; + if (!model || !model.requires_key) return true; + if (!apiKeysLoaded) return false; + const key = apiKeys[model.key_id]; + return (key?.length > 0); + } + property var postResponseHook + property real temperature: Persistent.states?.ai?.temperature ?? 0.5 + property QtObject tokenCount: QtObject { + property int input: -1 + property int output: -1 + property int total: -1 + } + + function idForMessage(message) { + // Generate a unique ID using timestamp and random value + return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); + } + + function safeModelName(modelName) { + return modelName.replace(/:/g, "_").replace(/ /g, "-").replace(/\//g, "-") + } + + property list defaultPrompts: [] + property list userPrompts: [] + property list promptFiles: [...defaultPrompts, ...userPrompts] + property list savedChats: [] + + property var promptSubstitutions: { + "{DISTRO}": SystemInfo.distroName, + "{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`, + "{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown", + "{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})` + } + + // Gemini: https://ai.google.dev/gemini-api/docs/function-calling + // OpenAI: https://platform.openai.com/docs/guides/function-calling + property string currentTool: Config?.options.ai.tool ?? "search" + property var tools: { + "gemini": { + "functions": [{"functionDeclarations": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ]}], + "search": [{ + "google_search": {} + }], + "none": [] + }, + "openai": { + "functions": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ], + "search": [], + "none": [], + }, + "mistral": { + "functions": [ + { + "type": "function", + "function": { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + "parameters": {} + }, + }, + { + "type": "function", + "function": { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + }, + ], + "search": [], + "none": [], + } + } + property list availableTools: Object.keys(root.tools[models[currentModelId]?.api_format]) + property var toolDescriptions: { + "functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"), + "search": Translation.tr("Gives the model search capabilities (immediately)"), + "none": Translation.tr("Disable tools") + } + + // Model properties: + // - name: Name of the model + // - icon: Icon name of the model + // - description: Description of the model + // - endpoint: Endpoint of the model + // - model: Model name of the model + // - requires_key: Whether the model requires an API key + // - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + // - key_get_link: Link to get an API key + // - key_get_description: Description of pricing and how to get an API key + // - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + // - extraParams: Extra parameters to be passed to the model. This is a JSON object. + property var models: { + "gemini-2.0-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.0 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nFast, can perform searches for up-to-date information"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent", + "model": "gemini-2.5-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-pro": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Pro", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent", + "model": "gemini-2.5-pro", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-lite": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash-Lite", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent", + "model": "gemini-2.5-flash-lite", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "mistral-medium-3": aiModelComponent.createObject(this, { + "name": "Mistral Medium 3", + "icon": "mistral-symbolic", + "description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"), + "homepage": "https://mistral.ai/news/mistral-medium-3", + "endpoint": "https://api.mistral.ai/v1/chat/completions", + "model": "mistral-medium-2505", + "requires_key": true, + "key_id": "mistral", + "key_get_link": "https://console.mistral.ai/api-keys", + "key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"), + "api_format": "mistral", + }), + "openrouter-deepseek-r1": aiModelComponent.createObject(this, { + "name": "DeepSeek R1", + "icon": "deepseek-symbolic", + "description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"), + "homepage": "https://openrouter.ai/deepseek/deepseek-r1:free", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "deepseek/deepseek-r1:free", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }), + } + property var modelList: Object.keys(root.models) + property var currentModelId: Persistent.states?.ai?.model || modelList[0] + + property var apiStrategies: { + "openai": openaiApiStrategy.createObject(this), + "gemini": geminiApiStrategy.createObject(this), + "mistral": mistralApiStrategy.createObject(this), + } + property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"] + + Connections { + target: Config + function onReadyChanged() { + if (!Config.ready) return; + (Config?.options.ai?.extraModels ?? []).forEach(model => { + const safeModelName = root.safeModelName(model["model"]); + root.addModel(safeModelName, model) + }); + } + } + + Component.onCompleted: { + setModel(currentModelId, false, false); // Do necessary setup for model + } + + function guessModelLogo(model) { + if (model.includes("llama")) return "ollama-symbolic"; + if (model.includes("gemma")) return "google-gemini-symbolic"; + if (model.includes("deepseek")) return "deepseek-symbolic"; + if (/^phi\d*:/i.test(model)) return "microsoft-symbolic"; + return "ollama-symbolic"; + } + + function guessModelName(model) { + const replaced = model.replace(/-/g, ' ').replace(/:/g, ' '); + let words = replaced.split(' '); + words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`) + words = words.map((word) => { + return (word.charAt(0).toUpperCase() + word.slice(1)) + }); + if (words[words.length - 1] === "Latest") words.pop(); + else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets + const result = words.join(' '); + return result; + } + + function addModel(modelName, data) { + root.models[modelName] = aiModelComponent.createObject(this, data); + } + + Process { + id: getOllamaModels + running: true + command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")] + stdout: SplitParser { + onRead: data => { + try { + if (data.length === 0) return; + const dataJson = JSON.parse(data); + root.modelList = [...root.modelList, ...dataJson]; + dataJson.forEach(model => { + const safeModelName = root.safeModelName(model); + root.addModel(safeModelName, { + "name": guessModelName(model), + "icon": guessModelLogo(model), + "description": Translation.tr("Local Ollama model | %1").arg(model), + "homepage": `https://ollama.com/library/${model}`, + "endpoint": "http://localhost:11434/v1/chat/completions", + "model": model, + "requires_key": false, + }) + }); + + root.modelList = Object.keys(root.models); + + } catch (e) { + console.log("Could not fetch Ollama models:", e); + } + } + } + } + + Process { + id: getDefaultPrompts + running: true + command: ["ls", "-1", Directories.defaultAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.defaultPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.defaultAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getUserPrompts + running: true + command: ["ls", "-1", Directories.userAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.userPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.userAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getSavedChats + running: true + command: ["ls", "-1", Directories.aiChats] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.savedChats = text.split("\n") + .filter(fileName => fileName.endsWith(".json")) + .map(fileName => `${Directories.aiChats}/${fileName}`) + } + } + } + + FileView { + id: promptLoader + watchChanges: false; + onLoadedChanged: { + if (!promptLoader.loaded) return; + Config.options.ai.systemPrompt = promptLoader.text(); + root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + } + + function printPrompt() { + root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + + function loadPrompt(filePath) { + promptLoader.path = "" // Unload + promptLoader.path = filePath; // Load + promptLoader.reload(); + } + + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "rawContent": message, + "thinking": false, + "done": true, + }); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function removeMessage(index) { + if (index < 0 || index >= messageIDs.length) return; + const id = root.messageIDs[index]; + root.messageIDs.splice(index, 1); + root.messageIDs = [...root.messageIDs]; + delete root.messageByID[id]; + } + + function addApiKeyAdvice(model) { + root.addMessage( + Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3') + .arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("No further instruction provided")).arg("/key"), + Ai.interfaceRole + ); + } + + function getModel() { + return models[currentModelId]; + } + + function setModel(modelId, feedback = true, setPersistentState = true) { + if (!modelId) modelId = "" + modelId = modelId.toLowerCase() + if (modelList.indexOf(modelId) !== -1) { + const model = models[modelId] + // Fetch API keys if needed + if (model?.requires_key) KeyringStorage.fetchKeyringData(); + // See if policy prevents online models + if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) { + root.addMessage( + Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"), + root.interfaceRole + ); + return; + } + if (setPersistentState) Persistent.states.ai.model = modelId; + if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole); + if (model.requires_key) { + // If key not there show advice + if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) { + root.addApiKeyAdvice(model) + } + } + } else { + if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```" + } + } + + function setTool(tool) { + if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) { + root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole); + return false; + } + Config.options.ai.tool = tool; + return true; + } + + function getTemperature() { + return root.temperature; + } + + function setTemperature(value) { + if (value == NaN || value < 0 || value > 2) { + root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole); + return; + } + Persistent.states.ai.temperature = value; + root.temperature = value; + root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole); + } + + function setApiKey(key) { + const model = models[currentModelId]; + if (!model.requires_key) { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + return; + } + if (!key || key.length === 0) { + const model = models[currentModelId]; + root.addApiKeyAdvice(model) + return; + } + KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim()); + root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole); + } + + function printApiKey() { + const model = models[currentModelId]; + if (model.requires_key) { + const key = root.apiKeys[model.key_id]; + if (key) { + root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole); + } else { + root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole); + } + } else { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + } + } + + function printTemperature() { + root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole); + } + + function clearMessages() { + root.messageIDs = []; + root.messageByID = ({}); + root.tokenCount.input = -1; + root.tokenCount.output = -1; + root.tokenCount.total = -1; + } + + Process { + id: requester + property list baseCommand: ["bash", "-c"] + property AiMessageData message + property ApiStrategy currentStrategy + + function markDone() { + requester.message.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; // Reset hook after use + } + root.saveChat("lastSession") + } + + function makeRequest() { + const model = models[currentModelId]; + requester.currentStrategy = root.currentApiStrategy; + requester.currentStrategy.reset(); // Reset strategy state + + /* Put API key in environment variable */ + if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "" + + /* Build endpoint, request data */ + const endpoint = root.currentApiStrategy.buildEndpoint(model); + const messageArray = root.messageIDs.map(id => root.messageByID[id]); + const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole); + const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool]); + // console.log("[Ai] Request data: ", JSON.stringify(data, null, 2)); + + let requestHeaders = { + "Content-Type": "application/json", + } + + /* Create local message object */ + requester.message = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": currentModelId, + "content": "", + "rawContent": "", + "thinking": true, + "done": false, + }); + const id = idForMessage(requester.message); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = requester.message; + + /* Build header string for curl */ + let headerString = Object.entries(requestHeaders) + .filter(([k, v]) => v && v.length > 0) + .map(([k, v]) => `-H '${k}: ${v}'`) + .join(' '); + + // console.log("Request headers: ", JSON.stringify(requestHeaders)); + // console.log("Header string: ", headerString); + + /* Get authorization header from strategy */ + const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName); + + /* Create command string */ + const requestCommandString = `curl --no-buffer "${endpoint}"` + + ` ${headerString}` + + (authHeader ? ` ${authHeader}` : "") + + ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + + /* Send the request */ + requester.command = baseCommand.concat([requestCommandString]); + requester.running = true + } + + stdout: SplitParser { + onRead: data => { + if (data.length === 0) return; + if (requester.message.thinking) requester.message.thinking = false; + // console.log("[Ai] Raw response line: ", data); + + // Handle response line + try { + const result = requester.currentStrategy.parseResponseLine(data, requester.message); + // console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2)); + + if (result.functionCall) { + requester.message.functionCall = result.functionCall; + root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message); + } + if (result.tokenUsage) { + root.tokenCount.input = result.tokenUsage.input; + root.tokenCount.output = result.tokenUsage.output; + root.tokenCount.total = result.tokenUsage.total; + } + if (result.finished) { + requester.markDone(); + } + + } catch (e) { + console.log("[AI] Could not parse response: ", e); + requester.message.rawContent += data; + requester.message.content += data; + } + } + } + + onExited: (exitCode, exitStatus) => { + const result = requester.currentStrategy.onRequestFinished(requester.message); + + if (result.finished) { + requester.markDone(); + } else if (!requester.message.done) { + requester.markDone(); + } + + // Handle error responses + if (requester.message.content.includes("API key not valid")) { + root.addApiKeyAdvice(models[requester.message.model]); + } + } + } + + function sendUserMessage(message) { + if (message.length === 0) return; + root.addMessage(message, "user"); + requester.makeRequest(); + } + + function createFunctionOutputMessage(name, output, includeOutputInChat = true) { + return aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + // "visibleToUser": false, + }); + } + + function addFunctionOutputMessage(name, output) { + const aiMessage = createFunctionOutputMessage(name, output); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function rejectCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user")) + } + + function approveCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + + const responseMessage = createFunctionOutputMessage(message.functionName, "", false); + const id = idForMessage(responseMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = responseMessage; + + commandExecutionProc.message = responseMessage; + commandExecutionProc.baseMessageContent = responseMessage.content; + commandExecutionProc.shellCommand = message.functionCall.args.command; + commandExecutionProc.running = true; // Start the command execution + } + + Process { + id: commandExecutionProc + property string shellCommand: "" + property AiMessageData message + property string baseMessageContent: "" + command: ["bash", "-c", shellCommand] + stdout: SplitParser { + onRead: (output) => { + commandExecutionProc.message.functionResponse += output + "\n\n"; + const updatedContent = commandExecutionProc.baseMessageContent + `\n\n\n${commandExecutionProc.message.functionResponse}\n`; + commandExecutionProc.message.rawContent = updatedContent; + commandExecutionProc.message.content = updatedContent; + } + } + onExited: (exitCode, exitStatus) => { + commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`; + requester.makeRequest(); // Continue + } + } + + function handleFunctionCall(name, args: var, message: AiMessageData) { + if (name === "switch_to_search_mode") { + const modelId = root.currentModelId; + root.currentTool = "search" + root.postResponseHook = () => { root.currentTool = "functions" } + addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request.")) + requester.makeRequest(); + } else if (name === "get_shell_config") { + const configJson = CF.ObjectUtils.toPlainObject(Config.options) + addFunctionOutputMessage(name, JSON.stringify(configJson)); + requester.makeRequest(); + } else if (name === "set_shell_config") { + if (!args.key || !args.value) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`.")); + return; + } + const key = args.key; + const value = args.value; + Config.setNestedValue(key, value); + } else if (name === "run_shell_command") { + if (!args.command || args.command.length === 0) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`.")); + return; + } + const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``; + message.rawContent += contentToAppend; + message.content += contentToAppend; + message.functionPending = true; // Use thinking to indicate the command is waiting for approval + } + else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant"); + } + + function chatToJson() { + return root.messageIDs.map(id => { + const message = root.messageByID[id] + return ({ + "role": message.role, + "rawContent": message.rawContent, + "model": message.model, + "thinking": false, + "done": true, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }) + }) + } + + FileView { + id: chatSaveFile + property string chatName: "chat" + path: `${Directories.aiChats}/${chatName}.json` + blockLoading: true + } + + /** + * Saves chat to a JSON list of message objects. + * @param chatName name of the chat + */ + function saveChat(chatName) { + chatSaveFile.chatName = chatName.trim() + const saveContent = JSON.stringify(root.chatToJson()) + chatSaveFile.setText(saveContent) + getSavedChats.running = true; + } + + /** + * Loads chat from a JSON list of message objects. + * @param chatName name of the chat + */ + function loadChat(chatName) { + try { + chatSaveFile.chatName = chatName.trim() + chatSaveFile.reload() + const saveContent = chatSaveFile.text() + // console.log(saveContent) + const saveData = JSON.parse(saveContent) + root.clearMessages() + root.messageIDs = saveData.map((_, i) => { + return i + }) + // console.log(JSON.stringify(messageIDs)) + for (let i = 0; i < saveData.length; i++) { + const message = saveData[i]; + root.messageByID[i] = root.aiMessageComponent.createObject(root, { + "role": message.role, + "rawContent": message.rawContent, + "content": message.rawContent, + "model": message.model, + "thinking": message.thinking, + "done": message.done, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }); + } + } catch (e) { + console.log("[AI] Could not load chat: ", e); + } finally { + getSavedChats.running = true; + } + } +} diff --git a/configs/quickshell/services/AppSearch.qml b/configs/quickshell/services/AppSearch.qml new file mode 100644 index 0000000..34555f3 --- /dev/null +++ b/configs/quickshell/services/AppSearch.qml @@ -0,0 +1,156 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions as Functions +import Quickshell + +/** + * - Eases fuzzy searching for applications by name + * - Guesses icon name for window class name + */ +Singleton { + id: root + property bool sloppySearch: false // Default value, will be updated when Config is ready + property real scoreThreshold: 0.2 + + // Update sloppySearch when Config becomes available + Component.onCompleted: { + if (Config && Config.options && Config.options.search) { + sloppySearch = Config.options.search.sloppy || false + } + } + 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", + "zen": "zen-browser", + "brave-browser": "brave-desktop" + }) + property var regexSubstitutions: [ + { + "pattern": "^steam_app_(\\d+)$", + "replace": "steam_icon_$1" + }, + { + "pattern": "Minecraft.*", + "replace": "minecraft" + }, + { + "pattern": ".*polkit.*", + "replace": "system-lock-screen" + }, + { + "pattern": "gcr.prompter", + "replace": "system-lock-screen" + } + ] + + readonly property list list: Array.from(DesktopEntries.applications.values) + .sort((a, b) => a.name.localeCompare(b.name)) + + readonly property var preppedNames: list.map(a => ({ + name: Functions.Fuzzy.prepare(`${a.name} `), + entry: a + })) + + readonly property var preppedIcons: list.map(a => ({ + name: Functions.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: Functions.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 Functions.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 guessIcon(str) { + if (!str || str.length == 0) return "image-missing"; + + // 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 regex = new RegExp(substitution.pattern); + const replacedName = str.replace( + 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; + + + // Search in desktop entries + const iconSearchResults = Functions.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; + } + + + // Give up + return str; + } +} diff --git a/configs/quickshell/services/Audio.qml b/configs/quickshell/services/Audio.qml new file mode 100644 index 0000000..0651ebc --- /dev/null +++ b/configs/quickshell/services/Audio.qml @@ -0,0 +1,54 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for default Pipewire audio sink and source. + */ +Singleton { + id: root + + property bool ready: Pipewire.defaultAudioSink?.ready ?? false + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + + signal sinkProtectionTriggered(string reason); + + 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; + if (!lastReady) { + lastVolume = sink.audio.volume; + lastReady = true; + return; + } + const newVolume = sink.audio.volume; + 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("Illegal increment"); + } else if (newVolume > maxAllowed) { + root.sinkProtectionTriggered("Exceeded max allowed"); + sink.audio.volume = Math.min(lastVolume, maxAllowed); + } + if (sink.ready && (isNaN(sink.audio.volume) || sink.audio.volume === undefined || sink.audio.volume === null)) { + sink.audio.volume = 0; + } + lastVolume = sink.audio.volume; + } + + } + +} diff --git a/configs/quickshell/services/Battery.qml b/configs/quickshell/services/Battery.qml new file mode 100644 index 0000000..8a3cf31 --- /dev/null +++ b/configs/quickshell/services/Battery.qml @@ -0,0 +1,50 @@ +pragma Singleton + +import qs +import qs.modules.common +import Quickshell +import Quickshell.Services.UPower + +Singleton { + 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 + readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend + + property bool isLow: percentage <= Config.options.battery.low / 100 + property bool isCritical: percentage <= Config.options.battery.critical / 100 + property bool isSuspending: percentage <= Config.options.battery.suspend / 100 + + property bool isLowAndNotCharging: isLow && !isCharging + property bool isCriticalAndNotCharging: isCritical && !isCharging + property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging + + onIsLowAndNotChargingChanged: { + if (available && isLowAndNotCharging) Quickshell.execDetached([ + "notify-send", + Translation.tr("Low battery"), + Translation.tr("Consider plugging in your device"), + "-u", "critical", + "-a", "Shell" + ]) + } + + onIsCriticalAndNotChargingChanged: { + if (available && isCriticalAndNotCharging) 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" + ]); + + } + + onIsSuspendingAndNotChargingChanged: { + if (available && isSuspendingAndNotCharging) { + Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]); + } + } +} diff --git a/configs/quickshell/services/Bluetooth.qml b/configs/quickshell/services/Bluetooth.qml new file mode 100644 index 0000000..817bbc9 --- /dev/null +++ b/configs/quickshell/services/Bluetooth.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * Basic polled Bluetooth state. + */ +Singleton { + id: root + + property int updateInterval: 1000 + property string bluetoothDeviceName: "" + property string bluetoothDeviceAddress: "" + property bool bluetoothEnabled: false + property bool bluetoothConnected: false + + function update() { + updateBluetoothDevice.running = true + updateBluetoothStatus.running = true + updateBluetoothEnabled.running = true + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + update() + interval = root.updateInterval + } + } + + // Check if Bluetooth is enabled (controller powered on) + Process { + id: updateBluetoothEnabled + command: ["sh", "-c", "bluetoothctl show | grep -q 'Powered: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothEnabled = (parseInt(data) === 1) + } + } + } + + // Get the name and address of the first connected Bluetooth device + Process { + id: updateBluetoothDevice + command: ["sh", "-c", "bluetoothctl info | awk -F': ' '/Name: /{name=$2} /Device /{addr=$2} END{print name \":\" addr}'"] + running: true + stdout: SplitParser { + onRead: data => { + let parts = data.split(":") + root.bluetoothDeviceName = parts[0] || "" + root.bluetoothDeviceAddress = parts[1] || "" + } + } + } + + // Check if any device is connected + Process { + id: updateBluetoothStatus + command: ["sh", "-c", "bluetoothctl info | grep -q 'Connected: yes' && echo 1 || echo 0"] + running: true + stdout: SplitParser { + onRead: data => { + root.bluetoothConnected = (parseInt(data) === 1) + } + } + } +} diff --git a/configs/quickshell/services/Booru.qml b/configs/quickshell/services/Booru.qml new file mode 100644 index 0000000..e2a9d19 --- /dev/null +++ b/configs/quickshell/services/Booru.qml @@ -0,0 +1,467 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs +import Quickshell; +import QtQuick; + +/** + * A service for interacting with various booru APIs. + */ +Singleton { + id: root + property Component booruResponseDataComponent: BooruResponseData {} + + signal tagSuggestion(string query, var suggestions) + + property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number") + property var responses: [] + property int runningRequests: 0 + property var defaultUserAgent: Config.options?.networking?.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 var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api) + property var providers: { + "system": { "name": Translation.tr("System") }, + "yandere": { + "name": "yande.re", + "url": "https://yande.re", + "api": "https://yande.re/post.json", + "description": Translation.tr("All-rounder | Good quality, decent quantity"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://yande.re/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "konachan": { + "name": "Konachan", + "url": "https://konachan.net", + "api": "https://konachan.net/post.json", + "description": Translation.tr("For desktop wallpapers | Good quality"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://konachan.net/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "zerochan": { + "name": "Zerochan", + "url": "https://www.zerochan.net", + "api": "https://www.zerochan.net/?json", + "description": Translation.tr("Clean stuff | Excellent quality, no NSFW"), + "mapFunc": (response) => { + response = response.items + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.join(" "), + "rating": "safe", // Zerochan doesn't have nsfw + "is_nsfw": false, + "md5": item.md5, + "preview_url": item.thumbnail, + "sample_url": item.thumbnail, + "file_url": item.thumbnail, + "file_ext": "avif", + "source": getWorkingImageSource(item.source) ?? item.thumbnail, + "character": item.tag + } + }) + } + }, + "danbooru": { + "name": "Danbooru", + "url": "https://danbooru.donmai.us", + "api": "https://danbooru.donmai.us/posts.json", + "description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"), + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.image_width, + "height": item.image_height, + "aspect_ratio": item.image_width / item.image_height, + "tags": item.tag_string, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_file_url, + "sample_url": item.file_url ?? item.large_file_url, + "file_url": item.large_file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://danbooru.donmai.us/tags.json?search[name_matches]={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.post_count + } + }) + } + + }, + "gelbooru": { + "name": "Gelbooru", + "url": "https://gelbooru.com", + "api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1", + "description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"), + "mapFunc": (response) => { + response = response.post + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags, + "rating": item.rating.replace('general', 's').charAt(0), + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_url.split('.').pop(), + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&name_pattern={{query}}%", + "tagMapFunc": (response) => { + return response.tag.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "waifu.im": { + "name": "waifu.im", + "url": "https://waifu.im", + "api": "https://api.waifu.im/search", + "description": Translation.tr("Waifus only | Excellent quality, limited quantity"), + "mapFunc": (response) => { + response = response.images + return response.map(item => { + return { + "id": item.image_id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.map(tag => {return tag.name}).join(" "), + "rating": item.is_nsfw ? "e" : "s", + "is_nsfw": item.is_nsfw, + "md5": item.md5, + "preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk) + "sample_url": item.url, + "file_url": item.url, + "file_ext": item.extension, + "source": getWorkingImageSource(item.source) ?? item.url, + } + }) + }, + "tagSearchTemplate": "https://api.waifu.im/tags", + "tagMapFunc": (response) => { + return [...response.versatile.map(item => {return {"name": item}}), + ...response.nsfw.map(item => {return {"name": item}})] + } + }, + "t.alcy.cc": { + "name": "Alcy", + "url": "https://t.alcy.cc", + "api": "https://t.alcy.cc/", + "description": Translation.tr("Large images | God tier quality, no NSFW."), + "fixedTags": [ + { + "name": "ycy", + "count": "General" + }, + { + "name": "moez", + "count": "Moe" + }, + { + "name": "ysz", + "count": "Genshin Impact" + }, + { + "name": "fj", + "count": "Landscape" + }, + { + "name": "bd", + "count": "Girl on white background" + }, + { + "name": "xhl", + "count": "Shiggy" + }, + ], + "manualParseFunc": (responseText) => { + // Alcy just returns image links, each on a new line + const lines = responseText.trim().split('\n'); + return lines.map(line => { + return { + "id": Qt.md5(line), + // Alcy doesn't provide dimensions and images are often of god resolution + "width": 1000, + "height": 1000, + "aspect_ratio": 1, // Default aspect ratio + "tags": "[no tags]", + "rating": "s", + "is_nsfw": false, + "md5": Qt.md5(line), + "preview_url": line, + "sample_url": line, + "file_url": line, + "file_ext": line.split('.').pop(), + "source": "", + } + }); + }, + } + } + property var currentProvider: Persistent.states.booru.provider + + function getWorkingImageSource(url) { + if (url.includes('pximg.net')) { + return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`; + } + return url; + } + + function setProvider(provider) { + provider = provider.toLowerCase() + if (providerList.indexOf(provider) !== -1) { + Persistent.states.booru.provider = provider + root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name + + (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : "")) + } else { + root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- ")) + } + } + + function clearResponses() { + responses = [] + } + + function addSystemMessage(message) { + responses = [...responses, root.booruResponseDataComponent.createObject(null, { + "provider": "system", + "tags": [], + "page": -1, + "images": [], + "message": `${message}` + })] + } + + function constructRequestUrl(tags, nsfw=true, limit=20, page=1) { + var provider = providers[currentProvider] + var baseUrl = provider.api + var url = baseUrl + var tagString = tags.join(" ") + if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) { + if (currentProvider == "gelbooru") + tagString += " rating:general"; + else + tagString += " rating:safe"; + } + var params = [] + // Tags & limit + if (currentProvider === "zerochan") { + params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color + params.push("l=" + limit) + params.push("s=" + "fav") + params.push("t=" + 1) + params.push("p=" + page) + } + else if (currentProvider === "waifu.im") { + var tagsArray = tagString.split(" "); + tagsArray.forEach(tag => { + params.push("included_tags=" + encodeURIComponent(tag)); + }); + params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30 + params.push("is_nsfw=" + (nsfw ? "null" : "false")) // null is random + } + else if (currentProvider === "t.alcy.cc") { + url += tagString + params.push("json") + params.push("quantity=" + limit) + } + else { + params.push("tags=" + encodeURIComponent(tagString)) + params.push("limit=" + limit) + if (currentProvider == "gelbooru") { + params.push("pid=" + page) + } + else { + params.push("page=" + page) + } + } + if (baseUrl.indexOf("?") === -1) { + url += "?" + params.join("&") + } else { + url += "&" + params.join("&") + } + return url + } + + function makeRequest(tags, nsfw=false, limit=20, page=1) { + var url = constructRequestUrl(tags, nsfw, limit, page) + console.log("[Booru] Making request to " + url) + + const newResponse = root.booruResponseDataComponent.createObject(null, { + "provider": currentProvider, + "tags": tags, + "page": page, + "images": [], + "message": "" + }) + + var xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + const provider = providers[currentProvider] + let response; + if (provider.manualParseFunc) { + response = provider.manualParseFunc(xhr.responseText) + } else { + response = JSON.parse(xhr.responseText) + response = provider.mapFunc(response) + } + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + newResponse.images = response + newResponse.message = response.length > 0 ? "" : root.failMessage + + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + newResponse.message = root.failMessage + } finally { + root.runningRequests--; + root.responses = [...root.responses, newResponse] + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + else if (currentProvider == "zerochan") { + const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent + xhr.setRequestHeader("User-Agent", userAgent) + } + root.runningRequests++; + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } + + property var currentTagRequest: null + function triggerTagSearch(query) { + if (currentTagRequest) { + currentTagRequest.abort(); + } + + var provider = providers[currentProvider] + if (provider.fixedTags) { + root.tagSuggestion(query, provider.fixedTags) + return provider.fixedTags; + } else if (!provider.tagSearchTemplate) { + return + } + var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query)) + + var xhr = new XMLHttpRequest() + currentTagRequest = xhr + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + currentTagRequest = null + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + var response = JSON.parse(xhr.responseText) + response = provider.tagMapFunc(response) + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + root.tagSuggestion(query, response) + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } +} + diff --git a/configs/quickshell/services/BooruResponseData.qml b/configs/quickshell/services/BooruResponseData.qml new file mode 100644 index 0000000..2a61ff6 --- /dev/null +++ b/configs/quickshell/services/BooruResponseData.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import QtQuick; + +/** + * A booru response. + */ +QtObject { + property string provider + property var tags + property var page + property var images + property string message +} diff --git a/configs/quickshell/services/Brightness.qml b/configs/quickshell/services/Brightness.qml new file mode 100644 index 0000000..927a10c --- /dev/null +++ b/configs/quickshell/services/Brightness.qml @@ -0,0 +1,152 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://github.com/caelestia-dots/shell/ (`quickshell` branch) with modifications. +// License: GPLv3 + +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; + } + + 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({ + model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }); + } + } + } + onExited: root.ddcMonitorsChanged() + } + + Process { + id: setProc + } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? "" + property real brightness + property bool ready: false + + onBrightnessChanged: { + if (monitor.ready) { + root.brightnessChanged(); + } + } + + function initialize() { + monitor.ready = false; + 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.brightness = parseInt(current) / parseInt(max); + monitor.ready = true; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0.01, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + brightness = value; + setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"]; + setProc.startDetached(); + } + + Component.onCompleted: { + initialize(); + } + + onBusNumChanged: { + initialize(); + } + } + + Component { + id: monitorComp + + BrightnessMonitor {} + } + + 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/configs/quickshell/services/Cliphist.qml b/configs/quickshell/services/Cliphist.qml new file mode 100644 index 0000000..6e3f7a1 --- /dev/null +++ b/configs/quickshell/services/Cliphist.qml @@ -0,0 +1,101 @@ +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 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 (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 refresh() { + readProc.buffer = [] + readProc.running = true + } + + function copy(entry) { + Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(entry)}' | cliphist decode | wl-copy`]); + } + + Process { + id: deleteProc + property string entry: "" + command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | cliphist delete`] + function deleteEntry(entry) { + deleteProc.entry = entry; + deleteProc.running = true; + deleteProc.entry = ""; + } + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + function deleteEntry(entry) { + deleteProc.deleteEntry(entry); + } + + 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: ["cliphist", "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) + } + } + } +} diff --git a/configs/quickshell/services/DateTime.qml b/configs/quickshell/services/DateTime.qml new file mode 100644 index 0000000..16dc6c4 --- /dev/null +++ b/configs/quickshell/services/DateTime.qml @@ -0,0 +1,51 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property var clock: SystemClock { + id: clock + precision: SystemClock.Minutes + } + property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm") + property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy") + 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/configs/quickshell/services/Emojis.qml b/configs/quickshell/services/Emojis.qml new file mode 100644 index 0000000..436401b --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/services/FirstRunExperience.qml b/configs/quickshell/services/FirstRunExperience.qml new file mode 100644 index 0000000..f23cce5 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/services/HyprlandData.qml b/configs/quickshell/services/HyprlandData.qml new file mode 100644 index 0000000..07c2d89 --- /dev/null +++ b/configs/quickshell/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: ["bash", "-c", "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: ["bash", "-c", "hyprctl monitors -j"] + stdout: StdioCollector { + id: monitorsCollector + onStreamFinished: { + root.monitors = JSON.parse(monitorsCollector.text); + } + } + } + + Process { + id: getLayers + command: ["bash", "-c", "hyprctl layers -j"] + stdout: StdioCollector { + id: layersCollector + onStreamFinished: { + root.layers = JSON.parse(layersCollector.text); + } + } + } + + Process { + id: getWorkspaces + command: ["bash", "-c", "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: ["bash", "-c", "hyprctl activeworkspace -j"] + stdout: StdioCollector { + id: activeWorkspaceCollector + onStreamFinished: { + root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text); + } + } + } +} diff --git a/configs/quickshell/services/HyprlandKeybinds.qml b/configs/quickshell/services/HyprlandKeybinds.qml new file mode 100644 index 0000000..3381926 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/services/HyprlandXkb.qml b/configs/quickshell/services/HyprlandXkb.qml new file mode 100644 index 0000000..cace0d2 --- /dev/null +++ b/configs/quickshell/services/HyprlandXkb.qml @@ -0,0 +1,108 @@ +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: key + whitespace + description + const match = line.match(/^\s*(\S+)\s+(.+)$/); + if (match && match[2] === targetDescription) { + root.cachedLayoutCodes[match[2]] = match[1]; + root.currentLayoutCode = match[1]; + return true; + } + }); + // 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.layouts.length > 1) + "): " + // + root.layouts.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.split(",")[1]; + + // Update layout for on-screen keyboard (osk) + Config.options.osk.layout = root.currentLayoutName; + } else if (event.name == "configreloaded") { + // Mark layout code list to be updated when config is reloaded + root.needsLayoutRefresh = true; + } + } + } +} diff --git a/configs/quickshell/services/Hyprsunset.qml b/configs/quickshell/services/Hyprsunset.qml new file mode 100644 index 0000000..d6def43 --- /dev/null +++ b/configs/quickshell/services/Hyprsunset.qml @@ -0,0 +1,117 @@ +pragma Singleton + +import QtQuick +import qs.modules.common +import Quickshell +import Quickshell.Io + +/** + * 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 var manualActive + property string from: Config.options?.light?.night?.from ?? "19:00" // Default to 7 PM + property string to: Config.options?.light?.night?.to ?? "06:30" // Default to 6:30 AM + property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true) + property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000 // Default color temperature + 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 + + + function isNoLater(hour1, minute1, hour2, minute2) { + if (hour1 < hour2) + return true; + if (hour1 === hour2 && minute1 < minute2) + return true; + return false; + } + + + onClockMinuteChanged: reEvaluate() + onAutomaticChanged: { + root.manualActive = undefined; + root.firstEvaluation = true; + reEvaluate(); + } + function reEvaluate() { + const toHourIsNextDay = !isNoLater(fromHour, fromMinute, toHour, toMinute); + const toHourWrapped = toHourIsNextDay ? toHour + 24 : toHour; + const toMinuteWrapped = toMinute; + root.shouldBeOn = isNoLater(fromHour, fromMinute, clockHour, clockMinute) && isNoLater(clockHour, clockMinute, toHourWrapped, toMinuteWrapped); + 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"); + // console.log("[Hyprsunset] Fetched state:", output, "->", root.active); + } + } + } + + function toggle() { + if (root.manualActive === undefined) + root.manualActive = root.active; + + root.manualActive = !root.manualActive; + if (root.manualActive) { + root.enable(); + } else { + root.disable(); + } + } +} diff --git a/configs/quickshell/services/KeyringStorage.qml b/configs/quickshell/services/KeyringStorage.qml new file mode 100644 index 0000000..ce6b8eb --- /dev/null +++ b/configs/quickshell/services/KeyringStorage.qml @@ -0,0 +1,118 @@ +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", `echo $(secret-tool lookup 'application' 'illogical-impulse')`, + ] + stdout: SplitParser { + onRead: data => { + if(data.length === 0) 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 !== 0) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + root.loaded = true; + } + } + +} diff --git a/configs/quickshell/services/LatexRenderer.qml b/configs/quickshell/services/LatexRenderer.qml new file mode 100644 index 0000000..5baf336 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/services/MaterialThemeLoader.qml b/configs/quickshell/services/MaterialThemeLoader.qml new file mode 100644 index 0000000..8872c47 --- /dev/null +++ b/configs/quickshell/services/MaterialThemeLoader.qml @@ -0,0 +1,58 @@ +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) + } + + 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) + } + } +} diff --git a/configs/quickshell/services/MprisController.qml b/configs/quickshell/services/MprisController.qml new file mode 100644 index 0000000..60923a6 --- /dev/null +++ b/configs/quickshell/services/MprisController.qml @@ -0,0 +1,165 @@ +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 qs +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root; + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + + 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/configs/quickshell/services/Network.qml b/configs/quickshell/services/Network.qml new file mode 100644 index 0000000..50bfb67 --- /dev/null +++ b/configs/quickshell/services/Network.qml @@ -0,0 +1,93 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Simple polled network state service. + */ +Singleton { + id: root + + property bool wifi: true + property bool ethernet: false + property int updateInterval: 1000 + property string networkName: "" + property int networkStrength + property string materialSymbol: ethernet ? "lan" : + (Network.networkName.length > 0 && Network.networkName != "lo") ? ( + Network.networkStrength > 80 ? "signal_wifi_4_bar" : + Network.networkStrength > 60 ? "network_wifi_3_bar" : + Network.networkStrength > 40 ? "network_wifi_2_bar" : + Network.networkStrength > 20 ? "network_wifi_1_bar" : + "signal_wifi_0_bar" + ) : "signal_wifi_off" + function update() { + updateConnectionType.startCheck(); + updateNetworkName.running = true; + updateNetworkStrength.running = true; + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + root.update(); + interval = root.updateInterval; + } + } + + Process { + id: updateConnectionType + property string buffer + command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"] + 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'); + let hasEthernet = false; + let hasWifi = false; + lines.forEach(line => { + if (line.includes("ethernet")) + hasEthernet = true; + else if (line.includes("wireless")) + hasWifi = true; + }); + 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); + } + } + } +} diff --git a/configs/quickshell/services/Notifications.qml b/configs/quickshell/services/Notifications.qml new file mode 100644 index 0000000..bf5d4e7 --- /dev/null +++ b/configs/quickshell/services/Notifications.qml @@ -0,0 +1,289 @@ +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 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: 5000 + running: true + onTriggered: () => { + root.timeoutNotification(notificationId); + destroy() + } + } + + property bool silent: false + 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 ? 5000 : notification.expireTimeout, + }); + } + } + + root.notify(newNotifObject); + // console.log(notifToString(newNotifObject)); + notifFileView.setText(stringifyList(root.list)); + } + } + + 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 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/configs/quickshell/services/ResourceUsage.qml b/configs/quickshell/services/ResourceUsage.qml new file mode 100644 index 0000000..6505284 --- /dev/null +++ b/configs/quickshell/services/ResourceUsage.qml @@ -0,0 +1,62 @@ +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 { + property double memoryTotal: 1 + property double memoryFree: 1 + property double memoryUsed: memoryTotal - memoryFree + property double memoryUsedPercentage: memoryUsed / memoryTotal + property double swapTotal: 1 + property double swapFree: 1 + property double swapUsed: swapTotal - swapFree + property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 + property double cpuUsage: 0 + property var previousCpuStats + + 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 } + } + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { id: fileMeminfo; path: "/proc/meminfo" } + FileView { id: fileStat; path: "/proc/stat" } +} diff --git a/configs/quickshell/services/SystemInfo.qml b/configs/quickshell/services/SystemInfo.qml new file mode 100644 index 0000000..a8da8e1 --- /dev/null +++ b/configs/quickshell/services/SystemInfo.qml @@ -0,0 +1,114 @@ +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 "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; + 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/configs/quickshell/services/Todo.qml b/configs/quickshell/services/Todo.qml new file mode 100644 index 0000000..93227cb --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/services/Weather.qml b/configs/quickshell/services/Weather.qml new file mode 100644 index 0000000..c8d46e2 --- /dev/null +++ b/configs/quickshell/services/Weather.qml @@ -0,0 +1,154 @@ +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 + + 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 + }) + + 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 = ""; + 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.temp += " (" + (data?.current?.FeelsLikeF || 0) + ") "; + temp.temp += "\u{02109}"; + } 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.temp += " (" + (data?.current?.FeelsLikeC || 0) + ") "; + temp.temp += "\u{02103}"; + } + 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/configs/quickshell/services/Ydotool.qml b/configs/quickshell/services/Ydotool.qml new file mode 100644 index 0000000..f25b093 --- /dev/null +++ b/configs/quickshell/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/configs/quickshell/services/ai/AiMessageData.qml b/configs/quickshell/services/ai/AiMessageData.qml new file mode 100644 index 0000000..023458d --- /dev/null +++ b/configs/quickshell/services/ai/AiMessageData.qml @@ -0,0 +1,21 @@ +import QtQuick; + +/** + * Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure. + */ +QtObject { + property string role + property string content + property string rawContent + property string model + property bool thinking: true + property bool done: false + property var annotations: [] + property var annotationSources: [] + property list searchQueries: [] + property string functionName + property var functionCall + property string functionResponse + property bool functionPending: false + property bool visibleToUser: true +} diff --git a/configs/quickshell/services/ai/AiModel.qml b/configs/quickshell/services/ai/AiModel.qml new file mode 100644 index 0000000..7cf9852 --- /dev/null +++ b/configs/quickshell/services/ai/AiModel.qml @@ -0,0 +1,32 @@ +import QtQuick; + +/** + * An AI model representation. + * - name: Friendly name of the model + * - icon: Icon name of the model + * - description: Description of the model + * - endpoint: Endpoint of the model + * - model: Model code (like gpt-4.1 or gemini-2.5-flash) + * - requires_key: Whether the model requires an API key + * - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + * - key_get_link: Link to get an API key + * - key_get_description: Description of pricing and how to get an API key + * - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + * - extraParams: Extra parameters to be passed to the model. This is a JSON object. + */ + +QtObject { + property string name + property string icon + property string description + property string homepage + property string endpoint + property string model + property bool requires_key: true + property string key_id + property string key_get_link + property string key_get_description + property string api_format: "openai" + property var tools + property var extraParams: ({}) +} diff --git a/configs/quickshell/services/ai/ApiStrategy.qml b/configs/quickshell/services/ai/ApiStrategy.qml new file mode 100644 index 0000000..75736d6 --- /dev/null +++ b/configs/quickshell/services/ai/ApiStrategy.qml @@ -0,0 +1,10 @@ +import QtQuick + +QtObject { + function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") } + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { throw new Error("Not implemented") } + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") } + function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") } + function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling + function reset() { } // Reset any internal state if needed +} diff --git a/configs/quickshell/services/ai/GeminiApiStrategy.qml b/configs/quickshell/services/ai/GeminiApiStrategy.qml new file mode 100644 index 0000000..12c775c --- /dev/null +++ b/configs/quickshell/services/ai/GeminiApiStrategy.qml @@ -0,0 +1,155 @@ +import QtQuick + +ApiStrategy { + property string buffer: "" + + function buildEndpoint(model: AiModel): string { + const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}` + // console.log("[AI] Endpoint: " + result); + return result; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "contents": messages.map(message => { + const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role; + const usingSearch = tools[0]?.google_search !== undefined + if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionCall: { + "name": message.functionName, + } + }] + } + } + if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionResponse: { + "name": message.functionName, + "response": { "content": message.functionResponse } + } + }] + } + } + return { + "role": geminiApiRoleName, + "parts": [{ + text: message.rawContent, + }] + } + }), + "tools": tools, + "system_instruction": { + "parts": [{ text: systemPrompt }] + }, + "generationConfig": { + "temperature": temperature, + }, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + // Gemini doesn't use Authorization header, key is in URL + return ""; + } + + function parseResponseLine(line, message) { + if (line.startsWith("[")) { + buffer += line.slice(1).trim(); + } else if (line === "]") { + buffer += line.slice(0, -1).trim(); + return parseBuffer(message); + } else if (line.startsWith(",")) { + return parseBuffer(message); + } else { + buffer += line.trim(); + } + return {}; + } + + function parseBuffer(message) { + // console.log("[Ai] Gemini buffer: ", buffer); + let finished = false; + try { + if (buffer.length === 0) return {}; + const dataJson = JSON.parse(buffer); + if (!dataJson.candidates) return {}; + + if (dataJson.candidates[0]?.finishReason) { + finished = true; + } + + // Function call handling + if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) { + const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall; + message.functionName = functionCall.name; + message.functionCall = functionCall.name; + const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n` + message.rawContent += newContent; + message.content += newContent; + return { functionCall: { name: functionCall.name, args: functionCall.args }, finished: finished }; + } + + // Normal text response + const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text + message.rawContent += responseContent; + message.content += responseContent; + + // Handle annotations and metadata + const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => { + return { + "type": "url_citation", + "text": chunk?.web?.title, + "url": chunk?.web?.uri, + } + }) ?? []; + + const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => { + return { + "type": "url_citation", + "start_index": citation.segment?.startIndex, + "end_index": citation.segment?.endIndex, + "text": citation?.segment.text, + "url": annotationSources[citation.groundingChunkIndices[0]]?.url, + "sources": citation.groundingChunkIndices + } + }); + message.annotationSources = annotationSources; + message.annotations = annotations; + message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? []; + + // Usage metadata + if (dataJson.usageMetadata) { + return { + tokenUsage: { + input: dataJson.usageMetadata.promptTokenCount ?? -1, + output: dataJson.usageMetadata.candidatesTokenCount ?? -1, + total: dataJson.usageMetadata.totalTokenCount ?? -1 + }, + finished: finished + }; + } + + } catch (e) { + console.log("[AI] Gemini: Could not parse buffer: ", e); + message.rawContent += buffer; + message.content += buffer; + } finally { + buffer = ""; + } + return { finished: finished }; + } + + function onRequestFinished(message) { + return parseBuffer(message); + } + + function reset() { + buffer = ""; + } +} diff --git a/configs/quickshell/services/ai/MistralApiStrategy.qml b/configs/quickshell/services/ai/MistralApiStrategy.qml new file mode 100644 index 0000000..dfcb950 --- /dev/null +++ b/configs/quickshell/services/ai/MistralApiStrategy.qml @@ -0,0 +1,124 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0 + let messageData = { + "role": message.role, + "content": message.rawContent, + } + if (hasFunctionCall) { + if (message.functionResponse?.length > 0) { + messageData.name = message.functionName; // Does the func call also need this name? or just the func output? + messageData.role = "tool"; + messageData.content = message.functionResponse; + messageData.tool_call_id = message.functionCall.id + } + } + return messageData + }), + ], + "stream": true, + "temperature": temperature, + "tools": tools, + }; + // console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2)); + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + // Function call + if (dataJson.choices[0]?.delta?.tool_calls) { + const functionCall = dataJson.choices[0].delta.tool_calls[0]; + const functionName = functionCall.function.name; + const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string??? + const functionId = functionCall.id; + const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`; + message.rawContent += newContent; + message.content += newContent; + message.functionName = functionName; + message.functionCall = functionName; + return { functionCall: { name: functionName, args: functionArgs, id: functionId } }; + } + + // Thinking? + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n
\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + // Text + message.content += newContent; + message.rawContent += newContent; + + if (`dataJson`.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] Mistral: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/configs/quickshell/services/ai/OpenAiApiStrategy.qml b/configs/quickshell/services/ai/OpenAiApiStrategy.qml new file mode 100644 index 0000000..a5792ac --- /dev/null +++ b/configs/quickshell/services/ai/OpenAiApiStrategy.qml @@ -0,0 +1,97 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + return { + "role": message.role, + "content": message.rawContent, + } + }), + ], + "stream": true, + "temperature": temperature, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + message.content += newContent; + message.rawContent += newContent; + + if (dataJson.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] OpenAI: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + // OpenAI format doesn't need special finish handling + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/configs/quickshell/services/qmldir b/configs/quickshell/services/qmldir new file mode 100644 index 0000000..8fb46eb --- /dev/null +++ b/configs/quickshell/services/qmldir @@ -0,0 +1,27 @@ +singleton Ai 1.0 Ai.qml +singleton AppSearch 1.0 AppSearch.qml +singleton Audio 1.0 Audio.qml +singleton Battery 1.0 Battery.qml +singleton Bluetooth 1.0 Bluetooth.qml +singleton Booru 1.0 Booru.qml +BooruResponseData 1.0 BooruResponseData.qml +singleton Brightness 1.0 Brightness.qml +singleton Cliphist 1.0 Cliphist.qml +singleton DateTime 1.0 DateTime.qml +singleton Emojis 1.0 Emojis.qml +singleton FirstRunExperience 1.0 FirstRunExperience.qml +singleton HyprlandData 1.0 HyprlandData.qml +singleton HyprlandKeybinds 1.0 HyprlandKeybinds.qml +singleton HyprlandXkb 1.0 HyprlandXkb.qml +singleton Hyprsunset 1.0 Hyprsunset.qml +singleton KeyringStorage 1.0 KeyringStorage.qml +singleton LatexRenderer 1.0 LatexRenderer.qml +singleton MaterialThemeLoader 1.0 MaterialThemeLoader.qml +singleton MprisController 1.0 MprisController.qml +singleton Network 1.0 Network.qml +singleton Notifications 1.0 Notifications.qml +singleton ResourceUsage 1.0 ResourceUsage.qml +singleton SystemInfo 1.0 SystemInfo.qml +singleton Todo 1.0 Todo.qml +singleton Weather 1.0 Weather.qml +singleton Ydotool 1.0 Ydotool.qml diff --git a/configs/quickshell/settings.qml b/configs/quickshell/settings.qml new file mode 100644 index 0000000..a15670b --- /dev/null +++ b/configs/quickshell/settings.qml @@ -0,0 +1,248 @@ +//@ 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 qs +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("Style"), + icon: "palette", + component: "modules/settings/StyleConfig.qml" + }, + { + name: Translation.tr("Interface"), + icon: "cards", + 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() + } + + minimumWidth: 600 + minimumHeight: 400 + 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.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.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 + iconText: "edit" + buttonText: Translation.tr("Edit config") + expanded: navRail.expanded + onClicked: { + Qt.openUrlExternally(`${Directories.config}/illogical-impulse/config.json`); + } + + StyledToolTip { + extraVisibleCondition: !navRail.expanded + content: "Edit shell config file" + } + } + + NavigationRailTabArray { + currentIndex: root.currentPage + expanded: navRail.expanded + Repeater { + model: root.pages + NavigationRailButton { + required property var index + required property var modelData + toggled: root.currentPage === index + onClicked: root.currentPage = index; + expanded: navRail.expanded + buttonIcon: modelData.icon + 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 + source: root.pages[0].component + Connections { + target: root + function onCurrentPageChanged() { + if (pageLoader.sourceComponent !== root.pages[root.currentPage].component) { + 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 + } + PropertyAction { + target: pageLoader + property: "source" + value: root.pages[root.currentPage].component + } + NumberAnimation { + target: pageLoader + properties: "opacity" + from: 0 + to: 1 + duration: 200 + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.emphasizedLastHalf + } + } + } + } + } + } +} diff --git a/configs/quickshell/shell.qml b/configs/quickshell/shell.qml new file mode 100644 index 0000000..86b8cc2 --- /dev/null +++ b/configs/quickshell/shell.qml @@ -0,0 +1,78 @@ +//@ 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 "./modules/common/" +import "./modules/background/" +import "./modules/bar/" +import "./modules/cheatsheet/" +import "./modules/dock/" +import "./modules/lock/" +import "./modules/mediaControls/" +import "./modules/notificationPopup/" +import "./modules/onScreenDisplay/" +import "./modules/onScreenKeyboard/" +import "./modules/overview/" +import "./modules/screenCorners/" +import "./modules/session/" +import "./modules/sidebarLeft/" +import "./modules/sidebarRight/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import "./ii/qs/" + +ShellRoot { + // Enable/disable modules here. False = not loaded at all, so rest assured + // no unnecessary stuff will take up memory if you decide to only use, say, the overview. + property bool enableBar: true + property bool enableBackground: true + property bool enableCheatsheet: true + property bool enableDock: true + property bool enableLock: true + property bool enableMediaControls: true + property bool enableNotificationPopup: true + property bool enableOnScreenDisplayBrightness: true + property bool enableOnScreenDisplayVolume: true + property bool enableOnScreenKeyboard: true + property bool enableOverview: true + property bool enableReloadPopup: true + property bool enableScreenCorners: true + property bool enableSession: true + property bool enableSidebarLeft: true + property bool enableSidebarRight: true + // property bool enableSettings: true // Temporarily disabled + + // Force initialization of some singletons + Component.onCompleted: { + Cliphist.refresh() + FirstRunExperience.load() + Hyprsunset.load() + MaterialThemeLoader.reapplyTheme() + } + + LazyLoader { active: enableBar; component: Bar {} } + LazyLoader { active: enableBackground; component: Background {} } + LazyLoader { active: enableCheatsheet; component: Cheatsheet {} } + LazyLoader { active: enableDock && Config.options.dock.enable; component: Dock {} } + LazyLoader { active: enableLock; component: Lock {} } + LazyLoader { active: enableMediaControls; component: MediaControls {} } + LazyLoader { active: enableNotificationPopup; component: NotificationPopup {} } + LazyLoader { active: enableOnScreenDisplayBrightness; component: OnScreenDisplayBrightness {} } + LazyLoader { active: enableOnScreenDisplayVolume; component: OnScreenDisplayVolume {} } + LazyLoader { active: enableOnScreenKeyboard; component: OnScreenKeyboard {} } + LazyLoader { active: enableOverview; component: Overview {} } + LazyLoader { active: enableReloadPopup; component: ReloadPopup {} } + LazyLoader { active: enableScreenCorners; component: ScreenCorners {} } + LazyLoader { active: enableSession; component: Session {} } + LazyLoader { active: enableSidebarLeft; component: SidebarLeft {} } + LazyLoader { active: enableSidebarRight; component: SidebarRight {} } +} + diff --git a/configs/quickshell/translations/en_US.json b/configs/quickshell/translations/en_US.json new file mode 100644 index 0000000..fe8c645 --- /dev/null +++ b/configs/quickshell/translations/en_US.json @@ -0,0 +1,314 @@ +{ + "Mo": "Mo/*keep*/", + "Tu": "Tu/*keep*/", + "We": "We/*keep*/", + "Th": "Th/*keep*/", + "Fr": "Fr/*keep*/", + "Sa": "Sa/*keep*/", + "Su": "Su/*keep*/", + "%1 characters": "%1 characters", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "No further instruction provided", + "Action": "Action", + "Add": "Add", + "Add task": "Add task", + "All-rounder | Good quality, decent quantity": "All-rounder | Good quality, decent quantity", + "Allow NSFW": "Allow NSFW", + "Allow NSFW content": "Allow NSFW content", + "Anime": "Anime", + "Anime boorus": "Anime boorus", + "App": "App", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel", + "Bluetooth": "Bluetooth", + "Brightness": "Brightness", + "Cancel": "Cancel", + "Chain of Thought": "Chain of Thought", + "Cheat sheet": "Cheat sheet", + "Choose model": "Choose model", + "Clean stuff | Excellent quality, no NSFW": "Clean stuff | Excellent quality, no NSFW", + "Clear": "Clear", + "Clear chat history": "Clear chat history", + "Clear the current list of images": "Clear the current list of images", + "Close": "Close", + "Copy": "Copy", + "Copy code": "Copy code", + "Delete": "Delete", + "Desktop": "Desktop", + "Disable NSFW content": "Disable NSFW content", + "Done": "Done", + "Download": "Download", + "Edit": "Edit", + "Enter text to translate...": "Enter text to translate...", + "Finished tasks will go here": "Finished tasks will go here", + "For desktop wallpapers | Good quality": "For desktop wallpapers | Good quality", + "For storing API keys and other sensitive information": "For storing API keys and other sensitive information", + "Game mode": "Game mode", + "Get the next page of results": "Get the next page of results", + "Hibernate": "Hibernate", + "Input": "Input", + "Intelligence": "Intelligence", + "Interface": "Interface", + "Invalid arguments. Must provide `key` and `value`.": "Invalid arguments. Must provide `key` and `value`.", + "Jump to current month": "Jump to current month", + "Keep system awake": "Keep system awake", + "Large images | God tier quality, no NSFW.": "Large images | God tier quality, no NSFW.", + "Large language models": "Large language models", + "Launch": "Launch", + "Lock": "Lock", + "Logout": "Logout", + "Markdown test": "Markdown test", + "Math result": "Math result", + "Night Light": "Night Light", + "No audio source": "No audio source", + "No media": "No media", + "No notifications": "No notifications", + "Not visible to model": "Not visible to model", + "Nothing here!": "Nothing here!", + "Notifications": "Notifications", + "OK": "OK", + "Open file link": "Open file link", + "Output": "Output", + "Reboot": "Reboot", + "Reboot to firmware settings": "Reboot to firmware settings", + "Reload Hyprland & Quickshell": "Reload Hyprland & Quickshell", + "Run": "Run", + "Run command": "Run command", + "Save": "Save", + "Save to Downloads": "Save to Downloads", + "Search": "Search", + "Search the web": "Search the web", + "Search, calculate or run": "Search, calculate or run", + "Select Language": "Select Language", + "Session": "Session", + "Set API key": "Set API key", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.", + "Set the current API provider": "Set the current API provider", + "Shutdown": "Shutdown", + "Silent": "Silent", + "Sleep": "Sleep", + "System": "System", + "Task Manager": "Task Manager", + "Task description": "Task description", + "Temperature must be between 0 and 2": "Temperature must be between 0 and 2", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "The hentai one | Great quantity, a lot of NSFW, quality varies wildly", + "The popular one | Best quantity, but quality can vary wildly": "The popular one | Best quantity, but quality can vary wildly", + "Thinking": "Thinking", + "Translation goes here...": "Translation goes here...", + "Translator": "Translator", + "Unfinished": "Unfinished", + "Unknown": "Unknown", + "Unknown Album": "Unknown Album", + "Unknown Artist": "Unknown Artist", + "Unknown Title": "Unknown Title", + "View Markdown source": "View Markdown source", + "Volume": "Volume", + "Volume mixer": "Volume mixer", + "Waifus only | Excellent quality, limited quantity": "Waifus only | Excellent quality, limited quantity", + "Waiting for response...": "Waiting for response...", + "Workspace": "Workspace", + "Set with /mode PROVIDER": "Set with /mode PROVIDER", + "Invalid API provider. Supported: \n-": "Invalid API provider. Supported: \n-", + "Unknown command:": "Unknown command:", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window", + "The current API used. Endpoint:": "The current API used. Endpoint:", + "Provider set to": "Provider set to", + "Invalid model. Supported: \n```": "Invalid model. Supported: \n```", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number", + "Online | Google's model\nGives up-to-date information with search.": "Online | Google's model\nGives up-to-date information with search.", + "Switched to search mode. Continue with the user's request.": "Switched to search mode. Continue with the user's request.", + "Experimental | Online | Google's model\nCan do a little more but doesn't search quickly": "Experimental | Online | Google's model\nCan do a little more but doesn't search quickly", + "Settings": "Settings", + "Save chat": "Save chat", + "Load chat": "Load chat", + "or": "or", + "Set the system prompt for the model.": "Set the system prompt for the model.", + "To Do": "To Do", + "Calendar": "Calendar", + "Advanced": "Advanced", + "About": "About", + "Services": "Services", + "Style": "Style", + "Edit config": "Edit config", + "Colors & Wallpaper": "Colors & Wallpaper", + "Light": "Light", + "Dark": "Dark", + "Material palette": "Material palette", + "Fidelity": "Fidelity", + "Fruit Salad": "Fruit Salad", + "Alternatively use /dark, /light, /img in the launcher": "Alternatively use /dark, /light, /img in the launcher", + "Fake screen rounding": "Fake screen rounding", + "When not fullscreen": "When not fullscreen", + "Choose file": "Choose file", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers", + "Be patient...": "Be patient...", + "Decorations & Effects": "Decorations & Effects", + "Tonal Spot": "Tonal Spot", + "Shell windows": "Shell windows", + "Auto": "Auto", + "Wallpaper": "Wallpaper", + "Content": "Content", + "Title bar": "Title bar", + "Transparency": "Transparency", + "Expressive": "Expressive", + "Yes": "Yes", + "Enable": "Enable", + "Rainbow": "Rainbow", + "Might look ass. Unsupported.": "Might look ass. Unsupported.", + "Monochrome": "Monochrome", + "Random: Konachan": "Random: Konachan", + "Center title": "Center title", + "Neutral": "Neutral", + "Pick wallpaper image on your system": "Pick wallpaper image on your system", + "No": "No", + "AI": "AI", + "Local only": "Local only", + "Policies": "Policies", + "Weeb": "Weeb", + "Closet": "Closet", + "Bar style": "Bar style", + "Show next time": "Show next time", + "Usage": "Usage", + "Plain rectangle": "Plain rectangle", + "Useless buttons": "Useless buttons", + "GitHub": "GitHub", + "Style & wallpaper": "Style & wallpaper", + "Configuration": "Configuration", + "Change any time later with /dark, /light, /img in the launcher": "Change any time later with /dark, /light, /img in the launcher", + "Keybinds": "Keybinds", + "Float": "Float", + "Hug": "Hug", + "Yooooo hi there": "Yooooo hi there", + "illogical-impulse Welcome": "illogical-impulse Welcome", + "Info": "Info", + "Volume limit": "Volume limit", + "Prevents abrupt increments and restricts volume limit": "Prevents abrupt increments and restricts volume limit", + "Resources": "Resources", + "12h am/pm": "12h am/pm", + "Base URL": "Base URL", + "Audio": "Audio", + "Networking": "Networking", + "Format": "Format", + "Time": "Time", + "Battery": "Battery", + "Prefixes": "Prefixes", + "Emojis": "Emojis", + "Earbang protection": "Earbang protection", + "Automatically suspends the system when battery is low": "Automatically suspends the system when battery is low", + "Automatic suspend": "Automatic suspend", + "Suspend at": "Suspend at", + "Max allowed increase": "Max allowed increase", + "Web search": "Web search", + "Polling interval (ms)": "Polling interval (ms)", + "Clipboard": "Clipboard", + "Low warning": "Low warning", + "24h": "24h", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Use Levenshtein distance-based algorithm instead of fuzzy", + "System prompt": "System prompt", + "12h AM/PM": "12h AM/PM", + "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)": "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)", + "Critical warning": "Critical warning", + "User agent (for services that require it)": "User agent (for services that require it)", + "Such regions 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.": "Such regions 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.", + "Note: turning off can hurt readability": "Note: turning off can hurt readability", + "Workspaces shown": "Workspaces shown", + "Dark/Light toggle": "Dark/Light toggle", + "Dock": "Dock", + "Weather": "Weather", + "Pinned on startup": "Pinned on startup", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience", + "Appearance": "Appearance", + "Always show numbers": "Always show numbers", + "Buttons": "Buttons", + "Keyboard toggle": "Keyboard toggle", + "Scale (%)": "Scale (%)", + "Overview": "Overview", + "Rows": "Rows", + "Borderless": "Borderless", + "Screenshot tool": "Screenshot tool", + "Number show delay when pressing Super (ms)": "Number show delay when pressing Super (ms)", + "Timeout (ms)": "Timeout (ms)", + "Show app icons": "Show app icons", + "Workspaces": "Workspaces", + "Columns": "Columns", + "On-screen display": "On-screen display", + "Screen snip": "Screen snip", + "Mic toggle": "Mic toggle", + "Hover to reveal": "Hover to reveal", + "Bar": "Bar", + "Show background": "Show background", + "Show regions of potential interest": "Show regions of potential interest", + "Color picker": "Color picker", + "Help & Support": "Help & Support", + "Discussions": "Discussions", + "Color generation": "Color generation", + "Dotfiles": "Dotfiles", + "Distro": "Distro", + "Privacy Policy": "Privacy Policy", + "Documentation": "Documentation", + "Shell & utilities theming must also be enabled": "Shell & utilities theming must also be enabled", + "illogical-impulse": "illogical-impulse", + "Donate": "Donate", + "Terminal": "Terminal", + "Shell & utilities": "Shell & utilities", + "Qt apps": "Qt apps", + "Report a Bug": "Report a Bug", + "Issues": "Issues", + "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit": "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit", + "Current model: %1\nSet it with %2model MODEL": "Current model: %1\nSet it with %2model MODEL", + "Message the model... \"%1\" for commands": "Message the model... \"%1\" for commands", + "No API key set for %1": "No API key set for %1", + "Loaded the following system prompt\n\n---\n\n%1": "Loaded the following system prompt\n\n---\n\n%1", + "%1 | Right-click to configure": "%1 | Right-click to configure", + "API key set for %1": "API key set for %1", + "Online via %1 | %2's model": "Online via %1 | %2's model", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Current API endpoint: %1\nSet it with %2mode PROVIDER", + "Go to source (%1)": "Go to source (%1)", + "Temperature set to %1": "Temperature set to %1", + "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3", + "Enter tags, or \"%1\" for commands": "Enter tags, or \"%1\" for commands", + "%1 queries pending": "%1 queries pending", + "API key:\n\n```txt\n%1\n```": "API key:\n\n```txt\n%1\n```", + "Uptime: %1": "Uptime: %1", + "%1 Safe Storage": "%1 Safe Storage", + "%1 does not require an API key": "%1 does not require an API key", + "Temperature: %1": "Temperature: %1", + "Model set to %1": "Model set to %1", + "Page %1": "Page %1", + "Local Ollama model | %1": "Local Ollama model | %1", + "The current system prompt is\n\n---\n\n%1": "The current system prompt is\n\n---\n\n%1", + "Unknown function call: %1": "Unknown function call: %1", + "%1 notifications": "%1 notifications", + "Load chat from %1": "Load chat from %1", + "Load prompt from %1": "Load prompt from %1", + "Save chat to %1": "Save chat to %1", + "Weather Service": "Weather Service", + "Cannot find a GPS service. Using the fallback method instead.": "Cannot find a GPS service. Using the fallback method instead.", + "Critically low battery": "Critically low battery", + "Select output device": "Select output device", + "Code saved to file": "Code saved to file", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Online models disallowed\n\nControlled by `policies.ai` config option", + "Scroll to change volume": "Scroll to change volume", + "Elements": "Elements", + "%1 โ€ข %2 tasks": "%1 โ€ข %2 tasks", + "Download complete": "Download complete", + "Please charge!\nAutomatic suspend triggers at %1": "Please charge!\nAutomatic suspend triggers at %1", + "Cloudflare WARP": "Cloudflare WARP", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Scroll to change brightness": "Scroll to change brightness", + "Connection failed. Please inspect manually with the warp-cli command": "Connection failed. Please inspect manually with the warp-cli command", + "Select input device": "Select input device", + "Registration failed. Please inspect manually with the warp-cli command": "Registration failed. Please inspect manually with the warp-cli command", + "Consider plugging in your device": "Consider plugging in your device", + "Low battery": "Low battery", + "Saved to %1": "Saved to %1", + "Sunset": "Sunset", + "UV Index": "UV Index", + "Humidity": "Humidity", + "Wind": "Wind", + "Sunrise": "Sunrise", + "Pressure": "Pressure", + "Visibility": "Visibility", + "Precipitation": "Precipitation" +} \ No newline at end of file diff --git a/configs/quickshell/translations/it_IT.json b/configs/quickshell/translations/it_IT.json new file mode 100644 index 0000000..bf875af --- /dev/null +++ b/configs/quickshell/translations/it_IT.json @@ -0,0 +1,306 @@ +{ + "Launch": "Avvia", + "Columns": "Colonne", + "Save": "Salva", + "Temperature: %1": "Temperatura: %1", + "Night Light | Right-click to toggle Auto mode": "Modalitร  notte", + "Silent": "Silenzia", + "To Do": "Promemoria", + "Action": "Comandi", + "Search the web": "Cerca sul web", + "Workspace": "Spazio di lavoro", + "Desktop": "Scrivania", + "Settings": "Impostazioni", + "Math result": "Risultato", + "Calendar": "Calendario", + "Run": "Esegui", + "Cancel": "Cancella", + "Uptime: %1": "Tempo di attivitร : %1", + "Search": "Cerca", + "Battery": "Batteria", + "Weather": "Meteo", + "Brightness": "Luminositร ", + "Clear": "Cancella", + "No notifications": "Nessuna notifica", + "No media": "Non in riproduzione", + "Add task": "Aggiungi promemoria", + "Run command": "Esegui comando", + "Game mode": "Modalitร  gioco", + "Reload Hyprland & Quickshell": "Riavvia Hyprland e Quickshell", + "Task description": "Titolo promemoria", + "%1 | Right-click to configure": "%1", + "Done": "Completati", + "Keep system awake": "Mantieni schermo attivo", + "Search, calculate or run": "Cerca, calcola o esegui", + "Copy": "Copia", + "Rows": "Righe", + "Session": "Sessione", + "Notifications": "Notifiche", + "Unfinished": "Da completare", + "Add": "Aggiungi", + "Nothing here!": "Nessun promemoria", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Gi", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Do", + "Edit config": "Apri config.", + "Center title": "Titolo centrato", + "Elements": "Elementi", + "Color picker": "Selettore colore", + "Title bar": "Barra del titolo", + "Sleep": "Sospendi", + "Transparency": "Trasparenza", + "Bluetooth": "Bluetooth", + "UV Index": "Indice UV", + "Bar": "Barra", + "Format": "Formato", + "Select output device": "Seleziona dispositivo di output", + "Pressure": "Pressione", + "Volume": "Volume", + "Volume mixer": "Mixer volume", + "Interface": "Interfaccia", + "Workspaces": "Spazi di lavoro", + "Dark": "Scuro", + "%1 notifications": "%1 notifiche", + "Reboot": "Riavvia", + "No": "No", + "Wind": "Vento", + "Humidity": "Umiditร ", + "Select Language": "Seleziona lingua", + "Wallpaper": "Sfondo", + "Copy code": "Copia codice", + "Allow NSFW": "Mostra NSFW", + "Colors & Wallpaper": "Sfondo e stile", + "Shutdown": "Spegni", + "Decorations & Effects": "Decorazioni e effetti", + "Translation goes here...": "Traduzione", + "Polling interval (ms)": "Intervallo di polling (ms)", + "System prompt": "Prompt di sistema", + "Base URL": "URL base", + "Always show numbers": "Mostra numeri", + "Wallpaper parallax": "Effetto parallasse sfondo", + "Plain rectangle": "Semplice", + "illogical-impulse Welcome": "Benvenuto su illogical-impulse", + "Local only": "Solo locale", + "Chain of Thought": "Catena di pensiero", + "Dark/Light toggle": "Modalitร  chiaro/scuro", + "Screen snip": "Cattura schermo", + "Style & wallpaper": "Sfondo e stile", + "Show app icons": "Mostra icone app", + "Useless buttons": "Tasti inutili", + "Scale (%)": "Dimensione (%)", + "Show background": "Mostra sfondo", + "Intelligence": "AI", + "Appearance": "Aspetto", + "Enable": "Abilita", + "Borderless": "Senza bordi", + "Random: Konachan": "Casuale: Konachan", + "Yes": "Sรฌ", + "Documentation": "Documentazione", + "Unknown Artist": "Artista sconosciuto", + "Help & Support": "Aiuto e supporto", + "Report a Bug": "Segnala un bug", + "Sunset": "Tramonto", + "Weeb": "Anime", + "Shell windows": "Finestre", + "Workspaces shown": "Numero spazi di lavoro", + "Screenshot tool": "Cattura schermo", + "Enter text to translate...": "Inserisci il testo qui", + "Unknown Album": "Album sconosciuto", + "Hug": "Stondato", + "Scroll to change brightness": "Scorri per cambiare luminositร ", + "Privacy Policy": "Privacy policy", + "12h AM/PM": "12 ore (AM/PM)", + "Material palette": "Tavolozza dei colori", + "No audio source": "Nessuna sorgente audio", + "Download": "Scarica", + "Prefixes": "Prefissi", + "Show next time": "Mostra al prossimo avvio", + "Lock": "Blocca", + "Scroll to change volume": "Scorri per cambiare volume", + "Unknown": "Sconosciuto", + "Jump to current month": "Torna al mese corrente", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Terminal": "Terminale", + "About": "Informazioni", + "Precipitation": "Precipitazioni", + "Keybinds": "Comandi", + "Select input device": "Seleziona dispositivo di input", + "When not fullscreen": "Tranne a schermo intero", + "Unknown Title": "Titolo sconosciuto", + "Task Manager": "Gestione attivitร ", + "System": "Sistema", + "Choose file": "Scegli file", + "Pinned on startup": "Fissa sullo schermo", + "Policies": "Servizi", + "View Markdown source": "Mostra Markdown", + "Sunrise": "Alba", + "Configuration": "Configurazione", + "Overview": "Panoramica", + "Translator": "Traduttore", + "Finished tasks will go here": "Nessun promemoria", + "Mic toggle": "Microfono", + "Light": "Chiaro", + "Bar style": "Stile barra", + "Advanced": "Avanzate", + "Web search": "Cerca sul web", + "12h am/pm": "12 ore (am/pm)", + "Services": "Servizi", + "Donate": "Supporta", + "Resources": "Risorse", + "Float": "Fluttuante", + "Fake screen rounding": "Bordi curvi schermo", + "Hibernate": "Iberna", + "Visibility": "Visibilitร ", + "Delete": "Elimina", + "Style": "Stile", + "Page %1": "Pagina %1", + "Keyboard toggle": "Tastiera virtuale", + "Buttons": "Pulsanti", + "24h": "24 ore", + "Time": "Ora", + "Clipboard": "Appunti", + "or": "o", + "Edit": "Modifica", + "Yooooo hi there": "Ciao bellissimษ™", + "Emojis": "Emoji", + "Shell & utilities": "App e shell", + "Reboot to firmware settings": "Riavvia alle impostazioni firmware", + "No API key set for %1": "Chiave API non impostata per %1", + "Disable NSFW content": "Nascondi contenuti NSFW", + "Closet": "Nascosto", + "Depends on sidebars": "Abilita per barre laterali", + "Invalid model. Supported: \n```": "Modello non valido. Supportati: \n```", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Usa /key per utilizzare i modelli online\nCtrl+O per espandere\nCtrl+P per separare in finestra", + "Not visible to model": "Non visible al modello", + "Local Ollama model | %1": "Modello Ollama locale | %1", + "Open file link": "Apri link al file", + "Waiting for response...": "In attesa di risposta...", + "Cheat sheet": "Prontuario", + "Allow NSFW content": "Mostra contenuti NSFW", + "%1 characters": "%1 caratteri", + "Model set to %1": "Modello impostato su %1", + "Be patient...": "Attendi...", + "User agent (for services that require it)": "User agent (usato dai servizi)", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Endpoint API: %1\nUsa /mode per cambiarlo", + "For desktop wallpapers | Good quality": "Per sfondi del desktop | Buona qualitร ", + "For storing API keys and other sensitive information": "Gestione credenziali e informazioni sensibili", + "Your package manager is running": "Il package manager รจ in esecuzione", + "Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search": "Sperimentale | Online | Modello di Google\nPiรน potente ma richiede piรน risorse", + "Provider set to": "Provider impostato su", + "Color generation": "Tavolozza dei colori", + "Temperature must be between 0 and 2": "Il valore della temperatura deve essere fra 0 e 2", + "Earbang protection": "Protezione udito", + "Online | Google's model\nGives up-to-date information with search.": "Online | Modello di Google\nRestituisce informazioni attuali tramite ricerca.", + "Alternatively use /dark, /light, /img in the launcher": "Oppure usa /dark, /light, /img nel launcher", + "Change any time later with /dark, /light, /img in the launcher": "Oppure usa /dark, /light, /img nel launcher", + "Current model: %1\nSet it with %2model MODEL": "Modello attuale: %1\nUsa /model per cambiarlo", + "Waifus only | Excellent quality, limited quantity": "Solo waifu | Qualitร  eccellente, quantitร  limitata", + "Save to Downloads": "Salva in Scaricati", + "Thinking": "Ragionando", + "On-screen display": "Popup di sistema", + "%1 โ€ข %2 tasks": "%1 โ€ข %2 promemoria", + "Qt apps": "Applicazioni Qt", + "The popular one | Best quantity, but quality can vary wildly": "Il piรน usato | Quantitร  elevata, ma la qualitร  potrebbe variare totalmente", + "Set the system prompt for the model.": "Imposta il prompt di sistema per il modello.", + "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "Per impostare una chiave API, utilizzala come argomento di questo comando\n\nPer vedere la chiave corrente, utilizza come argomento \"get\"
\n\n### For %1:\n\n**Link**: %2\n\n%3", + "Markdown test": "Test Markdown", + "Prevents abrupt increments and restricts volume limit": "Impedice aumenti improvvisi del volume e ne imposta un limite.", + "Preferred wallpaper zoom (%)": "Zoom sfondo (%)", + "Depends on workspace": "Abilita per spazi di lavoro", + "Note: turning off can hurt readability": "Nota: disattivarlo potrebbe ridurre la leggibilitร ", + "Pick wallpaper image on your system": "Seleziona un'immagine nel tuo sistema", + "Invalid API provider. Supported: \n-": "Provider API non valido. Supportati: \n-", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "Hentai | Quantitร  elevata, NSFW, la qualitร  potrebbe variare totalmente", + "Saved to %1": "Salvato in %1", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Imposta la temperatura (casualitร ) del modello. Il valore va da 0 a 2 per Gemini, e da 0 a 1 per gli altri modelli. Il valore predefinito รจ 0.5.", + "Close": "Chiudi", + "Might look ass. Unsupported.": "Potrebbe fare schifo. Non supportato.", + "API key set for %1": "Chiave API impostata per %1", + "Temperature\nChange with /temp VALUE": "Temperatura\nUsa /temp per cambiarla", + "%1 does not require an API key": "%1 non richiede una chiave API", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Modelli online disattivati\n\nDisattiva l'opzione \"Solo locale\"", + "Set the current API provider": "Imposta il provider API", + "Total token count\nInput: %1\nOutput: %2": "Numero token totali\nInput: %1\nOutput: %2", + "EasyEffects | Right-click to configure": "EasyEffects", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Sfondo anime SFW casuale da Konachan\nL'immagine verrร  salvata in ~/Immagini/Wallpapers", + "The current API used. Endpoint:": "Provider API attuale. Endpoint:", + "Set with /mode PROVIDER": "Usa /mode per cambiarlo", + "Hover to reveal": "Mostra col passaggio del mouse", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Usa i tasti freccia per navigare, Invio per selezionare\nEsc o clicca qualsiasi punto per uscire", + "Save chat to %1": "Salva chat in %1", + "Choose model": "Seleziona modello", + "No API key\nSet it with /key YOUR_API_KEY": "Nessuna chiave API\nUsa /key per impostarla", + "API key:\n\n```txt\n%1\n```": "Chiave API:\n\n```txt\n%1\n```", + "Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Sperimentale | Online | Modello di Google\nModello Gemini 2.5 Flash ottimizzato per efficenza e throughput elevato.", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Usa algoritmo di Levenshtein al posto di fuzzy", + "Download complete": "Download completato", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Suggerimento: Nascondi le icone e mostra i numeri se vuoi\nun'esperienza fedele all'originale", + "Usage": "Istruzioni", + "Set API key": "Imposta chiave API", + "Networking": "Rete", + "Volume limit": "Limite volume", + "Temperature set to %1": "Temperatura impostata a %1", + "Large images | God tier quality, no NSFW.": "Immagini grandi | Qualitร  perfetta, no NSFW.", + "Shell & utilities theming must also be enabled": "\"App e shell\" deve essere abilitato", + "API key is set\nChange with /key YOUR_API_KEY": "Chiave API impostata\nUsa /key per cambiarla", + "All-rounder | Good quality, decent quantity": "Versatile | Qualitร  buona, quantitร  discreta", + "Large language models": "Intelligenza artificiale", + "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)": "Puรฒ aiutare in caso di refusi,\nma i risultati potrebbero non essere accurati\n(es. \"GIMP\" potrebbe non restituire l'omonimo programma)", + "Automatic suspend": "Sospensione automatica", + "Max allowed increase": "Aumento massimo consentito", + "No corresponding search model found for %1": "Nessun modello di ricerca trovato con %1", + "Please charge!\nAutomatic suspend triggers at %1": "Ricarica la batteria!\nSospensione automatica in %1", + "Weather Service": "Servizio meteo", + "The current system prompt is\n\n---\n\n%1": "Il prompt di sistema attuale รจ\n\n---\n\n%1", + "%1 Safe Storage": "Portachiavi di %1", + "Load prompt from %1": "Carica prompt da %1", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "Nessun risultato. Suggerimenti:\n- Controlla i tag e le impostazioni NSFW\n- Se non hai un tag in mente, inserici il numero di pagina", + "Load chat": "Carica chat", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Prezzo**: gratuito. I tuoi dati vengono usati per training.\n\n**Istruzioni**: Accedi al tuo account Google, abilita i permessi richiesti da AI Studio, torna indietro e seleziona \"Get API key\"", + "Online via %1 | %2's model": "Online tramite %1 | Modello di %2", + "Get the next page of results": "Ottieni la pagina successiva dei risultati", + "Unknown function call: %1": "Funzione sconosciuta: %1", + "Cannot find a GPS service. Using the fallback method instead.": "Servizio GPS non disponibile. Verrร  utilizzata la cittร  preimpostata.", + "Registration failed. Please inspect manually with the warp-cli command": "Registrazione fallita. Verifica l'errore manualmente col comando warp-cli", + "Code saved to file": "Codice salvato", + "Low warning": "Livello basso", + "Clear the current list of images": "Elimina la lista corrente di immagini", + "Invalid arguments. Must provide `key` and `value`.": "Argomenti non validi. Il comando richiede `key` e `value`.", + "Connection failed. Please inspect manually with the warp-cli command": "Connessione fallita. Verifica l'errore manualmente col comando warp-cli", + "Unknown command:": "Comando sconosciuto:", + "Message the model... \"%1\" for commands": "\"%1\" per mostrare i comandi", + "Load chat from %1": "Carica chat da %1", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Prezzo**: gratuito. Le policy di trattamento dei dati potrebbero variare in base alle impostazioni del tuo account OpenRouter.\n\n**Istruzioni**: Accedi al tuo account OpenRouter, vai nella sezione \"Keys\" dal menu in alto, clicca \"Create API Key\"", + "Enter tags, or \"%1\" for commands": "\"%1\" per mostrare i comandi", + "Show regions of potential interest": "Mostra regioni d'interesse", + "Critical warning": "Livello critico", + "Go to source (%1)": "Vai alla fonte (%1)", + "Automatically suspends the system when battery is low": "Sospende automaticamente il sistema quando il livello della batteria รจ basso", + "Cannot switch to search mode from %1": "Impossibile attivare modalitร  ricerca da %1", + "Clean stuff | Excellent quality, no NSFW": "Roba pulita | Qualitร  eccellente, no NSFW", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Note per Zerochan:\n- Devi inserire un colore\n- Imposta il tuo nome utente di zerochan nell'opzione `sidebar.booru.zerochan.username`. Potresti [venire bannato se non lo fai](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "Critically low battery": "Batteria scarica", + "Loaded the following system prompt\n\n---\n\n%1": "Il seguente prompt di sistema รจ stato caricato\n\n---\n\n%1", + "Suspend at": "Sospendi al", + "Such regions 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.": "Queste regioni potrebbero essere immagini o parti di schermo contenute.\nPotrebbe non essere preciso.\nViene utilizzato un algoritmo di image processing in locale, non viene usata AI.", + "Clear chat history": "Elimina cronologia chat", + "Low battery": "Batteria quasi scarica", + "Save chat": "Salva chat", + "Switched to search mode. Continue with the user's request.": "Modalitร  ricerca attiva. Continua con la richiesta dell'utente.", + "Number show delay when pressing Super (ms)": "Mostra numeri premendo Super dopo (ms)", + "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit": "Trascina o clicca una regione โ€ข LMB: Copia โ€ข RMB: Modifica", + "Consider plugging in your device": "Connetti il tuo dispositivo alla rete elettrica", + "%1 queries pending": "%1 ricerche in sospeso", + "No further instruction provided": "Nessun'altra istruzione fornita", + "There might be a download in progress": "Potrebbe esserci un download in corso", + "Approve": "Approva", + "Invalid arguments. Must provide `command`.": "Argomenti non validi. Il comando richiede `command`.", + "Reject": "Rifiuta", + "Thought": "Pensiero", + "Performance Profile toggle": "Profilo prestazioni", + "Command rejected by user": "Comando rifiutato dall'utente" +} \ No newline at end of file diff --git a/configs/quickshell/translations/ru_RU.json b/configs/quickshell/translations/ru_RU.json new file mode 100644 index 0000000..df1c048 --- /dev/null +++ b/configs/quickshell/translations/ru_RU.json @@ -0,0 +1,346 @@ +{ + "Output": "ะ’ั‹ะฒะพะด", + "Markdown test": "ะขะตัั‚ Markdown", + "Intelligence": "ะ˜ะ˜", + "Load chat": "ะ—ะฐะณั€ัƒะทะธั‚ัŒ ั‡ะฐั‚", + "Workspaces shown": "ะšะพะป-ะฒะพ ั€ะฐะฑะพั‡ะธั… ัั‚ะพะปะพะฒ", + "Style": "ะกั‚ะธะปัŒ", + "Reject": "ะžั‚ะบะปะพะฝะธั‚ัŒ", + "Volume": "ะ“ั€ะพะผะบะพัั‚ัŒ", + "Shutdown": "ะ—ะฐะฒะตั€ัˆะตะฝะธะต ั€ะฐะฑะพั‚ั‹", + "Fidelity": "ะ’ะตั€ะฝะพัั‚ัŒ", + "Switched to search mode. Continue with the user's request.": "ะŸะตั€ะตะบะปัŽั‡ะตะฝะพ ะฒ ั€ะตะถะธะผ ะฟะพะธัะบะฐ. ะŸั€ะพะดะพะปะถะฐะน ั ะทะฐะฟั€ะพัะพะผ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั.", + "No notifications": "ะะตั‚ ัƒะฒะตะดะพะผะปะตะฝะธะน", + "EasyEffects | Right-click to configure": "EasyEffects | ะŸะšะœ, ั‡ั‚ะพะฑั‹ ะฝะฐัั‚ั€ะพะธั‚ัŒ", + "Suspend at": "ะŸะตั€ะตั…ะพะด ะฒ ั€ะตะถะธะผ ัะฝะฐ ะฝะฐ", + "Brightness": "ะฏั€ะบะพัั‚ัŒ", + "Volume mixer": "ะœะธะบัˆะตั€ ะณั€ะพะผะบะพัั‚ะธ", + "Discussions": "ะžะฑััƒะถะดะตะฝะธั", + "Earbang protection": "ะ—ะฐั‰ะธั‚ะฐ ะพั‚ ะณั€ะพะผะบะธั… ะทะฒัƒะบะพะฒ", + "Message the model... \"%1\" for commands": "ะกะพะพะฑั‰ะตะฝะธะต ะดะปั ะ˜ะ˜... \"%1\" ะดะปั ะบะพะผะฐะฝะด", + "Decorations & Effects": "ะ”ะตะบะพั€ะฐั†ะธะธ ะธ ัั„ั„ะตะบั‚ั‹", + "Load chat from %1": "ะ—ะฐะณั€ัƒะทะธั‚ัŒ ั‡ะฐั‚ ะธะท %1", + "OK": "ะžะš", + "No further instruction provided": "ะ‘ะพะปัŒัˆะต ะธะฝัั‚ั€ัƒะบั†ะธะน ะฝะต ะฟั€ะตะดะพัั‚ะฐะฒะปะตะฝะพ", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "ะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ั‚ะตะผะฟะตั€ะฐั‚ัƒั€ัƒ (ัะปัƒั‡ะฐะนะฝะพัั‚ัŒ) ะผะพะดะตะปะธ. ะ’ั‹ะฑะตั€ะธั‚ะต ะทะฝะฐั‡ะตะฝะธะต ะพั‚ 0 ะดะพ 2 ะดะปั Gemini, ะพั‚ 0 ะดะพ 1 ะดะปั ะดั€ัƒะณะธั… ะผะพะดะตะปะตะน. ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ: 0.5", + "Open file link": "ะžั‚ะบั€ั‹ั‚ัŒ ััั‹ะปะบัƒ ะฝะฐ ั„ะฐะนะป", + "API key is set\nChange with /key YOUR_API_KEY": "API ะบะปัŽั‡ ัƒัั‚ะฐะฝะพะฒะปะตะฝ\nะŸะพะผะตะฝัั‚ัŒ ั ะฟะพะผะพั‰ัŒัŽ /key ะ’ะะจ_ะšะ›ะฎะง_API", + "Advanced": "ะŸั€ะพะดะฒะธะฝัƒั‚ั‹ะต", + "Title bar": "ะ—ะฐะณะพะปะพะฒะพะบ", + "Keybinds": "ะจะพั€ั‚ะบะฐั‚ั‹", + "Alternatively use /dark, /light, /img in the launcher": "ะขะฐะบะถะต ะผะพะถะฝะพ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ /dark, /light, /img ะฒ ะปะฐัƒะฝั‡ะตั€ะต", + "Dark/Light toggle": "ะขะตะผะฝั‹ะน/ัะฒะตั‚ะปั‹ะน", + "Shell & utilities": "ะžะฑะพะปะพั‡ะบะฐ", + "Clipboard": "ะ‘ัƒั„ะตั€ ะพะฑะผะตะฝะฐ", + "Yooooo hi there": "ะ™ะพะพะพะพัƒ ะฟั€ะธะฒะตั‚", + "Show app icons": "ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะธะบะพะฝะบะธ", + "Save": "ะกะพั…ั€ะฐะฝะธั‚ัŒ", + "Style & wallpaper": "ะกั‚ะธะปัŒ ะธ ะพะฑะพะธ", + "Battery": "ะ‘ะฐั‚ะฐั€ะตั", + "Expressive": "ะ’ั‹ั€ะฐะทะธั‚ะตะปัŒะฝะพัั‚ัŒ", + "Reboot": "ะŸะตั€ะตะทะฐะณั€ัƒะทะบะฐ", + "AI": "ะ˜ะ˜", + "Sleep": "ะกะฟัั‰ะธะน ั€ะตะถะธะผ", + "Allow NSFW": "ะ ะฐะทั€ะตัˆะธั‚ัŒ NSFW", + "Please charge!\nAutomatic suspend triggers at %1": "ะŸะพะดะบะปัŽั‡ะธั‚ะต ะŸะš ะบ ะธัั‚ะพั‡ะฝะธะบัƒ ะฟะธั‚ะฐะฝะธั!\nะŸะตั€ะตั…ะพะด ะฒ ัะฟัั‰ะธะน ั€ะตะถะธะผ ะฝะฐ %1", + "Select input device": "ะ’ั‹ะฑะตั€ะธั‚ะต ะผะธะบั€ะพั„ะพะฝ", + "Hover to reveal": "ะะฐะฒะตะดะธั‚ะต, ั‡ั‚ะพะฑั‹ ั€ะฐัะบั€ั‹ั‚ัŒ", + "Unknown Artist": "ะะตะธะทะฒะตัั‚ะฝั‹ะน ะธัะฟะพะปะฝะธั‚ะตะปัŒ", + "Connection failed. Please inspect manually with the warp-cli command": "ะžัˆะธะฑะบะฐ ะฟะพะดะบะปัŽั‡ะตะฝะธั. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฒั€ัƒั‡ะฝัƒัŽ ะบะพะผะฐะฝะดะพะน warp-cli", + "Lock": "ะ‘ะปะพะบะธั€ะพะฒะบะฐ", + "Monochrome": "ะœะพะฝะพั…ั€ะพะผ", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**ะฆะตะฝะฐ**: ะฑะตัะฟะปะฐั‚ะฝะพ. ะ”ะฐะฝะฝั‹ะต ะธัะฟะพะปัŒะทัƒัŽั‚ัั ะดะปั ะพะฑัƒั‡ะตะฝะธั.\n\n**ะ˜ะฝัั‚ัƒั€ะบั†ะธะธ**: ะ’ะพะนะดะธั‚ะต ะฒ ะฐะบะบะฐัƒะฝั‚ Google, ั€ะฐะทั€ะตัˆะธั‚ะต AI Studio ัะพะทะดะฐั‚ัŒ ะฟั€ะพะตะบั‚ Google Cloud ะธะปะธ ั‡ั‚ะพ ะพะฝะพ ะฟะพะฟั€ะพัะธั‚, ะฒะตั€ะฝะธั‚ะตััŒ ะธ ะฝะฐะถะผะธั‚ะต Get API key", + "Online models disallowed\n\nControlled by `policies.ai` config option": "ะžะฝะปะฐะนะฝ ะผะพะดะตะปะธ ะทะฐะฟั€ะตั‰ะตะฝั‹\n\nะฃะฟั€ะฐะฒะปัะตั‚ัั ะพะฟั†ะธะตะน `policies.ai` ะฒ ะบะพะฝั„ะธะณะต", + "Emojis": "ะญะผะพะดะทะธ", + "Wallpaper parallax": "ะŸะฐั€ะฐะปะปะฐะบั ะพะฑะพะตะฒ", + "Interface": "ะ˜ะฝั‚ะตั€ั„ะตะนั", + "System prompt": "ะกะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚", + "Edit config": "ะ ะตะด. ะบะพะฝั„ะธะณ", + "Page %1": "ะกั‚ั€ะฐะฝะธั†ะฐ %1", + "Task description": "ะžะฟะธัะฐะฝะธะต ะทะฐะดะฐะฝะธั", + "Fruit Salad": "ะคั€ัƒะบั‚ะพะฒั‹ะน ัะฐะปะฐะด", + "%1 Safe Storage": "ะ‘ะตะทะพะฟะฐัะฝะพะต ั…ั€ะฐะฝะธะปะธั‰ะต %1", + "Distro": "ะ”ะธัั‚ั€ะธะฑัƒั‚ะธะฒ", + "Add": "ะ”ะพะฑะฐะฒะธั‚ัŒ", + "Closet": "ะ—ะฐะบั€ั‹ั‚ั‹ะน", + "Task Manager": "ะกะธัั‚ะตะผะฝั‹ะน ะผะพะฝะธั‚ะพั€", + "Configuration": "ะะฐัั‚ั€ะพะนะบะฐ", + "There might be a download in progress": "ะ’ะพะทะผะพะถะฝะพ ะฟั€ะพะธัั…ะพะดะธั‚ ัะบะฐั‡ะธะฒะฐะฝะธะต", + "Visibility": "ะ’ะธะดะธะผะพัั‚ัŒ", + "Desktop": "ะ ะฐะฑะพั‡ะธะน ัั‚ะพะป", + "Run": "ะ—ะฐะฟัƒัั‚ะธั‚ัŒ", + "Sunrise": "ะ ะฐััะฒะตั‚", + "Set API key": "ะ—ะฐะดะฐั‚ัŒ API ะบะปัŽั‡", + "Shell windows": "ะžะบะฝะฐ ะพะฑะพะปะพั‡ะบะธ", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Action": "ะ”ะตะนัั‚ะฒะธะต", + "Elements": "ะญะปะตะผะตะฝั‚ั‹", + "Resources": "ะ ะตััƒั€ัั‹", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**ะฆะตะฝะฐ**: ะฑะตัะฟะปะฐั‚ะฝะพ. ะŸะพะปะธั‚ะธะบะฐ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ะดะฐะฝะฝั‹ั… ะทะฐะฒะธัะธั‚ ะพั‚ ะฝะฐัั‚ั€ะพะตะบ OpenRouter.\n\n**ะ˜ะฝัั‚ั€ัƒะบั†ะธะธ**: ะ’ะพะนะดะธั‚ะต ะฒ ะฐะบะบะฐัƒะฝั‚ OpenRouter, ะทะฐะนะดะธั‚ะต ะฒ Keys ะฒ ะผะตะฝัŽ ัะฒะตั€ั…ัƒ ัะฟั€ะฐะฒะฐ, ะฝะฐะถะผะธั‚ะต Create API Key", + "Game mode": "ะ˜ะณั€ะพะฒะพะน ั€ะตะถะธะผ", + "Code saved to file": "ะšะพะด ัะพั…ั€ะฐะฝะตะฝ ะฒ ั„ะฐะนะป", + "Online | Google's model\nGives up-to-date information with search.": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะ”ะฐะตั‚ ั‚ะพั‡ะฝัƒัŽ ะธะฝั„ะพั€ะผะฐั†ะธัŽ ะฑะปะฐะณะพะดะฐั€ั ะฟะพะธัะบัƒ", + "Issues": "ะŸั€ะพะฑะปะตะผั‹", + "Depends on sidebars": "ะ—ะฐะฒะธัะธั‚ ะพั‚ ะฟะฐะฝะตะปะตะน", + "Translation goes here...": "ะ—ะดะตััŒ ะฑัƒะดะตั‚ ะฟะตั€ะตะฒะพะด...", + "Bar style": "ะกั‚ะธะปัŒ ะฟะฐะฝะตะปะธ", + "Unknown command:": "ะะตะธะทะฒะตัั‚ะฝะฐั ะบะพะผะฐะฝะดะฐ:", + "Invalid API provider. Supported: \n-": "ะะตะธะทะฒะตัั‚ะฝั‹ะน API ะฟั€ะพะฒะฐะนะดะตั€. ะŸะพะดะดะตั€ะถะธะฒะฐะตะผั‹ะต: \n-", + "Clear chat history": "ะžั‡ะธัั‚ะธั‚ัŒ ะธัั‚ะพั€ะธัŽ", + "Run command": "ะ’ั‹ะฟะพะปะฝะธั‚ัŒ ะบะพะผะฐะฝะดัƒ", + "Local only": "ะ›ะพะบะฐะปัŒะฝั‹ะต", + "Tonal Spot": "ะขะพะฝะฐะปัŒะฝะพะต ะฟัั‚ะฝะพ", + "No corresponding search model found for %1": "ะะต ะฝะฐะนะดะตะฝะพ ะฟะพะธัะบะพะฒะพะน ะผะพะดะตะปะธ ะดะปั %1", + "Content": "ะšะพะฝั‚ะตะฝั‚", + "Wind": "ะ’ะตั‚ะตั€", + "Total token count\nInput: %1\nOutput: %2": "ะšะพะปะธั‡ะตัั‚ะฒะพ ั‚ะพะบะตะฝะพะฒ\nะ’ะฒะพะด: %1\nะ’ั‹ะฒะพะด: %2", + "Current model: %1\nSet it with %2model MODEL": "ะขะตะบัƒั‰ะฐั ะผะพะดะตะปัŒ: %1\nะกะผะตะฝะธั‚ัŒ ั ะฟะพะผะพั‰ัŒัŽ %2model ะœะžะ”ะ•ะ›ะฌ", + "Dark": "ะขะตะผะฝั‹ะน", + "Hug": "ะ—ะฐั…ะฒะฐั‚", + "Hibernate": "ะ“ะธะฑะตั€ั€ะฝะฐั†ะธั", + "Registration failed. Please inspect manually with the warp-cli command": "ะžัˆะธะฑะบะฐ ั€ะตะณะธัั‚ั€ะฐั†ะธะธ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฒั€ัƒั‡ะฝัƒัŽ ะบะพะผะฐะฝะดะพะน warp-cli", + "Calendar": "ะšะฐะปะตะฝะดะฐั€ัŒ", + "Save chat to %1": "ะกะพั…ั€ะฐะฝะธั‚ัŒ ั‡ะฐั‚ ะฒ %1", + "Finished tasks will go here": "ะ—ะดะตััŒ ะฑัƒะดัƒั‚ ะฒั‹ะฟะพะปะฝะตะฝะฝั‹ะต ะทะฐะดะฐั‡ะธ", + "Set the current API provider": "ะ—ะฐะดะฐั‚ัŒ ั‚ะตะบัƒั‰ะตะณะพ ะฟั€ะพะฒะฐะนะดะตั€ะฐ API", + "Weather Service": "ะกะตั€ะฒะธั ะฟะพะณะพะดั‹", + "Fake screen rounding": "ะคะตะนะบะพะฒะพะต ะพะบั€ัƒะณะปะตะฝะธะต ัะบั€ะฐะฝะฐ", + "View Markdown source": "ะŸะพัะผะพั‚ั€ะตั‚ัŒ ะธัั…ะพะดะฝะพะน Markdown", + "Change any time later with /dark, /light, /img in the launcher": "ะ˜ะทะผะตะฝัะตั‚ัั ะฒ ะปัŽะฑะพะต ะฒั€ะตะผั ั ะฟะพะผะพั‰ัŒัŽ /dark, /light, /img ะฒ ะปะฐัƒะฝั‡ะตั€ะต", + "Critical warning": "ะšั€ะธั‚ะธั‡ะตัะบะธะน", + "Waifus only | Excellent quality, limited quantity": "ะขะพะปัŒะบะพ ะฒะฐะนั„ัƒ | ะŸั€ะตะฒะพัั…ะพะดะฝะพะต ะบะฐั‡ะตัั‚ะฒะพ, ะพะณั€ะฐะฝะธั‡ะตะฝะฝะพะต ะบะพะปะธั‡ะตัั‚ะฒะพ", + "Unknown function call: %1": "ะะตะธะทะฒะตัั‚ะฝั‹ะน ะฒั‹ะทะพะฒ ั„ัƒะฝะบั†ะธะธ: %1", + "Neutral": "ะะตะนั‚ั€ะฐะป", + "Anime": "ะะฝะธะผะต", + "Cannot switch to search mode from %1": "ะะตะฒะพะทะผะพะถะฝะพ ะฟะตั€ะตะบะปัŽั‡ะธั‚ัŒัั ะฒ ั€ะตะถะธะผ ะฟะพะธัะบะฐ ะธะท %1", + "Useless buttons": "ะ‘ะตัะฟะพะปะตะทะฝั‹ะต ะบะฝะพะฟะพั‡ะบะธ", + "Unfinished": "ะะตะทะฐะฒะตั€ัˆะตะฝะฝั‹ะต", + "Privacy Policy": "ะŸะพะปะธั‚ะธะบะฐ ะบะพะฝั„ะธะดะตะฝั†ะธะฐะปัŒะฝะพัั‚ะธ", + "Online via %1 | %2's model": "ะžะฝะปะฐะนะฝ ั‡ะตั€ะตะท %1 | ะœะพะดะตะปัŒ %2", + "Large images | God tier quality, no NSFW.": "ะžะณั€ะพะผะฝั‹ะต ะธะทะพะฑั€ะฐะถะตะฝะธะต | ะ‘ะพะถะตัั‚ะฒะตะฝะฝะพะต ะบะฐั‡ะตัั‚ะฒะพ, ะฝะตั‚ัƒ NSFW.", + "Base URL": "ะ‘ะฐะทะพะฒั‹ะน URL", + "Not visible to model": "ะะตะฒะธะดะธะผั‹ะน ะดะปั ะผะพะดะตะปะธ", + "Auto": "ะะฒั‚ะพ", + "Might look ass. Unsupported.": "ะœะพะถะตั‚ ะฒั‹ะณะปัะดะตั‚ัŒ ั…ัƒะตะฒะพ. ะะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั", + "%1 โ€ข %2 tasks": "%1 โ€ข %2 ะทะฐะดะฐะฝะธะน", + "Search the web": "ะ˜ัะบะฐั‚ัŒ ะฒ ะธะฝั‚ะตั€ะฝะตั‚ะต", + "Scroll to change brightness": "ะ›ะธัั‚ะฐะนั‚ะต, ั‡ั‚ะพะฑั‹ ะธะทะผะตะฝะธั‚ัŒ ัั€ะบะพัั‚ัŒ", + "Invalid arguments. Must provide `key` and `value`.": "ะะตะฟั€ะฐะฒะธะปัŒะฝั‹ะต ะฐั€ะณัƒะผะตะฝั‚ั‹. ะัƒะถะฝะพ ะฟั€ะตะดะพัั‚ะฐะฒะธั‚ัŒ `key` ะธ `value`.", + "Settings": "ะะฐัั‚ั€ะพะนะบะธ", + "Show regions of potential interest": "ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ั€ะตะณะธะพะฝั‹ ั ะฟะพั‚ะตะฝั†ะธะฐะปัŒะฝั‹ะผ ะธะฝั‚ะตั€ะตัะพะผ", + "Pressure": "ะ”ะฐะฒะปะตะฝะธะต", + "No": "ะะตั‚", + "Such regions 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.": "ะขะฐะบะธะต ั€ะตะณะธะพะฝั‹ ะผะพะณัƒั‚ ะฑั‹ั‚ัŒ ะธะทะพะฑั€ะฐะถะตะฝะธัะผะธ ะธะปะธ ั‡ะฐัั‚ะธ ัะบั€ะฐะฝะฐ, ะบะพั‚ะพั€ั‹ะต ะธะผะตัŽั‚ ะฝะตะบัƒัŽ ะพะณั€ะฐะฝะธั‡ะตะฝะฝะพัั‚ัŒ.\nะœะพะถะตั‚ ะฑั‹ั‚ัŒ ะฝะตะฒะตั€ะฝะพ.\nะญั‚ะพ ะฟั€ะพะธัั…ะพะดะธั‚ ั ะฟะพะผะพั‰ัŒัŽ ะฐะปะณะพั€ะธั‚ะผะฐ ะพะฑั€ะฐะฑะพั‚ะบะธ ะธะทะพะฑั€ะถะตะฝะธะน ะปะพะบะฐะปัŒะฝะพ ะธ ะ˜ะ˜ ะฝะต ะธัะฟะพะปัŒะทัƒะตั‚ัั", + "Select Language": "ะ’ั‹ะฑั€ะฐั‚ัŒ ะฏะทั‹ะบ", + "Command rejected by user": "ะšะพะผะฐะฝะดะฐ ะพั‚ะผะตะฝะตะฝะฐ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปะตะผ", + "Approve": "ะ ะฐะทั€ะตัˆะธั‚ัŒ", + "Terminal": "ะขะตั€ะผะธะฝะฐะป", + "Search": "ะŸะพะธัะบ", + "or": "ะธะปะธ", + "Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "ะญะบัะฟะตั€ะตะผะตะฝั‚ะฐะปัŒะฝะฐั | ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะœะพะดะตะปัŒ Gemini 2.5 Flash, ะพะฟั‚ะธะผะธะทะธั€ะพะฒะฐะฝะฝะฐั ะฟะพะด ัะบะพะฝะพะผะธัŽ ะธ ะฒั‹ัะพะบัƒัŽ ะฟั€ะพะฟัƒัะบะฝัƒัŽ ัะฟะพัะพะฑะฝะพัั‚ัŒ", + "Uptime: %1": "ะ’ั€ะตะผั ั€ะฐะฑะพั‚ั‹: %1", + "Save chat": "ะกะพั…ั€ะฐะฝะธั‚ัŒ ั‡ะฐั‚", + "Load prompt from %1": "ะ—ะฐะณั€ัƒะทะธั‚ัŒ ะฟั€ะพะผะฟั‚ ะธะท %1", + "Critically low battery": "ะšั€ะธั‚ะธั‡ะตัะบะธ ะฝะธะทะบะธะน ะทะฐั€ัะด ะฑะฐั‚ะฐั€ะตะธ", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "ะฅะตะฝั‚ะฐะน | ะžั‚ะปะธั‡ะฝั‹ะน ะฒั‹ะฑะพั€, ะบะฐั‡ะตัั‚ะฒะพ ัะธะปัŒะฝะพ ะฒะฐั€ัŒะธั€ัƒะตั‚ัั", + "Input": "ะ’ะฒะพะด", + "Borderless": "ะ‘ะตะทั€ะฐะผะพั‡ะฝั‹ะน", + "Loaded the following system prompt\n\n---\n\n%1": "ะ—ะฐะณั€ัƒะถะตะฝ ัะปะตะดัƒัŽั‰ะธะน ัะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚\n\n---\n\n%1", + "Thought": "ะœั‹ัะปะธ", + "Your package manager is running": "ะ’ะฐัˆ ะผะตะฝะตะดะถะตั€ ะฟะฐะบะตั‚ะพะฒ ั€ะฐะฑะพั‚ะฐะตั‚", + "12h AM/PM": "12 ั‡. AM/PM", + "Scale (%)": "ะ ะฐะทะผะตั€ (%)", + "Waiting for response...": "ะžะถะธะดะฐะฝะธะต ะพั‚ะฒะตั‚ะฐ...", + "%1 does not require an API key": "%1 ะฝะต ั‚ั€ะตะฑัƒะตั‚ API ะบะปัŽั‡", + "Edit": "ะ˜ะทะผะตะฝะธั‚ัŒ", + "Dock": "ะŸะฐะฝะตะปัŒ ะทะฐะดะฐั‡", + "Set with /mode PROVIDER": "ะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ั ะฟะพะผะพั‰ัŒัŽ /mode ะŸะ ะžะ’ะะ™ะ”ะ•ะ ", + "Low warning": "ะะธะทะบะธะน", + "Silent": "ะ’ั‹ะบะป. ะทะฒัƒะบ", + "Rainbow": "ะ ะฐะดัƒะณะฐ", + "Anime boorus": "ะะฝะธะผะต ะฑะพะพั€ัƒ", + "Nothing here!": "ะะธั‡ะตะณะพ ะฝะตั‚ัƒ!", + "Documentation": "ะ”ะพะบัƒะผะตะฝั‚ะฐั†ะธั", + "Clear": "ะžั‡ะธัั‚ะธั‚ัŒ", + "Transparency": "ะŸั€ะพะทั€ะฐั‡ะฝะพัั‚ัŒ", + "Show background": "ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ั„ะพะฝ", + "Info": "ะ˜ะฝั„ะพ", + "12h am/pm": "12 ั‡. am/pm", + "Reload Hyprland & Quickshell": "ะŸะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ Hyprland ะธ Quickshell", + "%1 characters": "%1 ัะธะผะฒะพะปะพะฒ", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". ะะฐะฟะพะผะธะฝะฐะฝะธะต ะดะปั Zerochan:\n- ะ’ั‹ ะดะพะปะถะฝั‹ ะฒะฒะตัั‚ะธ ั†ะฒะตั‚\n- ะฃัั‚ะฐะฝะพะฒะธั‚ะต ัะฒะพะน ัŽะทะตั€ะฝะตะนะผ Zerochan ั ะฟะพะผะพั‰ัŒัŽ `sidebar.booru.zerochan.username` ะฒ ะบะพะฝั„ะธะณะต. ะ’ั‹ [ะผะพะถะตั‚ะต ะฑั‹ั‚ัŒ ะทะฐะฑะฐะฝะตะฝั‹, ะตัะปะธ ะฝะต ัะดะตะปะฐะตั‚ะต ัั‚ะพะณะพ](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "For desktop wallpapers | Good quality": "ะ”ะปั ะพะฑะพะตะฒ | ะžั‚ะปะธั‡ะฝะพะต ะบะฐั‡ะตัั‚ะฒะพ", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "ะ’ะฒะตะดะธั‚ะต /key, ั‡ั‚ะพะฑั‹ ะฝะฐั‡ะฐั‚ัŒ ั€ะฐะฑะพั‚ัƒ ั ะพะฝะปะฐะนะฝ ะผะพะดะตะปัะผะธ\nCtrl+O ะดะปั ั€ะฐััˆะธั€ะตะฝะธั ะฟะฐะฝะตะปะธ\nCtrl+P ะดะปั ะพั‚ัะพะตะดะธะฝะตะฝะธั ะฟะฐะฝะตะปะธ", + "Close": "ะ—ะฐะบั€ั‹ั‚ัŒ", + "Search, calculate or run": "ะŸะพะธัะบ, ะฒั‹ั‡ะธัะปะธั‚ัŒ, ะฒั‹ะฟะพะปะฝะธั‚ัŒ", + "Add task": "ะ”ะพะฑะฐะฒะธั‚ัŒ ะทะฐะดะฐะฝะธะต", + "Enable": "ะ’ะบะปัŽั‡ะธั‚ัŒ", + "Temperature set to %1": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ ัƒัั‚ะฐะฝะพะฒะปะตะฝะฐ ะฝะฐ %1", + "No media": "ะะตั‚ ะผะตะดะธะฐ", + "Screen snip": "ะกะบั€ะธะฝัˆะพั‚", + "Go to source (%1)": "ะŸะตั€ะตะนั‚ะธ ะบ ะธัั‚ะพั‡ะฝะธะบัƒ (%1)", + "Download": "ะกะบะฐั‡ะฐั‚ัŒ", + "Sunset": "ะ—ะฐะบะฐั‚", + "Weeb": "ะะฝะธะผะต", + "The current system prompt is\n\n---\n\n%1": "ะขะตะบัƒั‰ะธะน ัะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚\n\n---\n\n%1", + "Scroll to change volume": "ะ›ะธัั‚ะฐะนั‚ะต, ั‡ั‚ะพะฑั‹ ะธะทะผะตะฝะธั‚ัŒ ะณั€ะพะผะบะพัั‚ัŒ", + "Unknown Title": "ะะตะธะทะฒะตัั‚ะฝะพะต ะะฐะทะฒะฐะฝะธะต", + "Policies": "ะŸะพะปะธั‚ะธะบะธ", + "Disable NSFW content": "ะžั‚ะบะปัŽั‡ะธั‚ัŒ NSFW ะบะพะฝั‚ะตะฝั‚", + "The current API used. Endpoint:": "ะขะตะบัƒั‰ะธะน ะธัะฟะพะปัŒะทัƒะตะผั‹ะน API. ะขะพั‡ะบะฐ ะฒั…ะพะดะฐ:", + "Humidity": "ะ’ะปะฐะถะฝะพัั‚ัŒ", + "Cheat sheet": "ะจะฟะฐั€ะณะฐะปะบะฐ", + "Translator": "ะŸะตั€ะตะฒะพะดั‡ะธะบ", + "Be patient...": "ะŸะพะดะพะถะดะธั‚ะต...", + "Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search": "ะญะบัะฟะตั€ะตะผะตะฝั‚ะฐะปัŒะฝะฐั | ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะœะพะถะตั‚ ัะดะตะปะฐั‚ัŒ ั‡ัƒั‚ัŒ ะฑะพะปัŒัˆะต, ะฝะพ ะฝัƒะถะตะฝ ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ะน ะทะฐะฟั€ะพั ะดะปั ะฟะพะธัะบะฐ", + "Audio": "ะัƒะดะธะพ", + "Preferred wallpaper zoom (%)": "ะŸั€ะตะดะฟะพั‡ะธั‚ะฐะตะผั‹ะน ะทัƒะผ ะพะฑะพะตะฒ (%)", + "Model set to %1": "ะฃัั‚ะฐะฝะพะฒะปะตะฝะฐ ะผะพะดะตะปัŒ %1", + "Enter text to translate...": "ะ’ะฒะตะดะธั‚ะต ั‚ะตะบัั‚ ะดะปั ะฟะตั€ะตะฒะพะดะฐ...", + "Use Levenshtein distance-based algorithm instead of fuzzy": "ะ˜ัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะฐะปะณะพั€ะธั‚ะผ, ะพัะฝะพะฒะฐะฝะฝั‹ะน ะฝะฐ ั€ะฐััั‚ะพัะฝะธะธ ะ›ะตะฒะตะฝัˆั‚ะตะนะฝะฐ,\n ะฒะผะตัั‚ะพ ะฝะตั‡ั‘ั‚ะบะพะณะพ ัะพะฟะพัั‚ะฐะฒะปะตะฝะธั.", + "Depends on workspace": "ะ—ะฐะฒะธัะธั‚ ะพั‚ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ", + "All-rounder | Good quality, decent quantity": "ะฃะฝะธะฒะตั€ัะฐะปัŒะฝั‹ะน | ะฅะพั€ะพัˆะตะต ะบะฐั‡ะตัั‚ะฒะพ ะธ ะบะพะปะธั‡ะตัั‚ะฒะพ", + "Timeout (ms)": "ะขะฐะนะผะฐัƒั‚ (ะผั)", + "Plain rectangle": "ะžะฑั‹ั‡ะฝั‹ะน ะฟั€ัะผะพัƒะณะพะปัŒะฝะธะบ", + "No API key\nSet it with /key YOUR_API_KEY": "ะะตั‚ API ะบะปัŽั‡ะฐ.\nะฃัั‚ะฐะฝะพะฒะธั‚ะต ะตะณะพ ั ะฟะพะผะพั‰ัŒัŽ /key ะ’ะะจ_ะšะ›ะฎะง_API", + "Center title": "ะฆะตะฝั‚ั€ะธั€ะพะฒะฐั‚ัŒ ะฝะฐะทะฒะฐะฝะธะต", + "Buttons": "ะšะฝะพะฟะบะธ", + "Copy code": "ะกะบะพะฟะธั€ะพะฒะฐั‚ัŒ ะบะพะด", + "Precipitation": "ะžัะฐะดะบะธ", + "The popular one | Best quantity, but quality can vary wildly": "ะŸะพะฟัƒะปัั€ะฝั‹ะน | ะžะณั€ะพะผะฝั‹ะน ะฒั‹ะฑะพั€, ะฝะพ ะบะฐั‡ะตัั‚ะฒะพ ัะธะปัŒะฝะพ ะฒะฐั€ัŒะธั€ัƒะตั‚ัั", + "Temperature: %1": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ: %1", + "Qt apps": "Qt", + "Delete": "ะฃะดะฐะปะธั‚ัŒ", + "Saved to %1": "ะกะพั…ั€ะฐะฝะตะฝะพ ะฒ %1", + "Temperature\nChange with /temp VALUE": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ\nะ˜ะทะผะตะฝะธั‚ะต ั ะฟะพะผะพั‰ัŒัŽ /temp ะ—ะะะงะ•ะะ˜ะ•", + "App": "ะŸั€ะธะปะพะถะตะฝะธะต", + "Bluetooth": "Bluetooth", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "ะขะตะบัƒั‰ะธะน API: %1\nะ˜ะทะผะตะฝะธั‚ัŒ ั ะฟะพะผะพั‰ัŒัŽ %2mode ะŸะ ะžะ’ะะ™ะ”ะ•ะ ", + "Cancel": "ะžั‚ะผะตะฝะธั‚ัŒ", + "Clean stuff | Excellent quality, no NSFW": "ะงะธัั‚ั‹ะน ะบะพะฝั‚ะตะฝั‚ | ะŸั€ะตะฒะพัั…ะพะดะฝะพะต ะบะฐั‡ะตัั‚ะฒะพ, ะฝะตั‚ัƒ NSFW", + "Wallpaper": "ะžะฑะพะธ", + "Provider set to": "ะŸั€ะพะฒะฐะนะดะตั€ ัƒัั‚ะฐะฝะพะฒะปะตะฝ ะฝะฐ", + "Weather": "ะŸะพะณะพะดะฐ", + "24h": "24 ั‡.", + "Show next time": " ะŸะพะบะฐะทะฐั‚ัŒ ะฒ ัะปะตะดัƒัŽั‰ะธะน ั€ะฐะท", + "Unknown": "ะะตะธะทะฒะตัั‚ะฝะพ", + "Launch": "ะ—ะฐะฟัƒัั‚ะธั‚ัŒ", + "Overview": "ะžะฑะทะพั€", + "Help & Support": "ะŸะพะผะพั‰ัŒ ะธ ะฟะพะดะดะตั€ะถะบะฐ", + "Night Light | Right-click to toggle Auto mode": "ะะพั‡ะฝะพะน ัะฒะตั‚ | ะŸะšะœ ะดะปั ะฟะตั€ะตะบะปัŽั‡ะตะฝะธั ะะฒั‚ะพ ั€ะตะถะธะผะฐ", + "Web search": "ะ’ะตะฑ-ะฟะพะธัะบ", + "Cannot find a GPS service. Using the fallback method instead.": "ะะต ัƒะดะฐะปะพััŒ ะฝะฐะนั‚ะธ GPS ัะตั€ะฒะธั. ะ˜ัะฟะพะปัŒะทัƒะตะผ ะทะฐะฟะฐัะฝั‹ะน ะฒะฐั€ะธะฐะฝั‚.", + "Large language models": "ะ‘ะพะปัŒัˆะธะต ัะทั‹ะบะพะฒั‹ะต ะผะพะดะตะปะธ", + "Dotfiles": "ะ”ะพั‚ั„ะฐะนะปั‹", + "When not fullscreen": "ะšะพะณะดะฐ ะฝะต ะฒ ะฟะพะปะฝะพัะบั€ะฐะฝะฝะพะผ ั€ะตะถะธะผะต", + "Screenshot tool": "ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ ัะบั€ะธะฝัˆะพั‚ะพะฒ", + "Get the next page of results": "ะŸะพะปัƒั‡ะธั‚ัŒ ั€ะตะทัƒะปั‚ะฐั‚ั‹ ัะปะตะดัƒัŽั‰ะตะน ัั‚ั€ะฐะฝะธั†ั‹", + "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit": "ะŸั€ะพะฒะตะดะธั‚ะต ะธะปะธ ะบะปะธะบะฝะธั‚ะต ะฝะฐ ั€ะตะณะธะพะฝ โ€ข ะ›ะšะœ: ะกะบะพะฟะธั€ะพะฒะฐั‚ัŒ โ€ข ะŸะšะœ: ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ", + "Material palette": "ะœะฐั‚ะตั€ะธะฐะปัŒะฝะฐั ะฟะฐะปะธั‚ั€ะฐ", + "Columns": "ะกั‚ะพะปะฑั†ั‹", + "Bar": "ะŸะฐะฝะตะปัŒ", + "Max allowed increase": "ะœะฐะบั. ั€ะพัั‚", + "Always show numbers": "ะ’ัะตะณะดะฐ ะฟะพะบะฐะทั‹ะฒะฐั‚ัŒ ะฝะพะผะตั€ะฐ", + "%1 notifications": "%1 ัƒะฒะตะดะพะผะปะตะฝะธะน", + "Rows": "ะ ัะดั‹", + "Invalid arguments. Must provide `command`.": "ะะตะฟั€ะฐะฒะธะปัŒะฝั‹ะน ะฐั€ะณัƒะผะตะฝั‚. ะัƒะถะฝะพ ะฟั€ะตะดะพัั‚ะฐะฒะธั‚ัŒ `command`.", + "System": "ะกะธัั‚ะตะผะฐ", + "Shell & utilities theming must also be enabled": "ะ ะฐัั†ะฒะตั‚ะบะฐ ะพะฑะพะปะพั‡ะบะธ ั‚ะฐะบะถะต ะดะพะปะถะฝะฐ ะฑั‹ั‚ัŒ ะฒะบะปัŽั‡ะตะฝะฐ.", + "%1 queries pending": "%1 ะทะฐะฟั€ะพัะพะฒ ะฒ ะพั‡ะตั€ะตะดะธ", + "Copy": "ะกะบะพะฟะธั€ะพะฒะฐั‚ัŒ", + "Logout": "ะ’ั‹ั…ะพะด", + "Pinned on startup": "ะ—ะฐะบั€ะตะฟะปะตะฝะฐ ะฝะฐ ะทะฐะฟัƒัะบะต", + "%1 | Right-click to configure": "%1 | ะŸะšะœ, ั‡ั‚ะพะฑั‹ ะฝะฐัั‚ั€ะพะธั‚ัŒ", + "Donate": "ะ—ะฐะดะพะฝะฐั‚ะธั‚ัŒ", + "Temperature must be between 0 and 2": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ ะดะพะปะถะฝะฐ ะฑั‹ั‚ัŒ ะผะตะถะดัƒ 0 ะธ 2", + "Session": "ะกะตััะธั", + "Mic toggle": "ะŸะตั€ะตะบะป. ะผะธะบั€ะพั„ะพะฝ", + "Reboot to firmware settings": "ะŸะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒัั ะฒ ะฝะฐัั‚ั€ะพะนะบะธ BIOS/UEFI", + "Low battery": "ะะธะทะบะธะน", + "Usage": "ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต", + "Notifications": "ะฃะฒะตะดะพะผะปะตะฝะธั", + "Consider plugging in your device": "ะ—ะฐะดัƒะผะฐะนั‚ะตััŒ ะพ ะฟะพะดะบะปัŽั‡ะตะฝะธะธ ะŸะš ะบ ะธัั‚ะพั‡ะฝะธะบัƒ ะฟะธั‚ะฐะฝะธั", + "Cloudflare WARP": "Cloudflare WARP", + "Automatically suspends the system when battery is low": "ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ ะฟะตั€ะตั…ะพะดะธั‚ ะฒ ั€ะตะถะธะผ ัะฝะฐ ะบะพะณะดะฐ ะทะฐั€ัะด ะฝะธะทะบะธะน", + "Services": "ะกะตั€ะฒะธัั‹", + "Thinking": "ะ”ัƒะผะฐัŽ", + "Color generation": "ะ“ะตะฝะตั€ะฐั†ะธั ั†ะฒะตั‚ะพะฒ", + "Number show delay when pressing Super (ms)": "ะ—ะฐะดะตั€ะถะบะฐ ะฟะพะบะฐะทะฐ ะฝะพะผะตั€ะพะฒ ะฟั€ะธ ะฝะฐะถะฐั‚ะธะธ Super (ะผั)", + "illogical-impulse Welcome": "ะ”ะพะฑั€ะพ ะฟะพะถะฐะปะพะฒะฐั‚ัŒ ะฒ illogical-impulse", + "User agent (for services that require it)": "User agent (ะดะปั ัะตั€ะฒะธัะพะฒ, ะบะพั‚ะพั€ั‹ะผ ะพะฝ ะฝัƒะถะตะฝ)", + "Appearance": "ะ’ะฝะตัˆะฝะธะน ะฒะธะด", + "On-screen display": "On-screen display", + "Download complete": "ะ—ะฐะณั€ัƒะทะบะฐ ะทะฐะฒะตั€ัˆะตะฝะฐ", + "Time": "ะ’ั€ะตะผั", + "Float": "ะŸะพะฒะตั€ั…", + "Pick wallpaper image on your system": "ะ’ั‹ะฑะตั€ะธั‚ะต ะธะทะพะฑั€ะฐะถะตะฝะธะต ะดะปั ะพะฑะพะตะฒ ะฝะฐ ัะฒะพั‘ะผ ะŸะš", + "Prevents abrupt increments and restricts volume limit": "ะŸั€ะตะดะพั‚ะฒั€ะฐั‰ะฐะตั‚ ั€ะตะทะบะธะต ัƒะฒะตะปะธั‡ะตะฝะธั ะณั€ะพะผะบะพัั‚ะธ ะธ ะทะฐะบั€ะตะฟะปัะตั‚ ะปะธะผะธั‚ ะณั€ะพะผะพะบัั‚ะธ", + "Unknown Album": "ะะตะธะทะฒะตัั‚ะฝั‹ะน ะะปัŒะฑะพะผ", + "Math result": "ะ ะตะทัƒะปัŒั‚ะฐั‚ ะฒั‹ั‡ะธัะปะตะฝะธั", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "ะกะปัƒั‡ะฐะนะฝั‹ะต SFW ะพะฑะพะธ ั Konachan\nะ˜ะทะพะฑั€ะฐะถะตะฝะธะต ัะพั…ั€ะฐะฝััŽั‚ัั ะฒ ~/ะ˜ะทะพะฑั€ะฐะถะตะฝะธั/Wallpapers", + "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)": "ะœะพะถะตั‚ ะฑั‹ั‚ัŒ ะปัƒั‡ัˆะต, ะตัะปะธ ะฒั‹ ัะดะตะปะฐะตั‚ะต ะผะฝะพะณะพ ะพัˆะธะฑะพะบ,\nะฝะพ ั€ะตะทัƒะปัŒั‚ะฐั‚ั‹ ะผะพะณัƒั‚ ะฑั‹ั‚ัŒ ัั‚ั€ะฐะฝะฝั‹ะผะธ ะธ ะผะพะณัƒั‚ ะฝะต ั€ะฐะฑะพั‚ะฐั‚ัŒ ั ัะพะบั€ะฐั‰ะตะฝะธัะผะธ (ั‚.ะต. \"GIMP\" ะฝะต ะฒั‹ะดะฐัั‚ ะฟั€ะพะณั€ะฐะผะผัƒ ะดะปั ั€ะธัะพะฒะฐะฝะธั)", + "Select output device": "ะ’ั‹ะฑะตั€ะธั‚ะต ะดะธะฝะฐะผะธะบะธ", + "Set the system prompt for the model.": "ะ—ะฐะดะฐั‚ัŒ ัะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚ ะดะปั ัั‚ะพะน ะผะพะดะตะปะธ.", + "Choose file": "ะ’ั‹ะฑะตั€ะธั‚ะต ั„ะฐะนะป", + "Choose model": "ะ’ั‹ะฑะตั€ะธั‚ะต ะผะพะดะตะปัŒ", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "ะŸะพะดัะบะฐะทะบะฐ: ะ’ะบะปัŽั‡ะธั‚ะต \"ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะธะบะพะฝะธะบ \" ะธ \"ะ’ัะตะณะดะฐ ะฟะพะบะฐะทั‹ะฒะฐั‚ัŒ ะฝะพะผะตั€ะฐ\"\nะดะปั ะบะปะฐััะธั‡ะตัะบะพะณะพ ะพะฟั‹ั‚ะฐ illogical-impulse", + "Yes": "ะ”ะฐ", + "Local Ollama model | %1": "ะ›ะพะบะฐะปัŒะฝะฐั ะผะพะดะตะปัŒ Ollama | %1", + "API key set for %1": "API ะบะปัŽั‡ ัƒัั‚ะฐะฝะพะฒะปะตะฝ ะฝะฐ %1", + "Format": "ะคะพั€ะผะฐั‚", + "Colors & Wallpaper": "ะฆะฒะตั‚ะฐ ะธ ะžะฑะพะธ", + "Note: turning off can hurt readability": "ะŸะพะดัะบะฐะทะบะฐ: ะพั‚ะบะปัŽั‡ะตะฝะธะต ะผะพะถะตั‚ ะฟะพะฒั€ะตะดะธั‚ัŒ ั‡ะธั‚ะฐะฑะตะปัŒะฝะพัั‚ัŒ", + "illogical-impulse": "illogical-impulse", + "Automatic suspend": "ะะฒั‚ะพ-ัะพะฝ", + "Random: Konachan": "ะ ะฐะฝะดะพะผ: Konachan", + "Workspace": "ะ ะฐะฑะพั‡ะตะต ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะพ", + "About": "ะž ัะธัั‚ะตะผะต", + "Color picker": "ะŸะธะฟะตั‚ะบะฐ", + "Report a Bug": "ะกะพะพะฑั‰ะธั‚ัŒ ะพะฑ ะพัˆะธะฑะบะต", + "Volume limit": "ะ›ะธะผะธั‚ ะณั€ะพะผะบะพัั‚ะธ", + "GitHub": "GitHub", + "Prefixes": "ะŸั€ะตั„ะธะบัั‹", + "Done": "ะ“ะพั‚ะพะฒะพ", + "Invalid model. Supported: \n```": "ะะตะฟั€ะฐะฒะธะปัŒะฝะฐั ะผะพะดะตะปัŒ. ะ”ะพัั‚ัƒะฟะฝั‹: \n```", + "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "ะงั‚ะพะฑั‹ ัƒัั‚ะฐะฝะพะฒะธั‚ัŒ API ะบะปัŽั‡, ะดะพะฑะฐะฒัŒั‚ะต ะตะต ะบ ะบะพะผะฐะฝะดะต\n\nะงั‚ะพะฑั‹ ะฟั€ะพัะผะพั‚ั€ะตั‚ัŒ ะบะปัŽั‡, ะดะพะฑะฐะฒัŒั‚ะต \"get\" ะบ ะบะพะผะฐะฝะดะต
\n\n### ะ”ะปั %1:\n\n**ะกัั‹ะปะบะฐ**: %2\n\n%3", + "UV Index": "ะฃะค-ะธะฝะดะตะบั", + "Clear the current list of images": "ะžั‡ะธัั‚ะธั‚ัŒ ัะฟะธัะพะบ ะธะทะพะฑั€ะฐะถะตะฝะธะน", + "No audio source": "ะะตั‚ ะธัั‚ะพั‡ะฝะธะบะพะฒ ะฐัƒะดะธะพ", + "API key:\n\n```txt\n%1\n```": "API ะบะปัŽั‡:\n\n```txt\n%1\n```", + "For storing API keys and other sensitive information": "ะ”ะปั ั…ั€ะฐะฝะตะฝะธั API ะบะปัŽั‡ะตะน ะธ ะดั€ัƒะณะพะน ั‡ัƒะฒัั‚ะฒะธั‚ะตะปัŒะฝะพะน ะธะฝั„ะพั€ะผะฐั†ะธะธ", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "ะกั‚ั€ะตะปะพั‡ะบะธ ะดะปั ะฝะฐะฒะธะณะฐั†ะธะธ, Enter ะดะปั ะฒั‹ะฑะพั€ะฐ\nEsc ะธะปะธ ะบะปะธะบ ะฒะตะทะดะต ั‡ั‚ะพะฑั‹ ะพั‚ะผะตะฝะธั‚ัŒ", + "Networking": "ะกะตั‚ัŒ", + "Keep system awake": "ะ”ะตั€ะถะฐั‚ัŒ ะŸะš ะฒะบะปัŽั‡ั‘ะฝะฝั‹ะผ", + "Polling interval (ms)": "ะ˜ะฝั‚ะตั€ะฒะฐะป ะพะฟั€ะพัะฐ (ะผั)", + "To Do": "ะ—ะฐะดะฐั‡ะธ", + "Workspaces": "ะ ะฐะฑะพั‡ะธะต ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "ะญั‚ะพ ะฝะต ัั€ะฐะฑะพั‚ะฐะปะพ. ะŸะพะดัะบะฐะทะบะธ:\n- ะŸั€ะพะฒะตั€ัŒั‚ะต ั‚ะตะณะธ ะธ ะฝะฐัั‚ั€ะพะนะบะธ NSFW\n- ะ•ัะปะธ ะฝะฐ ัƒะผะต ะฝะตั‚ัƒ ั‚ะตะณะพะฒ, ะฒะฒะตะดะธั‚ะต ะฝะพะผะตั€ ัั‚ั€ะฐะฝะธั†ั‹", + "Jump to current month": "ะŸะตั€ะตะนั‚ะธ ะบ ั‚ะตะบัƒั‰ะตะผัƒ ะผะตััั†ัƒ", + "Enter tags, or \"%1\" for commands": "ะ’ะฒะตะดะธั‚ะต ั‚ะตะณะธ, ะธะปะธ \"%1\" ะดะปั ะบะพะผะฐะฝะด", + "No API key set for %1": "API ะบะปัŽั‡ ะฝะต ัƒัั‚ะฐะฝะพะฒะปะตะฝ ะดะปั %1", + "Allow NSFW content": "ะ ะฐะทั€ะตัˆะธั‚ัŒ NSFW ะบะพะฝั‚ะตะฝั‚", + "Save to Downloads": "ะกะพั…ั€ะฐะฝะธั‚ัŒ ะฒ ะทะฐะณั€ัƒะทะบะธ", + "Light": "ะกะฒะตั‚ะปั‹ะน", + "Keyboard toggle": "ะญะบั€ะฐะฝะฝะฐั ะบะปะฐะฒะธะฐั‚ัƒั€ะฐ", + "Night Light": "Night Light", + "We": "ะกั€/*keep*/", + "Mo": "ะŸะฝ/*keep*/", + "Su": "ะ’ั/*keep*/", + "Th": "ะงั‚/*keep*/", + "Tu": "ะ’ั‚/*keep*/", + "Experimental | Online | Google's model\nCan do a little more but doesn't search quickly": "ะญะบัะฟะตั€ะตะผะตะฝั‚ะฐะปัŒะฝะฐั | ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะœะพะถะตั‚ ะฝะตะผะฝะพะณะพ ะฑะพะปัŒัˆะต ะฝะพ ะฝะต ะธั‰ะตั‚ ะพั‡ะตะฝัŒ ะฑั‹ัั‚ั€ะพ", + "Sa": "ะกะฑ/*keep*/", + "Chain of Thought": "ะฆะตะฟะพั‡ะบะฐ ะผั‹ัะปะตะน", + "Fr": "ะŸั‚/*keep*/", + "Usage: %1load CHAT_NAME": "ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: %1load ะ˜ะœะฏ_ะงะะขะ", + "Tool set to %1": "ะฃัั‚ะฐะฝะพะฒะปะตะฝ ะธะฝัั‚ั€ัƒะผะตะฝั‚ %1", + "Set the tool to use for the model.": "ะฃัั‚ะฐะฝะพะฒะธั‚ะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ ะดะปั ัั‚ะพะน ะผะพะดะตะปะธ.", + "Invalid tool. Supported tools:\n- %1": "ะะตะฟั€ะฐะฒะธะปัŒะฝั‹ะน ะธะฝัั‚ั€ัƒะผะตะฝั‚. ะ”ะพัั‚ัƒะฟะฝั‹:\n- %1", + "Usage: %1tool TOOL_NAME": "ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: %1tool ะ˜ะะกะขะ ะฃะœะ•ะะข", + "Performance Profile toggle": "ะŸั€ะพั„ะธะปะธ ะฟั€ะพะธะทะฒะพะดะธั‚ะตะปัŒะฝะพัั‚ะธ", + "Usage: %1save CHAT_NAME": "ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: %1save ะ˜ะœะฏ_ะงะะขะ", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะœะพะดะตะปัŒ Gemini 2.5 Flash ะพะฟั‚ะธะผะธะทะธั€ะพะฒะฐะฝะฐ ะฟะพะด ะผะตะฝัŒัˆะธะต ะทะฐั‚ั€ะฐั‚ั‹ ะธ ะฒั‹ัะพะบัƒัŽ ะฟั€ะพะธะทะฒะพะดะธั‚ะตะปัŒะฝะฝะพัั‚ัŒ", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะะพะฒะฐั ะผะพะดะตะปัŒ, ะบะพั‚ะพั€ะฐั ะผะตะดะปะตะฝะตะต, ั‡ะตะผ ะตะต ะฟั€ะตะดัˆะตัั‚ะฒะตะฝะฝะธะบ, ะฝะพ ะฒั‹ะดะฐะตั‚ ะปัƒั‡ัˆะตะต ะบะฐั‡ะตัั‚ะฒะพ", + "Online | Google's model\nFast, can perform searches for up-to-date information": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะ‘ั‹ัั‚ั€ะฐั, ะผะพะถะตั‚ ะฒั‹ะฟะพะปะฝัั‚ัŒ ะฟะพะธัะบ ะฐะบั‚ัƒะฐะปัŒะฝะพะน ะธะฝั„ะพั€ะผะฐั†ะธะธ", + "Current tool: %1\nSet it with %2tool TOOL": "ะขะตะบัƒั‰ะธะน ะธะฝัั‚ั€ัƒะผะตะฝั‚: %1\nะ˜ะทะผะตะฝัะตั‚ัั ั ะฟะพะผะพั‰ัŒัŽ %2tool ะ˜ะะกะขะ ะฃะœะ•ะะข" +} diff --git a/configs/quickshell/translations/tools/README.md b/configs/quickshell/translations/tools/README.md new file mode 100644 index 0000000..94c2136 --- /dev/null +++ b/configs/quickshell/translations/tools/README.md @@ -0,0 +1,285 @@ +# Translation Management Tool Suite + +This suite is used to manage project translation files, automatically extract translatable texts, compare differences between language files, and provide maintenance functions. + +## Tool Components + +### 1. `translation-manager.py` - Main Translation Manager +- Extract translatable texts +- Compare and update translation files +- Interactive addition/removal of translation keys + +### 2. `translation-cleaner.py` - Translation File Maintenance Tool +- Clean unused translation keys +- Synchronize key structure across different language files + +### 3. `manage-translations.sh` - Convenient Wrapper Script +- Provides a unified command-line interface +- Displays translation status +- Simplifies common operations + +## Quick Start + +### Using the Wrapper Script (Recommended) + +```bash +# Enter the tools directory +cd .config/quickshell/translations/tools + +# Show help +./manage-translations.sh --help + +# Show current translation status +./manage-translations.sh status + +# Extract translatable texts +./manage-translations.sh extract + +# Update all translation files +./manage-translations.sh update + +# Update a specific language +./manage-translations.sh update -l zh_CN + +# Clean unused keys +./manage-translations.sh clean + +# Synchronize keys across all language files +./manage-translations.sh sync +``` + +Or run from the project root: +```bash +# Run from the project root +.config/quickshell/translations/tools/manage-translations.sh status +.config/quickshell/translations/tools/manage-translations.sh update +``` + +## Detailed Usage + +### Translation Manager (`translation-manager.py`) + +Basic usage: +```bash +# Process all languages +./translation-manager.py + +# Specify a particular language +./translation-manager.py --language zh_CN + +# Extract translatable texts only +./translation-manager.py --extract-only + +# Show extracted texts +./translation-manager.py --extract-only --show-temp +``` + +Parameter description: +- `--translations-dir`, `-t`: Translation files directory (default: `.config/quickshell/translations`) +- `--source-dir`, `-s`: Source code directory (default: `.config/quickshell`) +- `--language`, `-l`: Specify the language code to process +- `--extract-only`, `-e`: Only extract translatable texts +- `--show-temp`: Show the content of the temporary extraction file + +### Translation Cleaner (`translation-cleaner.py`) + +```bash +# Clean unused translation keys +./translation-cleaner.py --clean + +# Synchronize translation keys (using en_US as the base) +./translation-cleaner.py --sync + +# Specify a different source language for syncing +./translation-cleaner.py --sync --source-lang zh_CN + +# Clean without creating backups +./translation-cleaner.py --clean --no-backup +``` + +## Workflow + +### Regular Translation Update Workflow + +1. **Check status**: + ```bash + ./manage-translations.sh status + ``` + +2. **Update translations**: + ```bash + ./manage-translations.sh update + ``` + +3. **Clean unused keys** (optional): + ```bash + ./manage-translations.sh clean + ``` + +### Adding a New Language + +1. **Create a new language file**: + ```bash + ./manage-translations.sh update -l new_lang + ``` + +2. **Synchronize key structure**: + ```bash + ./manage-translations.sh sync + ``` + +### Cleanup After Large Refactoring + +1. **Backup translation files**: + ```bash + cp -r .config/quickshell/translations .config/quickshell/translations.backup + ``` + +2. **Clean unused keys**: + ```bash + ./manage-translations.sh clean + ``` + +3. **Synchronize all languages**: + ```bash + ./manage-translations.sh sync + ``` + +## Supported Translatable Text Formats + +The tool recognizes the following formats for translatable texts: + +```qml +// Basic format +Translation.tr("Hello, world!") +Translation.tr('Hello, world!') +Translation.tr(`Hello, world!`) + +// With line breaks +Translation.tr("Line 1\nLine 2") + +// With escape characters +Translation.tr("Say \"Hello\"") + +// With parameter placeholders +Translation.tr("Hello, %1!").arg(name) +``` + +## Example Output + +### Status Display +``` +$ ./manage-translations.sh status +Analyzing translation status... +=== Current Project Status === +166 translatable texts extracted + +=== Translation File Status === + en_US: 470 keys + zh_CN: 470 keys +``` + +### Update Translations +``` +$ ./manage-translations.sh update -l zh_CN +Updating translation files... +================================================== +Processing language: zh_CN +================================================== +Analysis result: + Missing keys: 5 + Extra keys: 20 + +Found 5 missing translation keys: +1. "New feature text" +2. "Another new text" +... + +Add these 5 missing keys? (y/n): y +5 keys added + +Found 20 extra translation keys: +1. "Removed old text" -> "ๅทฒๅˆ ้™ค็š„ๆ—งๆ–‡ๆœฌ" +... + +Delete these 20 extra keys? (y/n): y +20 keys deleted + +Translation file saved +``` + +### Clean Unused Keys +``` +$ ./manage-translations.sh clean +Cleaning unused translation keys... +Processing language: zh_CN +Found 50 unused keys: + 1. "old_unused_text" + 2. "deprecated_message" + ... + +Delete these 50 unused keys? (y/n): y +50 keys deleted +Original key count: 470, after cleaning: 420 +``` + +## Advanced Features + +### Custom Directory Structure + +```bash +# Use custom directories +./translation-manager.py \ + --translations-dir /path/to/translations \ + --source-dir /path/to/source +``` + +### Ignore Mark Feature + +For dynamic resources or special texts that should not be automatically cleaned, you can add `/*keep*/` at the end of the translation value. The tool will automatically ignore these keys and will not delete them during cleaning or syncing. + +Example: +```json +{ + "dynamic_key": "Some dynamic value /*keep*/" +} +``` + +## Notes + +1. **Backup is important**: The tool automatically creates backups before cleaning, but it is recommended to manually back up important files + +2. **Text extraction limitations**: + - ~~Only supports static strings, not dynamically constructed strings~~ + - Dynamic resources (such as variable concatenation or runtime-generated text) cannot be automatically extracted. You need to manually add them to the translation file and use the `/*keep*/` mark for ignore management. + - Must use the `Translation.tr()` format + +3. **File encoding**: All files must use UTF-8 encoding + +4. **Key naming conventions**: It is recommended to use English for key names and avoid special characters + +## Troubleshooting + +### Common Issues + +**Q: Text does not appear after adding Translation.tr?** +A: You need to import the translation feature in your QML file using `import "root:/"`, otherwise the translation text will not be displayed correctly. + +**Q: The number of extracted texts does not match expectations?** +A: Check whether all translatable texts use the `Translation.tr()` format and ensure there are no dynamically constructed strings. + +**Q: Some translations are missing after syncing?** +A: Check whether the source language file contains all necessary keys, and consider using a different source language for syncing. + +**Q: The cleaning operation deleted needed keys?** +A: Restore from the automatically created backup file and check whether `Translation.tr()` is used correctly in the source code. + +### Restore Backup + +```bash +# Restore a single file +cp .config/quickshell/translations/zh_CN.json.backup .config/quickshell/translations/zh_CN.json + +# Restore all files +cp .config/quickshell/translations.backup/* .config/quickshell/translations/ +``` diff --git a/configs/quickshell/translations/tools/guide/translation-tools-guide-zh_CN.md b/configs/quickshell/translations/tools/guide/translation-tools-guide-zh_CN.md new file mode 100644 index 0000000..821533b --- /dev/null +++ b/configs/quickshell/translations/tools/guide/translation-tools-guide-zh_CN.md @@ -0,0 +1,286 @@ +# ็ฟป่ฏ‘็ฎก็†ๅทฅๅ…ทๅฅ—ไปถ + +่ฟ™ๅฅ—ๅทฅๅ…ท็”จไบŽ็ฎก็†้กน็›ฎ็š„็ฟป่ฏ‘ๆ–‡ไปถ๏ผŒ่‡ชๅŠจๆๅ–ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ๏ผŒๆฏ”่พƒไธๅŒ่ฏญ่จ€ๆ–‡ไปถไน‹้—ด็š„ๅทฎๅผ‚๏ผŒๅนถๆไพ›็ปดๆŠคๅŠŸ่ƒฝใ€‚ + +## ๅทฅๅ…ท็ป„ๆˆ + +### 1. `translation-manager.py` - ไธป่ฆ็ฟป่ฏ‘็ฎก็†ๅ™จ +- ๆๅ–ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ +- ๆฏ”่พƒๅ’Œๆ›ดๆ–ฐ็ฟป่ฏ‘ๆ–‡ไปถ +- ไบคไบ’ๅผๆทปๅŠ /ๅˆ ้™ค็ฟป่ฏ‘้”ฎ + +### 2. `translation-cleaner.py` - ็ฟป่ฏ‘ๆ–‡ไปถ็ปดๆŠคๅทฅๅ…ท +- ๆธ…็†ไธๅ†ไฝฟ็”จ็š„็ฟป่ฏ‘้”ฎ +- ๅŒๆญฅไธๅŒ่ฏญ่จ€ๆ–‡ไปถ็š„้”ฎ็ป“ๆž„ + +### 3. `manage-translations.sh` - ไพฟๆทๅŒ…่ฃ…่„šๆœฌ +- ๆไพ›็ปŸไธ€็š„ๅ‘ฝไปค่กŒ็•Œ้ข +- ๆ˜พ็คบ็ฟป่ฏ‘็Šถๆ€ +- ็ฎ€ๅŒ–ๅธธ็”จๆ“ไฝœ + +## ๅฟซ้€Ÿๅผ€ๅง‹ + +### ไฝฟ็”จไพฟๆท่„šๆœฌ๏ผˆๆŽจ่๏ผ‰ + +```bash +# ่ฟ›ๅ…ฅๅทฅๅ…ท็›ฎๅฝ• +cd .config/quickshell/translations/tools + +# ๆŸฅ็œ‹ๅธฎๅŠฉ +./manage-translations.sh --help + +# ๆ˜พ็คบๅฝ“ๅ‰็ฟป่ฏ‘็Šถๆ€ +./manage-translations.sh status + +# ๆๅ–ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ +./manage-translations.sh extract + +# ๆ›ดๆ–ฐๆ‰€ๆœ‰็ฟป่ฏ‘ๆ–‡ไปถ +./manage-translations.sh update + +# ๆ›ดๆ–ฐ็‰นๅฎš่ฏญ่จ€ +./manage-translations.sh update -l zh_CN + +# ๆธ…็†ไธๅ†ไฝฟ็”จ็š„้”ฎ +./manage-translations.sh clean + +# ๅŒๆญฅๆ‰€ๆœ‰่ฏญ่จ€ๆ–‡ไปถ็š„้”ฎ +./manage-translations.sh sync +``` + +ๆˆ–่€…ไปŽ้กน็›ฎๆ น็›ฎๅฝ•่ฟ่กŒ๏ผš +```bash +# ไปŽ้กน็›ฎๆ น็›ฎๅฝ•่ฟ่กŒ +.config/quickshell/translations/tools/manage-translations.sh status +.config/quickshell/translations/tools/manage-translations.sh update +``` + +## ่ฏฆ็ป†ไฝฟ็”จ่ฏดๆ˜Ž + +### ็ฟป่ฏ‘็ฎก็†ๅ™จ (`translation-manager.py`) + +ๅŸบๆœฌ็”จๆณ•๏ผš +```bash +# ๅค„็†ๆ‰€ๆœ‰่ฏญ่จ€ +./translation-manager.py + +# ๆŒ‡ๅฎš็‰นๅฎš่ฏญ่จ€ +./translation-manager.py --language zh_CN + +# ไป…ๆๅ–ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ +./translation-manager.py --extract-only + +# ๆ˜พ็คบๆๅ–็š„ๆ–‡ๆœฌ +./translation-manager.py --extract-only --show-temp +``` + +ๅ‚ๆ•ฐ่ฏดๆ˜Ž๏ผš +- `--translations-dir`, `-t`: ็ฟป่ฏ‘ๆ–‡ไปถ็›ฎๅฝ•๏ผˆ้ป˜่ฎค๏ผš`.config/quickshell/translations`๏ผ‰ +- `--source-dir`, `-s`: ๆบไปฃ็ ็›ฎๅฝ•๏ผˆ้ป˜่ฎค๏ผš`.config/quickshell`๏ผ‰ +- `--language`, `-l`: ๆŒ‡ๅฎš่ฆๅค„็†็š„่ฏญ่จ€ไปฃ็  +- `--extract-only`, `-e`: ไป…ๆๅ–ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ +- `--show-temp`: ๆ˜พ็คบไธดๆ—ถๆๅ–ๆ–‡ไปถ็š„ๅ†…ๅฎน + +### ็ฟป่ฏ‘ๆธ…็†ๅ™จ (`translation-cleaner.py`) + +```bash +# ๆธ…็†ไธๅ†ไฝฟ็”จ็š„็ฟป่ฏ‘้”ฎ +./translation-cleaner.py --clean + +# ๅŒๆญฅ็ฟป่ฏ‘้”ฎ๏ผˆไปฅ en_US ไธบๅŸบๅ‡†๏ผ‰ +./translation-cleaner.py --sync + +# ๆŒ‡ๅฎšไธๅŒ็š„ๆบ่ฏญ่จ€่ฟ›่กŒๅŒๆญฅ +./translation-cleaner.py --sync --source-lang zh_CN + +# ๆธ…็†ๆ—ถไธๅˆ›ๅปบๅค‡ไปฝ +./translation-cleaner.py --clean --no-backup +``` + +## ๅทฅไฝœๆต็จ‹ + +### ๆ—ฅๅธธ็ฟป่ฏ‘ๆ›ดๆ–ฐๆต็จ‹ + +1. **ๆฃ€ๆŸฅ็Šถๆ€**๏ผš + ```bash + ./manage-translations.sh status + ``` + +2. **ๆ›ดๆ–ฐ็ฟป่ฏ‘**๏ผš + ```bash + ./manage-translations.sh update + ``` + +3. **ๆธ…็†ๆ— ็”จ้”ฎ**๏ผˆๅฏ้€‰๏ผ‰๏ผš + ```bash + ./manage-translations.sh clean + ``` + +### ๆ–ฐๅขž่ฏญ่จ€ๆต็จ‹ + +1. **ๅˆ›ๅปบๆ–ฐ่ฏญ่จ€ๆ–‡ไปถ**๏ผš + ```bash + ./manage-translations.sh update -l new_lang + ``` + +2. **ๅŒๆญฅ้”ฎ็ป“ๆž„**๏ผš + ```bash + ./manage-translations.sh sync + ``` + +### ๅคง่ง„ๆจก้‡ๆž„ๅŽ็š„ๆธ…็†ๆต็จ‹ + +1. **ๅค‡ไปฝ็ฟป่ฏ‘ๆ–‡ไปถ**๏ผš + ```bash + cp -r .config/quickshell/translations .config/quickshell/translations.backup + ``` + +2. **ๆธ…็†ๆ— ็”จ้”ฎ**๏ผš + ```bash + ./manage-translations.sh clean + ``` + +3. **ๅŒๆญฅๆ‰€ๆœ‰่ฏญ่จ€**๏ผš + ```bash + ./manage-translations.sh sync + ``` + +## ๆ”ฏๆŒ็š„็ฟป่ฏ‘ๆ–‡ๆœฌๆ ผๅผ + +ๅทฅๅ…ทๅฏไปฅ่ฏ†ๅˆซไปฅไธ‹ๆ ผๅผ็š„ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ๏ผš + +```qml +// ๅŸบๆœฌๆ ผๅผ +Translation.tr("Hello, world!") +Translation.tr('Hello, world!') +Translation.tr(`Hello, world!`) + +// ๅธฆๆข่กŒ็ฌฆ +Translation.tr("Line 1\nLine 2") + +// ๅธฆ่ฝฌไน‰ๅญ—็ฌฆ +Translation.tr("Say \"Hello\"") + +// ๅธฆๅ‚ๆ•ฐๅ ไฝ็ฌฆ +Translation.tr("Hello, %1!").arg(name) +``` + +## ็คบไพ‹่พ“ๅ‡บ + +### ็Šถๆ€ๆ˜พ็คบ +``` +$ ./manage-translations.sh status +ๆญฃๅœจๅˆ†ๆž็ฟป่ฏ‘็Šถๆ€... +=== ๅฝ“ๅ‰้กน็›ฎ็Šถๆ€ === +ๆๅ–ๅˆฐ 166 ไธชๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ + +=== ็ฟป่ฏ‘ๆ–‡ไปถ็Šถๆ€ === + en_US: 470 ไธช้”ฎ + zh_CN: 470 ไธช้”ฎ +``` + +### ๆ›ดๆ–ฐ็ฟป่ฏ‘ +``` +$ ./manage-translations.sh update -l zh_CN +ๆ›ดๆ–ฐ็ฟป่ฏ‘ๆ–‡ไปถ... +================================================== +ๅค„็†่ฏญ่จ€: zh_CN +================================================== +ๅˆ†ๆž็ป“ๆžœ: + ็ผบๅฐ‘็š„้”ฎ: 5 + ๅคšไฝ™็š„้”ฎ: 20 + +ๅ‘็Žฐ 5 ไธช็ผบๅฐ‘็š„็ฟป่ฏ‘้”ฎ๏ผš +1. "New feature text" +2. "Another new text" +... + +ๆ˜ฏๅฆๆทปๅŠ ่ฟ™ 5 ไธช็ผบๅฐ‘็š„้”ฎ๏ผŸ (y/n): y +ๅทฒๆทปๅŠ  5 ไธช้”ฎ + +ๅ‘็Žฐ 20 ไธชๅคšไฝ™็š„็ฟป่ฏ‘้”ฎ๏ผš +1. "Removed old text" -> "ๅทฒๅˆ ้™ค็š„ๆ—งๆ–‡ๆœฌ" +... + +ๆ˜ฏๅฆๅˆ ้™ค่ฟ™ 20 ไธชๅคšไฝ™็š„้”ฎ๏ผŸ (y/n): y +ๅทฒๅˆ ้™ค 20 ไธช้”ฎ + +ๅทฒไฟๅญ˜็ฟป่ฏ‘ๆ–‡ไปถ +``` + +### ๆธ…็†ๆ— ็”จ้”ฎ +``` +$ ./manage-translations.sh clean +ๆธ…็†ไธๅ†ไฝฟ็”จ็š„็ฟป่ฏ‘้”ฎ... +ๅค„็†่ฏญ่จ€: zh_CN +ๅ‘็Žฐ 50 ไธชไธๅ†ไฝฟ็”จ็š„้”ฎ: + 1. "old_unused_text" + 2. "deprecated_message" + ... + +ๆ˜ฏๅฆๅˆ ้™ค่ฟ™ 50 ไธชไธๅ†ไฝฟ็”จ็š„้”ฎ๏ผŸ (y/n): y +ๅทฒๅˆ ้™ค 50 ไธช้”ฎ +ๅŽŸๅง‹้”ฎๆ•ฐ: 470, ๆธ…็†ๅŽ: 420 +``` + +## ้ซ˜็บงๅŠŸ่ƒฝ + +### ่‡ชๅฎšไน‰็›ฎๅฝ•็ป“ๆž„ + +```bash +# ไฝฟ็”จ่‡ชๅฎšไน‰็›ฎๅฝ• +./translation-manager.py \ + --translations-dir /path/to/translations \ + --source-dir /path/to/source +``` + + +## ๆณจๆ„ไบ‹้กน + +1. **ๅค‡ไปฝ้‡่ฆ**๏ผšๅœจๆ‰ง่กŒๆธ…็†ๆ“ไฝœๅ‰๏ผŒๅทฅๅ…ทไผš่‡ชๅŠจๅˆ›ๅปบๅค‡ไปฝ๏ผŒไฝ†ๅปบ่ฎฎๆ‰‹ๅŠจๅค‡ไปฝ้‡่ฆๆ–‡ไปถ + +2. **ๆ–‡ๆœฌๆๅ–้™ๅˆถ**๏ผš + - ~~ๅชๆ”ฏๆŒ้™ๆ€ๅญ—็ฌฆไธฒ๏ผŒไธๆ”ฏๆŒๅŠจๆ€ๆž„ๅปบ็š„ๅญ—็ฌฆไธฒ~~ + - ๅŠจๆ€่ต„ๆบ๏ผˆๅฆ‚ๅ˜้‡ๆ‹ผๆŽฅใ€่ฟ่กŒๆ—ถ็”Ÿๆˆ็š„ๆ–‡ๆœฌ๏ผ‰ๆ— ๆณ•่‡ชๅŠจๆๅ–๏ผŒ้œ€่ฆๅœจ็ฟป่ฏ‘ๆ–‡ไปถไธญๆ‰‹ๅŠจๆทปๅŠ ๏ผŒๅนถไฝฟ็”จ `/*keep*/` ๆ ‡่ฎฐ่ฟ›่กŒๅฟฝ็•ฅ็ฎก็†ใ€‚ + - ๅฟ…้กปไฝฟ็”จ `Translation.tr()` ๆ ผๅผ +### ๅฟฝ็•ฅๆ ‡่ฎฐๅŠŸ่ƒฝ + +ๅฏนไบŽๅŠจๆ€่ต„ๆบๆˆ–็‰นๆฎŠๆ–‡ๆœฌ๏ผŒๅฆ‚ๆžœไธๅธŒๆœ›่ขซ่‡ชๅŠจๆธ…็†๏ผŒๅฏๅœจ็ฟป่ฏ‘ๅ€ผๆœซๅฐพๆทปๅŠ  `/*keep*/`๏ผŒๅทฅๅ…ทไผš่‡ชๅŠจๅฟฝ็•ฅ่ฟ™ไบ›้”ฎ๏ผŒไธไผšๅœจๆธ…็†ๅ’ŒๅŒๆญฅๆ—ถๅˆ ้™คใ€‚ + +็คบไพ‹๏ผš +```json +{ + "dynamic_key": "Some dynamic value /*keep*/" +} +``` + +3. **ๆ–‡ไปถ็ผ–็ **๏ผšๆ‰€ๆœ‰ๆ–‡ไปถๅฟ…้กปไฝฟ็”จ UTF-8 ็ผ–็  + +4. **้”ฎๅ่ง„่Œƒ**๏ผšๅปบ่ฎฎไฝฟ็”จ่‹ฑๆ–‡ไฝœไธบ้”ฎๅ๏ผŒ้ฟๅ…ไฝฟ็”จ็‰นๆฎŠๅญ—็ฌฆ + +## ๆ•…้šœๆŽ’้™ค + +### ๅธธ่ง้—ฎ้ข˜ + + +**Q: ๆทปๅŠ ไบ† Translation.tr ๅŽๆ–‡ๅญ—ไธๆ˜พ็คบ๏ผŸ** +A: ้œ€่ฆๅœจ QML ๆ–‡ไปถไธญไฝฟ็”จ `import "root:/"` ๅฏผๅ…ฅ็ฟป่ฏ‘ๅŠŸ่ƒฝ๏ผŒๅฆๅˆ™ๆ— ๆณ•ๆญฃๅธธๆ˜พ็คบ็ฟป่ฏ‘ๆ–‡ๆœฌใ€‚ + +**Q: ๆๅ–็š„ๆ–‡ๆœฌๆ•ฐ้‡ไธŽ้ข„ๆœŸไธ็ฌฆ๏ผŸ** +A: ๆฃ€ๆŸฅๆ˜ฏๅฆๆ‰€ๆœ‰ๅฏ็ฟป่ฏ‘ๆ–‡ๆœฌ้ƒฝไฝฟ็”จไบ† `Translation.tr()` ๆ ผๅผ๏ผŒ็กฎไฟๆฒกๆœ‰ๅŠจๆ€ๆž„ๅปบ็š„ๅญ—็ฌฆไธฒใ€‚ + +**Q: ๅŒๆญฅๅŽๆŸไบ›็ฟป่ฏ‘ไธขๅคฑ๏ผŸ** +A: ๆฃ€ๆŸฅๆบ่ฏญ่จ€ๆ–‡ไปถๆ˜ฏๅฆๅŒ…ๅซๆ‰€ๆœ‰ๅฟ…่ฆ็š„้”ฎ๏ผŒ่€ƒ่™‘ไฝฟ็”จไธๅŒ็š„ๆบ่ฏญ่จ€่ฟ›่กŒๅŒๆญฅใ€‚ + +**Q: ๆธ…็†ๆ“ไฝœๅˆ ้™คไบ†้œ€่ฆ็š„้”ฎ๏ผŸ** +A: ไปŽ่‡ชๅŠจๅˆ›ๅปบ็š„ๅค‡ไปฝๆ–‡ไปถไธญๆขๅค๏ผŒๆฃ€ๆŸฅๆบไปฃ็ ไธญๆ˜ฏๅฆๆญฃ็กฎไฝฟ็”จไบ† `Translation.tr()`ใ€‚ + +### ๆขๅคๅค‡ไปฝ + +```bash +# ๆขๅคๅ•ไธชๆ–‡ไปถ +cp .config/quickshell/translations/zh_CN.json.backup .config/quickshell/translations/zh_CN.json + +# ๆขๅคๆ‰€ๆœ‰ๆ–‡ไปถ +cp .config/quickshell/translations.backup/* .config/quickshell/translations/ +``` diff --git a/configs/quickshell/translations/tools/guide/translation-tools-guide.md b/configs/quickshell/translations/tools/guide/translation-tools-guide.md new file mode 100644 index 0000000..94c2136 --- /dev/null +++ b/configs/quickshell/translations/tools/guide/translation-tools-guide.md @@ -0,0 +1,285 @@ +# Translation Management Tool Suite + +This suite is used to manage project translation files, automatically extract translatable texts, compare differences between language files, and provide maintenance functions. + +## Tool Components + +### 1. `translation-manager.py` - Main Translation Manager +- Extract translatable texts +- Compare and update translation files +- Interactive addition/removal of translation keys + +### 2. `translation-cleaner.py` - Translation File Maintenance Tool +- Clean unused translation keys +- Synchronize key structure across different language files + +### 3. `manage-translations.sh` - Convenient Wrapper Script +- Provides a unified command-line interface +- Displays translation status +- Simplifies common operations + +## Quick Start + +### Using the Wrapper Script (Recommended) + +```bash +# Enter the tools directory +cd .config/quickshell/translations/tools + +# Show help +./manage-translations.sh --help + +# Show current translation status +./manage-translations.sh status + +# Extract translatable texts +./manage-translations.sh extract + +# Update all translation files +./manage-translations.sh update + +# Update a specific language +./manage-translations.sh update -l zh_CN + +# Clean unused keys +./manage-translations.sh clean + +# Synchronize keys across all language files +./manage-translations.sh sync +``` + +Or run from the project root: +```bash +# Run from the project root +.config/quickshell/translations/tools/manage-translations.sh status +.config/quickshell/translations/tools/manage-translations.sh update +``` + +## Detailed Usage + +### Translation Manager (`translation-manager.py`) + +Basic usage: +```bash +# Process all languages +./translation-manager.py + +# Specify a particular language +./translation-manager.py --language zh_CN + +# Extract translatable texts only +./translation-manager.py --extract-only + +# Show extracted texts +./translation-manager.py --extract-only --show-temp +``` + +Parameter description: +- `--translations-dir`, `-t`: Translation files directory (default: `.config/quickshell/translations`) +- `--source-dir`, `-s`: Source code directory (default: `.config/quickshell`) +- `--language`, `-l`: Specify the language code to process +- `--extract-only`, `-e`: Only extract translatable texts +- `--show-temp`: Show the content of the temporary extraction file + +### Translation Cleaner (`translation-cleaner.py`) + +```bash +# Clean unused translation keys +./translation-cleaner.py --clean + +# Synchronize translation keys (using en_US as the base) +./translation-cleaner.py --sync + +# Specify a different source language for syncing +./translation-cleaner.py --sync --source-lang zh_CN + +# Clean without creating backups +./translation-cleaner.py --clean --no-backup +``` + +## Workflow + +### Regular Translation Update Workflow + +1. **Check status**: + ```bash + ./manage-translations.sh status + ``` + +2. **Update translations**: + ```bash + ./manage-translations.sh update + ``` + +3. **Clean unused keys** (optional): + ```bash + ./manage-translations.sh clean + ``` + +### Adding a New Language + +1. **Create a new language file**: + ```bash + ./manage-translations.sh update -l new_lang + ``` + +2. **Synchronize key structure**: + ```bash + ./manage-translations.sh sync + ``` + +### Cleanup After Large Refactoring + +1. **Backup translation files**: + ```bash + cp -r .config/quickshell/translations .config/quickshell/translations.backup + ``` + +2. **Clean unused keys**: + ```bash + ./manage-translations.sh clean + ``` + +3. **Synchronize all languages**: + ```bash + ./manage-translations.sh sync + ``` + +## Supported Translatable Text Formats + +The tool recognizes the following formats for translatable texts: + +```qml +// Basic format +Translation.tr("Hello, world!") +Translation.tr('Hello, world!') +Translation.tr(`Hello, world!`) + +// With line breaks +Translation.tr("Line 1\nLine 2") + +// With escape characters +Translation.tr("Say \"Hello\"") + +// With parameter placeholders +Translation.tr("Hello, %1!").arg(name) +``` + +## Example Output + +### Status Display +``` +$ ./manage-translations.sh status +Analyzing translation status... +=== Current Project Status === +166 translatable texts extracted + +=== Translation File Status === + en_US: 470 keys + zh_CN: 470 keys +``` + +### Update Translations +``` +$ ./manage-translations.sh update -l zh_CN +Updating translation files... +================================================== +Processing language: zh_CN +================================================== +Analysis result: + Missing keys: 5 + Extra keys: 20 + +Found 5 missing translation keys: +1. "New feature text" +2. "Another new text" +... + +Add these 5 missing keys? (y/n): y +5 keys added + +Found 20 extra translation keys: +1. "Removed old text" -> "ๅทฒๅˆ ้™ค็š„ๆ—งๆ–‡ๆœฌ" +... + +Delete these 20 extra keys? (y/n): y +20 keys deleted + +Translation file saved +``` + +### Clean Unused Keys +``` +$ ./manage-translations.sh clean +Cleaning unused translation keys... +Processing language: zh_CN +Found 50 unused keys: + 1. "old_unused_text" + 2. "deprecated_message" + ... + +Delete these 50 unused keys? (y/n): y +50 keys deleted +Original key count: 470, after cleaning: 420 +``` + +## Advanced Features + +### Custom Directory Structure + +```bash +# Use custom directories +./translation-manager.py \ + --translations-dir /path/to/translations \ + --source-dir /path/to/source +``` + +### Ignore Mark Feature + +For dynamic resources or special texts that should not be automatically cleaned, you can add `/*keep*/` at the end of the translation value. The tool will automatically ignore these keys and will not delete them during cleaning or syncing. + +Example: +```json +{ + "dynamic_key": "Some dynamic value /*keep*/" +} +``` + +## Notes + +1. **Backup is important**: The tool automatically creates backups before cleaning, but it is recommended to manually back up important files + +2. **Text extraction limitations**: + - ~~Only supports static strings, not dynamically constructed strings~~ + - Dynamic resources (such as variable concatenation or runtime-generated text) cannot be automatically extracted. You need to manually add them to the translation file and use the `/*keep*/` mark for ignore management. + - Must use the `Translation.tr()` format + +3. **File encoding**: All files must use UTF-8 encoding + +4. **Key naming conventions**: It is recommended to use English for key names and avoid special characters + +## Troubleshooting + +### Common Issues + +**Q: Text does not appear after adding Translation.tr?** +A: You need to import the translation feature in your QML file using `import "root:/"`, otherwise the translation text will not be displayed correctly. + +**Q: The number of extracted texts does not match expectations?** +A: Check whether all translatable texts use the `Translation.tr()` format and ensure there are no dynamically constructed strings. + +**Q: Some translations are missing after syncing?** +A: Check whether the source language file contains all necessary keys, and consider using a different source language for syncing. + +**Q: The cleaning operation deleted needed keys?** +A: Restore from the automatically created backup file and check whether `Translation.tr()` is used correctly in the source code. + +### Restore Backup + +```bash +# Restore a single file +cp .config/quickshell/translations/zh_CN.json.backup .config/quickshell/translations/zh_CN.json + +# Restore all files +cp .config/quickshell/translations.backup/* .config/quickshell/translations/ +``` diff --git a/configs/quickshell/translations/tools/manage-translations.sh b/configs/quickshell/translations/tools/manage-translations.sh new file mode 100755 index 0000000..c20896a --- /dev/null +++ b/configs/quickshell/translations/tools/manage-translations.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Translation management script - convenient wrapper + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRANSLATIONS_DIR="$(dirname "$SCRIPT_DIR")" +SOURCE_DIR="$(dirname "$(dirname "$TRANSLATIONS_DIR")")" + +show_help() { + echo "Translation Management Tool - Convenient Wrapper" + echo "" + echo "Usage: $0 [options] " + echo "" + echo "Commands:" + echo " extract Extract translatable texts to temporary file" + echo " update Update translation files (add missing/remove extra keys)" + echo " clean Clean unused translation keys" + echo " sync Sync keys across all language files" + echo " status Show translation status" + echo "" + echo "Options:" + echo " -l, --lang LANG Specify language (e.g.: zh_CN)" + echo " -t, --trans-dir DIR Translation files directory (default: $TRANSLATIONS_DIR)" + echo " -s, --source-dir DIR Source code directory (default: $SOURCE_DIR)" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " $0 extract # Extract translatable texts" + echo " $0 update -l zh_CN # Update Chinese translations" + echo " $0 update # Update all translations" + echo " $0 clean # Clean unused keys" + echo " $0 sync # Sync keys across all languages" + echo " $0 status # Show translation status" +} + +show_status() { + echo "Analyzing translation status..." + + # Extract current text count + echo "=== Current Project Status ===" + python3 "$SCRIPT_DIR/translation-manager.py" \ + --translations-dir "$TRANSLATIONS_DIR" \ + --source-dir "$SOURCE_DIR" \ + --extract-only | grep "Extracted" + + echo "" + echo "=== Translation File Status ===" + + if [ -d "$TRANSLATIONS_DIR" ]; then + for file in "$TRANSLATIONS_DIR"/*.json; do + if [ -f "$file" ]; then + lang=$(basename "$file" .json) + count=$(jq 'length' "$file" 2>/dev/null || echo "error") + echo " $lang: $count keys" + fi + done + else + echo " Translation directory does not exist: $TRANSLATIONS_DIR" + fi +} + +# Parse command line arguments +LANG_CODE="" +COMMAND="" + +while [[ $# -gt 0 ]]; do + case $1 in + -l|--lang) + LANG_CODE="$2" + shift 2 + ;; + -t|--trans-dir) + TRANSLATIONS_DIR="$2" + shift 2 + ;; + -s|--source-dir) + SOURCE_DIR="$2" + shift 2 + ;; + -h|--help) + show_help + exit 0 + ;; + extract|update|clean|sync|status) + if [ -n "$COMMAND" ]; then + echo "Error: Only one command can be specified" + exit 1 + fi + COMMAND="$1" + shift + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + +if [ -z "$COMMAND" ]; then + echo "Error: A command must be specified" + show_help + exit 1 +fi + +# Check dependencies +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is required" + exit 1 +fi + +if [ "$COMMAND" = "status" ] && ! command -v jq >/dev/null 2>&1; then + echo "Warning: jq is not installed, status display may be incomplete" +fi + +# Build base arguments +BASE_ARGS="--translations-dir $TRANSLATIONS_DIR --source-dir $SOURCE_DIR" + +case $COMMAND in + extract) + echo "Extracting translatable texts..." + python3 "$SCRIPT_DIR/translation-manager.py" $BASE_ARGS --extract-only --show-temp + ;; + update) + echo "Updating translation files..." + if [ -n "$LANG_CODE" ]; then + python3 "$SCRIPT_DIR/translation-manager.py" $BASE_ARGS --language "$LANG_CODE" + else + python3 "$SCRIPT_DIR/translation-manager.py" $BASE_ARGS + fi + ;; + clean) + echo "Cleaning unused translation keys..." + python3 "$SCRIPT_DIR/translation-cleaner.py" $BASE_ARGS --clean + ;; + sync) + echo "Syncing translation keys..." + python3 "$SCRIPT_DIR/translation-cleaner.py" $BASE_ARGS --sync + ;; + status) + show_status + ;; + *) + echo "Unknown command: $COMMAND" + show_help + exit 1 + ;; +esac diff --git a/configs/quickshell/translations/tools/translation-cleaner.py b/configs/quickshell/translations/tools/translation-cleaner.py new file mode 100755 index 0000000..b26e3fb --- /dev/null +++ b/configs/quickshell/translations/tools/translation-cleaner.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Translation File Maintenance Helper +Used to clean and organize translation files, removing unused keys +""" + +import os +import sys +import json +import argparse +import importlib.util +from pathlib import Path +from typing import Dict, Set, List + +# Import from the same directory using importlib +current_dir = os.path.dirname(os.path.abspath(__file__)) +manager_path = os.path.join(current_dir, 'translation-manager.py') +spec = importlib.util.spec_from_file_location("translation_manager", manager_path) +translation_manager = importlib.util.module_from_spec(spec) +spec.loader.exec_module(translation_manager) +TranslationManager = translation_manager.TranslationManager + +def clean_translation_files(translations_dir: str, source_dir: str, backup: bool = True): + """Clean translation files by removing unused keys""" + print("Starting translation file cleanup...") + + # Create manager + manager = TranslationManager(translations_dir, source_dir) + + # Extract currently used texts + print("Extracting currently used translatable texts...") + current_texts = manager.extract_translatable_texts() + print(f"Extracted {len(current_texts)} currently used texts") + + # Get all language files + languages = manager.get_available_languages() + if not languages: + print("No translation files found") + return + + print(f"Found language files: {', '.join(languages)}") + + total_removed = 0 + + for lang in languages: + print(f"\nProcessing language: {lang}") + + # Load translation file + translations = manager.load_translation_file(lang) + original_count = len(translations) + + # Find unused keys, skip those whose value ends with /*keep*/ + unused_keys = set() + for k in translations.keys(): + v = translations[k] + if k not in current_texts: + if isinstance(v, str) and v.strip().endswith('/*keep*/'): + continue + unused_keys.add(k) + + if unused_keys: + print(f"Found {len(unused_keys)} unused keys:") + for i, key in enumerate(sorted(unused_keys)[:10], 1): # Only show first 10 + print(f" {i}. \"{key[:50]}{'...' if len(key) > 50 else ''}\"") + if len(unused_keys) > 10: + print(f" ... and {len(unused_keys) - 10} more keys") + + response = input(f"Delete these {len(unused_keys)} unused keys? (y/n): ") + if response.lower().strip() in ['y', 'yes']: + if backup: + # Create backup only when user confirms deletion + backup_file = Path(translations_dir) / f"{lang}.json.bak" + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Created backup: {backup_file}") + # Delete unused keys + for key in unused_keys: + del translations[key] + + # Save cleaned file + manager.save_translation_file(lang, translations) + removed_count = len(unused_keys) + total_removed += removed_count + print(f"Deleted {removed_count} keys") + else: + print("Skipped deletion") + else: + print("No unused keys found") + + new_count = len(translations) + print(f"Original key count: {original_count}, after cleanup: {new_count}") + + print(f"\nCleanup completed! Total deleted {total_removed} unused keys.") + +def sync_translations(translations_dir: str, source_lang: str = "en_US", target_langs: List[str] = None): + """Sync translation keys to ensure all language files have the same keys""" + print(f"Starting translation key sync using {source_lang} as reference...") + + translations_path = Path(translations_dir) + + # Load source language file + source_file = translations_path / f"{source_lang}.json" + if not source_file.exists(): + print(f"Error: Source language file does not exist: {source_file}") + return + + with open(source_file, 'r', encoding='utf-8') as f: + source_translations = json.load(f) + + source_keys = set(source_translations.keys()) + print(f"Source language {source_lang} has {len(source_keys)} keys") + + # Get target language list + if target_langs is None: + target_langs = [] + for file_path in translations_path.glob("*.json"): + lang_code = file_path.stem + if lang_code != source_lang: + target_langs.append(lang_code) + + if not target_langs: + print("No target language files found") + return + + print(f"Target languages: {', '.join(target_langs)}") + + for target_lang in target_langs: + print(f"\nSyncing language: {target_lang}") + + target_file = translations_path / f"{target_lang}.json" + if target_file.exists(): + with open(target_file, 'r', encoding='utf-8') as f: + target_translations = json.load(f) + else: + target_translations = {} + + target_keys = set(target_translations.keys()) + + # Find missing and extra keys + missing_keys = source_keys - target_keys + extra_keys = target_keys - source_keys + + print(f" Missing keys: {len(missing_keys)}") + print(f" Extra keys: {len(extra_keys)}") + + # Add missing keys + if missing_keys: + for key in missing_keys: + # Use source language value as placeholder by default + target_translations[key] = source_translations[key] + print(f" Added {len(missing_keys)} missing keys") + + # Ask whether to delete extra keys + if extra_keys: + response = input(f" Delete {len(extra_keys)} extra keys? (y/n): ") + if response.lower().strip() in ['y', 'yes']: + for key in extra_keys: + del target_translations[key] + print(f" Deleted {len(extra_keys)} extra keys") + + # Save file (ensure UTF-8, fix for special chars) + with open(target_file, 'w', encoding='utf-8', newline='') as f: + json.dump(target_translations, f, ensure_ascii=False, indent=2) + print(f" Saved: {target_file}") + +def main(): + parser = argparse.ArgumentParser(description="Translation File Maintenance Helper") + parser.add_argument("--translations-dir", "-t", + default=".config/quickshell/translations", + help="Translation files directory") + parser.add_argument("--source-dir", "-s", + default=".config/quickshell", + help="Source code directory") + parser.add_argument("--clean", "-c", action="store_true", + help="Clean unused translation keys") + parser.add_argument("--sync", action="store_true", + help="Sync translation keys") + parser.add_argument("--source-lang", default="en_US", + help="Source language for syncing (default: en_US)") + parser.add_argument("--no-backup", action="store_true", + help="Do not create backup files when cleaning") + + args = parser.parse_args() + + # Convert to absolute paths + translations_dir = os.path.abspath(args.translations_dir) + source_dir = os.path.abspath(args.source_dir) + + if args.clean: + clean_translation_files(translations_dir, source_dir, backup=not args.no_backup) + elif args.sync: + sync_translations(translations_dir, args.source_lang) + else: + print("Please specify an operation:") + print(" --clean: Clean unused translation keys") + print(" --sync: Sync translation keys") + +if __name__ == "__main__": + main() diff --git a/configs/quickshell/translations/tools/translation-manager.py b/configs/quickshell/translations/tools/translation-manager.py new file mode 100755 index 0000000..a310544 --- /dev/null +++ b/configs/quickshell/translations/tools/translation-manager.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Translation File Management Script +Used to update and extract translatable texts, manage JSON translation file key comparison +""" + +import os +import json +import re +import sys +import argparse +from pathlib import Path +from typing import Dict, Set, List, Tuple +import tempfile +import subprocess + +class TranslationManager: + def __init__(self, translations_dir: str, source_dir: str): + self.translations_dir = Path(translations_dir) + self.source_dir = Path(source_dir) + self.temp_extracted_file = None + + # Ensure translation directory exists + self.translations_dir.mkdir(parents=True, exist_ok=True) + + def extract_translatable_texts(self) -> Set[str]: + """Extract translatable texts from source code""" + translatable_texts = set() + + # Search patterns: Translation.tr("text") or Translation.tr('text') + # Improved regex that handles nested quotes correctly + patterns = [ + r'Translation\.tr\s*\(\s*(["\'])(((?!\1)[^\\]|\\.)*)(\1)\s*\)', # Double or single quotes with escape support + r'Translation\.tr\s*\(\s*`([^`]*(?:\\.[^`]*)*?)`\s*\)', # Backticks (template strings) + ] + + # Search all .qml and .js files + file_extensions = ['*.qml', '*.js'] + + for ext in file_extensions: + for file_path in self.source_dir.rglob(ext): + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + for pattern in patterns: + matches = re.findall(pattern, content, re.MULTILINE | re.DOTALL) + for match in matches: + # Handle different match group structures + if isinstance(match, tuple): + # For improved regex, text is in the second group + if len(match) >= 3: + text = match[1] # Second group is the text content + else: + text = match[0] if match else "" + else: + text = match + + try: + if '\\u' in text or '\\x' in text: + clean_text = bytes(text, "utf-8").decode("unicode_escape") + else: + clean_text = ( + text.replace('\\n', '\n') + .replace('\\t', '\t') + .replace('\\r', '\r') + .replace('\\"', '"') + .replace('\\\'', "'") + .replace('\\f', '\f') + .replace('\\b', '\b') + .replace('\\\\', '\\') + ) + except Exception: + clean_text = text + + # Clean text (remove extra whitespace) + clean_text = clean_text.strip() + if clean_text: + translatable_texts.add(clean_text) + + except (UnicodeDecodeError, IOError) as e: + print(f"Warning: Cannot read file {file_path}: {e}") + + return translatable_texts + + def create_temp_translation_file(self, texts: Set[str]) -> str: + """Create temporary JSON file containing extracted texts""" + temp_data = {} + for text in sorted(texts): + temp_data[text] = text # Key and value are the same, indicating untranslated + + # Create temporary file + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f: + json.dump(temp_data, f, ensure_ascii=False, indent=2) + self.temp_extracted_file = f.name + + return self.temp_extracted_file + + def load_translation_file(self, lang_code: str) -> Dict[str, str]: + """Load translation file for specified language""" + file_path = self.translations_dir / f"{lang_code}.json" + if file_path.exists(): + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Cannot load translation file {file_path}: {e}") + return {} + return {} + + def save_translation_file(self, lang_code: str, translations: Dict[str, str]): + """Save translation file""" + file_path = self.translations_dir / f"{lang_code}.json" + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Translation file saved: {file_path}") + except IOError as e: + print(f"Error: Cannot save translation file {file_path}: {e}") + + def get_available_languages(self) -> List[str]: + """Get list of available languages""" + languages = [] + for file_path in self.translations_dir.glob("*.json"): + lang_code = file_path.stem + languages.append(lang_code) + return sorted(languages) + + def compare_translations(self, extracted_texts: Set[str], target_lang: str) -> Tuple[Set[str], Set[str]]: + """Compare extracted texts with existing translation file""" + existing_translations = self.load_translation_file(target_lang) + existing_keys = set(existing_translations.keys()) + + missing_keys = extracted_texts - existing_keys # Missing keys + extra_keys = existing_keys - extracted_texts # Extra keys + + return missing_keys, extra_keys + + def interactive_update(self, lang_code: str, missing_keys: Set[str], extra_keys: Set[str]): + """Interactively update translation file, create backup only if updating""" + translations = self.load_translation_file(lang_code) + modified = False + backup_created = False + + # Handle missing keys + if missing_keys: + print(f"\nFound {len(missing_keys)} missing translation keys:") + for i, key in enumerate(sorted(missing_keys), 1): + print(f"{i}. \"{key}\"") + + if self.ask_yes_no(f"\nAdd these {len(missing_keys)} missing keys?"): + if not backup_created: + backup_file = self.translations_dir / f"{lang_code}.json.bak" + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Created backup: {backup_file}") + backup_created = True + for key in missing_keys: + translations[key] = key # Default value is the key itself + modified = True + print(f"Added {len(missing_keys)} keys") + + # Handle extra keys + if extra_keys: + # Only show extra keys that are not marked with /*keep*/ + filtered_extra_keys = [key for key in extra_keys if not (isinstance(translations.get(key, ""), str) and translations.get(key, "").strip().endswith('/*keep*/'))] + if filtered_extra_keys: + print(f"\nFound {len(filtered_extra_keys)} extra translation keys:") + for i, key in enumerate(sorted(filtered_extra_keys), 1): + print(f"{i}. \"{key}\" -> \"{translations.get(key, '')}\"") + if self.ask_yes_no(f"\nDelete these {len(filtered_extra_keys)} extra keys?"): + if not backup_created: + backup_file = self.translations_dir / f"{lang_code}.json.bak" + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Created backup: {backup_file}") + backup_created = True + deleted_count = 0 + for key in filtered_extra_keys: + if key in translations: + del translations[key] + modified = True + deleted_count += 1 + print(f"Deleted {deleted_count} keys") + + # Save changes + if modified: + self.save_translation_file(lang_code, translations) + else: + print("No changes made") + + def ask_yes_no(self, question: str) -> bool: + """Ask user for confirmation""" + while True: + response = input(f"{question} (y/n): ").lower().strip() + if response in ['y', 'yes']: + return True + elif response in ['n', 'no']: + return False + else: + print("Please enter y/yes or n/no") + + def cleanup(self): + """Clean up temporary files""" + if self.temp_extracted_file and os.path.exists(self.temp_extracted_file): + os.unlink(self.temp_extracted_file) + +def main(): + parser = argparse.ArgumentParser(description="Translation file management tool") + parser.add_argument("--translations-dir", "-t", + default=".config/quickshell/translations", + help="Translation files directory (default: .config/quickshell/translations)") + parser.add_argument("--source-dir", "-s", + default=".config/quickshell", + help="Source code directory (default: .config/quickshell)") + parser.add_argument("--language", "-l", + help="Specify language code to process (e.g., zh_CN)") + parser.add_argument("--extract-only", "-e", action="store_true", + help="Only extract translatable texts to temporary file") + parser.add_argument("--show-temp", action="store_true", + help="Show temporary extracted file content") + + args = parser.parse_args() + + # Convert to absolute paths + translations_dir = os.path.abspath(args.translations_dir) + source_dir = os.path.abspath(args.source_dir) + + print(f"Translation directory: {translations_dir}") + print(f"Source code directory: {source_dir}") + + # Check if directories exist + if not os.path.exists(source_dir): + print(f"Error: Source code directory does not exist: {source_dir}") + sys.exit(1) + + # Create manager + manager = TranslationManager(translations_dir, source_dir) + + try: + # Extract translatable texts + print("\nExtracting translatable texts...") + extracted_texts = manager.extract_translatable_texts() + print(f"Extracted {len(extracted_texts)} translatable texts") + + # Create temporary file + temp_file = manager.create_temp_translation_file(extracted_texts) + print(f"Created temporary file: {temp_file}") + + if args.show_temp: + print("\nTemporary file contents:") + with open(temp_file, 'r', encoding='utf-8') as f: + print(f.read()) + + if args.extract_only: + print("Extract-only mode, program finished") + return + + # Get available languages + available_languages = manager.get_available_languages() + + if args.language: + target_languages = [args.language] + else: + print(f"\nAvailable languages: {', '.join(available_languages) if available_languages else 'None'}") + + if not available_languages: + print("No existing translation files found") + lang_input = input("Enter language code to create (e.g.: zh_CN): ").strip() + if lang_input: + target_languages = [lang_input] + else: + print("No language specified, program finished") + return + else: + print("Choose language to process:") + for i, lang in enumerate(available_languages, 1): + print(f"{i}. {lang}") + print("a. Process all languages") + + choice = input("Please choose (enter number, language code, or 'a'): ").strip() + + if choice.lower() == 'a': + target_languages = available_languages + elif choice.isdigit() and 1 <= int(choice) <= len(available_languages): + target_languages = [available_languages[int(choice) - 1]] + elif choice in available_languages: + target_languages = [choice] + else: + print("Invalid choice, program finished") + return + + # Process each language + for lang in target_languages: + print(f"\n{'='*50}") + print(f"Processing language: {lang}") + print('='*50) + + missing_keys, extra_keys = manager.compare_translations(extracted_texts, lang) + + if not missing_keys and not extra_keys: + print(f"Translation file for language {lang} is already up to date") + continue + + print(f"Analysis results:") + print(f" Missing keys: {len(missing_keys)}") + # Load translation file for current lang to get values + current_translations = manager.load_translation_file(lang) + filtered_extra_keys = [key for key in extra_keys if not (isinstance(current_translations.get(key, ""), str) and current_translations.get(key, "").strip().endswith('/*keep*/'))] + ignored_extra_keys = [key for key in extra_keys if (isinstance(current_translations.get(key, ""), str) and current_translations.get(key, "").strip().endswith('/*keep*/'))] + print(f" Extra keys: {len(filtered_extra_keys)}") + if ignored_extra_keys: + print(f" Ignored keys: {len(ignored_extra_keys)} (marked with /*keep*/)") + + if missing_keys or extra_keys: + manager.interactive_update(lang, missing_keys, extra_keys) + + finally: + # Clean up temporary files + manager.cleanup() + +if __name__ == "__main__": + main() diff --git a/configs/quickshell/translations/uk_UA.json b/configs/quickshell/translations/uk_UA.json new file mode 100644 index 0000000..830a257 --- /dev/null +++ b/configs/quickshell/translations/uk_UA.json @@ -0,0 +1,342 @@ +{ + "Mo": "ะŸะฝ/*keep*/", + "Tu": "ะ’ั‚/*keep*/", + "We": "ะกั€/*keep*/", + "Th": "ะงั‚/*keep*/", + "Fr": "ะŸั‚/*keep*/", + "Sa": "ะกะฑ/*keep*/", + "Su": "ะะด/*keep*/", + "%1 characters": "%1 ัะธะผะฒะพะปั–ะฒ", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**ะ’ะฐั€ั‚ั–ัั‚ัŒ**: ะฑะตะทะบะพัˆั‚ะพะฒะฝะพ. ะŸะพะปั–ั‚ะธะบะฐ ะฒะธะบะพั€ะธัั‚ะฐะฝะฝั ะดะฐะฝะธั… ะทะฐะปะตะถะธั‚ัŒ ะฒั–ะด ะฟะฐั€ะฐะผะตั‚ั€ั–ะฒ ะพะฑะปั–ะบะพะฒะพะณะพ ะทะฐะฟะธััƒ OpenRouter.\n\n**ะ†ะฝัั‚ั€ัƒะบั†ั–ั—**: ะฃะฒั–ะนะดั–ั‚ัŒ ะฒ ะพะฑะปั–ะบะพะฒะธะน ะทะฐะฟะธั OpenRouter, ะฟะตั€ะตะนะดั–ั‚ัŒ ะดะพ ั€ะพะทะดั–ะปัƒ ยซKeysยป ัƒ ะฒะตั€ั…ะฝัŒะพะผัƒ ะฟั€ะฐะฒะพะผัƒ ะผะตะฝัŽ, ะฝะฐั‚ะธัะฝั–ั‚ัŒ ยซCreate API Keyยป", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**ะ’ะฐั€ั‚ั–ัั‚ัŒ**: ะฑะตะทะบะพัˆั‚ะพะฒะฝะพ. ะ”ะฐะฝั– ะฒะธะบะพั€ะธัั‚ะพะฒัƒัŽั‚ัŒัั ะดะปั ั‚ั€ะตะฝัƒะฒะฐะฝะฝั.\n\n**ะ†ะฝัั‚ั€ัƒะบั†ั–ั—**: ะฃะฒั–ะนะดั–ั‚ัŒ ะฒ ะพะฑะปั–ะบะพะฒะธะน ะทะฐะฟะธั Google, ะดะพะทะฒะพะปัŒั‚ะต AI Studio ัั‚ะฒะพั€ะธั‚ะธ ะฟั€ะพะตะบั‚ Google Cloud ะฐะฑะพ ั‚ะต, ั‰ะพ ะฒะพะฝะฐ ะฟะพะฟั€ะพัะธั‚ัŒ, ะฟะพะฒะตั€ะฝั–ั‚ัŒัั ะฝะฐะทะฐะด ั– ะฝะฐั‚ะธัะฝั–ั‚ัŒ Get API key", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". ะŸั€ะธะผั–ั‚ะบะธ ะดะปั Zerochan:\n- ะ’ะธ ะฟะพะฒะธะฝะฝั– ะฒะฒะตัั‚ะธ ะบะพะปั–ั€\n- ะ’ัั‚ะฐะฝะพะฒั–ั‚ัŒ ะฒะฐัˆะต ั–ะผ'ั ะบะพั€ะธัั‚ัƒะฒะฐั‡ะฐ zerochan ัƒ ะฟะฐั€ะฐะผะตั‚ั€ั– ะบะพะฝั„ั–ะณัƒั€ะฐั†ั–ั— `sidebar.booru.zerochan.username`. ะฏะบั‰ะพ ะฒะธ ั†ัŒะพะณะพ ะฝะต ะทั€ะพะฑะธั‚ะต, ะฒะฐั [ะผะพะถะต ะฑัƒั‚ะธ ะทะฐะฑะปะพะบะพะฒะฐะฝะพ](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "ะŸะพะดะฐะปัŒัˆะธั… ั–ะฝัั‚ั€ัƒะบั†ั–ะน ะฝะต ะฝะฐะดะฐะฝะพ", + "Action": "ะ”ั–ั", + "Add": "ะ”ะพะดะฐั‚ะธ", + "Add task": "ะ”ะพะดะฐั‚ะธ ะทะฐะฒะดะฐะฝะฝั", + "All-rounder | Good quality, decent quantity": "ะฃะฝั–ะฒะตั€ัะฐะปัŒะฝะธะน | ะฅะพั€ะพัˆะฐ ัะบั–ัั‚ัŒ, ะฟั€ะธัั‚ะพะนะฝะฐ ะบั–ะปัŒะบั–ัั‚ัŒ", + "Allow NSFW": "ะ”ะพะทะฒะพะปะธั‚ะธ NSFW", + "Allow NSFW content": "ะ”ะพะทะฒะพะปะธั‚ะธ NSFW ะฒะผั–ัั‚", + "Anime": "ะะฝั–ะผะต", + "Anime boorus": "ะะฝั–ะผะต ะฑะพะพั€ัƒ", + "App": "ะŸั€ะพะณั€ะฐะผะฐ", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "ะกั‚ั€ั–ะปะบะธ ะดะปั ะฝะฐะฒั–ะณะฐั†ั–ั—, Enter ะดะปั ะฒะธะฑะพั€ัƒ\nะะฐั‚ะธัะฝั–ั‚ัŒ Esc ะฐะฑะพ ะฑัƒะดัŒ ะดะต ั‰ะพะฑ ัะบะฐััƒะฒะฐั‚ะธ", + "Bluetooth": "Bluetooth", + "Brightness": "ะฏัะบั€ะฐะฒั–ัั‚ัŒ", + "Cancel": "ะกะบะฐััƒะฒะฐั‚ะธ", + "Cheat sheet": "ะจะฟะฐั€ะณะฐะปะบะฐ", + "Choose model": "ะ’ะธะฑะตั€ั–ั‚ัŒ ะผะพะดะตะปัŒ", + "Clean stuff | Excellent quality, no NSFW": "ะงะธัั‚ะธะน ะฒะผั–ัั‚ | ะ’ั–ะดะผั–ะฝะฝะฐ ัะบั–ัั‚ัŒ, ะฑะตะท NSFW", + "Clear": "ะžั‡ะธัั‚ะธั‚ะธ", + "Clear chat history": "ะžั‡ะธัั‚ะธั‚ะธ ั–ัั‚ะพั€ั–ัŽ ั‡ะฐั‚ัƒ", + "Clear the current list of images": "ะžั‡ะธัั‚ะธั‚ะธ ะฟะพั‚ะพั‡ะฝะธะน ัะฟะธัะพะบ ะบะฐั€ั‚ะธะฝะพะบ", + "Close": "ะ—ะฐะบั€ะธั‚ะธ", + "Copy": "ะšะพะฟั–ัŽะฒะฐั‚ะธ", + "Copy code": "ะšะพะฟั–ัŽะฒะฐั‚ะธ ะบะพะด", + "Delete": "ะ’ะธะดะฐะปะธั‚ะธ", + "Desktop": "ะกั‚ั–ะปัŒะฝะธั†ั", + "Disable NSFW content": "ะ’ะธะผะบะฝัƒั‚ะธ NSFW ะฒะผั–ัั‚", + "Done": "ะ“ะพั‚ะพะฒะพ", + "Download": "ะ—ะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ", + "Edit": "ะ ะตะดะฐะณัƒะฒะฐั‚ะธ", + "Enter text to translate...": "ะ’ะฒะตะดั–ั‚ัŒ ั‚ะตะบัั‚ ั‰ะพะฑ ะฟะตั€ะตะบะปะฐัั‚ะธ...", + "Finished tasks will go here": "ะ—ะฐะฒะตั€ัˆะตะฝั– ะทะฐะฒะดะฐะฝะฝั ะทะฑะตั€ั–ะณะฐัŽั‚ัŒัั ั‚ัƒั‚", + "For desktop wallpapers | Good quality": "ะะฐ ัˆะฟะฐะปะตั€ะธ | ะฅะพั€ะพัˆะฐ ัะบั–ัั‚ัŒ", + "For storing API keys and other sensitive information": "ะ”ะปั ะทะฑะตั€ั–ะณะฐะฝะฝั API ะบะปัŽั‡ั–ะฒ ั‚ะฐ ั–ะฝัˆะพั— ะบะพะฝั„ั–ะดะตะฝั†ั–ะนะฝะพั— ั–ะฝั„ะพั€ะผะฐั†ั–ั", + "Game mode": "ะ†ะณั€ะพะฒะธะน ั€ะตะถะธะผ", + "Get the next page of results": "ะะฐัั‚ัƒะฟะฝะฐ ัั‚ะพั€ั–ะฝะบะฐ ั€ะตะทัƒะปัŒั‚ะฐั‚ั–ะฒ", + "Hibernate": "ะกะฟะปัั‡ะธะน ั€ะตะถะธะผ", + "Input": "ะ’ะฒั–ะด", + "Intelligence": "ะ†ะฝั‚ะตะปะตะบั‚", + "Interface": "ะ†ะฝั‚ะตั€ั„ะตะนั", + "Invalid arguments. Must provide `key` and `value`.": "ะะตะฟั€ะฐะฒะธะปัŒะฝั– ะฐั€ะณัƒะผะตะฝั‚ะธ. ะŸะพะฒะธะฝะฝั– ะฑัƒั‚ะธ ะฒะบะฐะทะฐะฝั– `key` ั‚ะฐ `value`.", + "Jump to current month": "ะŸะตั€ะตะนั‚ะธ ะดะพ ะฟะพั‚ะพั‡ะฝะพะณะพ ะผั–ััั†ั", + "Keep system awake": "ะขั€ะธะผะฐั‚ะธ ัะธัั‚ะตะผัƒ ะฒ ั€ะตะถะธะผั– ะพั‡ั–ะบัƒะฒะฐะฝะฝั", + "Large images | God tier quality, no NSFW.": "ะ’ะตะปะธะบั– ะทะพะฑั€ะฐะถะตะฝะฝั | ะ‘ะพะถะตัั‚ะฒะตะฝะฝะฐ ัะบั–ัั‚ัŒ, ะฑะตะท NSFW.", + "Large language models": "ะ’ะตะปะธะบั– ะผะพะฒะฝั– ะผะพะดะตะปั–", + "Launch": "ะŸัƒัะบ", + "Lock": "ะ‘ะปะพะบัƒะฒะฐั‚ะธ", + "Logout": "ะ’ะธะนั‚ะธ", + "Markdown test": "ะขะตัั‚ Markdown", + "Math result": "ะ ะตะทัƒะปัŒั‚ะฐั‚ ะพะฑั‡ะธัะปะตะฝัŒ", + "No audio source": "ะะตะผะฐั” ะดะถะตั€ะตะปะฐ ะฐัƒะดั–ะพ", + "No media": "ะะตะผะฐั” ะผะตะดั–ะฐ", + "No notifications": "ะะตะผะฐั” ัะฟะพะฒั–ั‰ะตะฝัŒ", + "Not visible to model": "ะะต ะฒะธะดะฝะพ ะดะปั ะผะพะดะตะปั–", + "Nothing here!": "ะขัƒั‚ ะฝั–ั‡ะพะณะพ!", + "Notifications": "ะกะฟะพะฒั–ั‰ะตะฝะฝั", + "OK": "ะ“ะฐั€ะฐะทะด", + "Open file link": "ะ’ั–ะดะบั€ะธั‚ะธ ะปั–ะฝะบ ะดะพ ั„ะฐะนะปัƒ", + "Output": "ะ’ะธะฒั–ะด", + "Reboot": "ะŸะตั€ะตะทะฐะฟัƒัะบ", + "Reboot to firmware settings": "ะŸะตั€ะตะทะฐะฟัƒัะบ ะฒ ะฟะฐั€ะฐะผะตั‚ั€ะธ UEFI/BIOS", + "Reload Hyprland & Quickshell": "ะŸะตั€ะตะทะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ Hyprland ั‚ะฐ Quickshell", + "Run": "ะ’ะธะบะพะฝะฐั‚ะธ", + "Run command": "ะ’ะธะบะพะฝะฐั‚ะธ ะบะพะผะฐะฝะดัƒ", + "Save": "ะ—ะฑะตั€ะตะณั‚ะธ", + "Save to Downloads": "ะ—ะฑะตั€ะตะณั‚ะธ ะฒ ะ—ะฐะฒะฐะฝั‚ะฐะถะตะฝะฝั", + "Search": "ะŸะพัˆัƒะบ", + "Search the web": "ะจัƒะบะฐั‚ะธ ะฒ ั–ะฝั‚ะตั€ะฝะตั‚ั–", + "Search, calculate or run": "ะจัƒะบะฐั‚ะธ, ะพะฑั‡ะธัะปัŽะฒะฐั‚ะธ ะฐะฑะพ ะทะฐะฟัƒัะบะฐั‚ะธ", + "Select Language": "ะ’ะธะฑั€ะฐั‚ะธ ะผะพะฒัƒ", + "Session": "ะกะตัั–ั", + "Set API key": "ะ’ะบะฐะทะฐั‚ะธ ะบะปัŽั‡ API", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "ะ—ะฐะดะฐั‚ะธ ั‚ะตะผะฟะตั€ะฐั‚ัƒั€ัƒ (ะฒะธะฟะฐะดะบะพะฒั–ัั‚ัŒ) ะผะพะดะตะปั–. ะ—ะฝะฐั‡ะตะฝะฝั ะฒ ะดั–ะฐะฟะฐะทะพะฝั– ะฒั–ะด 0 ะดะพ 2 ะดะปั Gemini, ะฒั–ะด 0 ะดะพ 1 ะดะปั ั–ะฝัˆะธั… ะผะพะดะตะปะตะน. ะ—ะฝะฐั‡ะตะฝะฝั ะทะฐ ะทะฐะผะพะฒั‡ัƒะฒะฐะฝะฝัะผ 0.5.", + "Set the current API provider": "ะ’ัั‚ะฐะฝะพะฒะธั‚ะธ ะฟะพั‚ะพั‡ะฝะพะณะพ ะฟั€ะพะฒะฐะนะดะตั€ะฐ API", + "Shutdown": "ะ’ะธะผะบะฝัƒั‚ะธ", + "Silent": "ะขะธัˆะฐ", + "Sleep": "ะกะพะฝ", + "System": "ะกะธัั‚ะตะผะฐ", + "Task Manager": "ะœะตะฝะตะดะถะตั€ ะทะฐะฒะดะฐะฝัŒ", + "Task description": "ะžะฟะธั ะทะฐะฒะดะฐะฝะฝั", + "Temperature must be between 0 and 2": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ ะผะฐั” ะฑัƒั‚ะธ ะผั–ะถ 0 ั‚ะฐ 2", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "ะฅะตะฝั‚ะฐะน | ะ’ะตะปะธะบะฐ ะบั–ะปัŒะบั–ัั‚ัŒ, ะฑะฐะณะฐั‚ะพ NSFW, ัะบั–ัั‚ัŒ ัะธะปัŒะฝะพ ะฒะฐั€ั–ัŽั”ั‚ัŒัั", + "The popular one | Best quantity, but quality can vary wildly": "ะŸะพะฟัƒะปัั€ะฝะธะน | ะะฐะนะบั€ะฐั‰ะฐ ะบั–ะปัŒะบั–ัั‚ัŒ, ะฐะปะต ัะบั–ัั‚ัŒ ะผะพะถะต ัะธะปัŒะฝะพ ะฒะฐั€ั–ัŽะฒะฐั‚ะธััŒ", + "Thinking": "ะ—ะฐะผะธัะปะธะฒัั", + "Translation goes here...": "ะŸะตั€ะตะบะปะฐะด ะฑัƒะดะต ั‚ัƒั‚...", + "Translator": "ะŸะตั€ะตะบะปะฐะดะฐั‡", + "Unfinished": "ะะตะทะฐะฒะตั€ัˆะตะฝะธะน", + "Unknown": "ะะตะฒั–ะดะผะธะน", + "Unknown Album": "ะะตะฒั–ะดะพะผะธะน ะะปัŒะฑะพะธ", + "Unknown Artist": "ะะตะฒั–ะดะพะผะธะน ะ’ะธะบะพะฝะฐะฒะตั†ัŒ", + "Unknown Title": "ะะตะฒั–ะดะพะผะฐ ะฝะฐะทะฒะฐ", + "View Markdown source": "ะ”ะธะฒะธั‚ะธัั ะดะถะตั€ะตะปะพ Markdown", + "Volume": "ะ“ัƒั‡ะฝั–ัั‚ัŒ", + "Volume mixer": "ะœั–ะบัˆะตั€ ะณัƒั‡ะฝะพัั‚ั–", + "Waifus only | Excellent quality, limited quantity": "ะ›ะธัˆะต ะฒะฐะนั„ัƒ | ะ’ั–ะดะผั–ะฝะฝะฐ ัะบั–ัั‚ัŒ, ะพะฑะผะตะถะตะฝะฐ ะบั–ะปัŒะบั–ัั‚ัŒ", + "Waiting for response...": "ะงะตะบะฐั”ะผะพ ะฒั–ะดะฟะพะฒั–ะดัŒ...", + "Workspace": "ะŸั€ะพัั‚ั–ั€", + "Invalid API provider. Supported: \n-": "ะะตะฟั€ะฐะฒะธะปัŒะฝะธะน ะฟั€ะพะฒะฐะนะดะตั€ API. ะŸั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั: \n-", + "Unknown command:": "ะะตะฒั–ะดะพะผะฐ ะบะพะผะฐะฝะดะฐ:", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "ะ’ะฒะตะดั–ั‚ัŒ /key, ั‰ะพะฑ ะฟะพั‡ะฐั‚ะธ ั€ะพะฑะพั‚ัƒ ะท ะพะฝะปะฐะนะฝ ะผะพะดะตะปัะผะธ\nCtrl+O, ั‰ะพะฑ ั€ะพะทะณะพั€ะฝัƒั‚ะธ ะฑั–ั‡ะฝัƒ ะฟะฐะฝะตะปัŒ\nCtrl+P, ั‰ะพะฑ ะฟั€ะธะฑั€ะฐั‚ะธ ะฑั–ั‡ะฝัƒ ะฟะฐะฝะตะปัŒ ัƒ ะฒั–ะบะฝะพ", + "Provider set to": "ะŸั€ะพะฒะฐะนะดะตั€ ะฒะธัั‚ะฐะฒะปะตะฝะธะน ะฝะฐ", + "Invalid model. Supported: \n```": "ะะตะฟั€ะฐะฒะตะปัŒะฝะฐ ะผะพะดะตะปัŒ. ะŸั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั: \n```", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "ะฆะต ะฝะต ัะฟั€ะฐั†ัŽะฒะฐะปะพ. ะŸะพั€ะฐะดะธ:\n- ะŸะตั€ะตะฒั–ั€ั‚ะต ัะฒะพั— ั‚ะตะณะธ ั‚ะฐ ะฟะฐั€ะฐะผะตั‚ั€ะธ NSFW\n- ะฏะบั‰ะพ ะฒะธ ะฝะต ะทะฝะฐั”ั‚ะต ั‚ะตะณะฐ, ะฒะฒะตะดั–ั‚ัŒ ะฝะพะผะตั€ ัั‚ะพั€ั–ะฝะบะธ", + "Switched to search mode. Continue with the user's request.": "ะŸะตั€ะตะนัˆะปะธ ะฒ ั€ะตะถะธะผ ะฟะพัˆัƒะบัƒ. ะŸั€ะพะดะพะฒะถะตะฝะฝั ะฟะพัˆัƒะบัƒ ะทะฐ ะทะฐะฟะธั‚ะพะผ ะบะพั€ะธัั‚ัƒะฒะฐั‡ะฐ.", + "Settings": "ะŸะฐั€ะฐะผะตั‚ั€ะธ", + "Save chat": "ะ—ะฑะตั€ะตะณั‚ะธ ั‡ะฐั‚", + "Load chat": "ะ—ะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ ั‡ะฐั‚", + "or": "ะฐะฑะพ", + "Set the system prompt for the model.": "ะ’ัั‚ะฐะฝะพะฒั–ั‚ัŒ ัะธัั‚ะตะผะฝะธะน ะทะฐะฟะธั‚ ะดะปั ะผะพะดะตะปั–.", + "To Do": "ะ—ั€ะพะฑะธั‚ะธ", + "Calendar": "ะšะฐะปะตะฝะดะฐั€", + "Advanced": "ะ ะพะทัˆะธั€ะตะฝั–", + "About": "ะŸั€ะพ", + "Services": "ะกะตั€ะฒั–ัะธ", + "Style": "ะกั‚ะธะปัŒ", + "Edit config": "ะ ะตะดะฐะณัƒะฒะฐั‚ะธ ะบะพะฝั„ั–ะณัƒั€ะฐั†ั–ัŽ", + "Colors & Wallpaper": "ะšะพะปัŒะพั€ะธ ั‚ะฐ ะจะฟะฐะปะตั€ะธ", + "Light": "ะกะฒั–ั‚ะปะฐ", + "Dark": "ะขะตะผะฝะฐ", + "Material palette": "ะŸะฐะปั–ั‚ั€ะฐ ะบะพะปัŒะพั€ั–ะฒ", + "Fidelity": "ะ’ั–ั€ะฝั–ัั‚ัŒ", + "Fruit Salad": "ะคั€ัƒะบั‚ะพะฒะธะน ัะฐะปะฐั‚", + "Alternatively use /dark, /light, /img in the launcher": "ะะฑะพ ะฒะธะบะพั€ะธัั‚ะพะฒัƒะนั‚ะต /dark, /light, /img ัƒ ะปะฐัƒะฝั‡ะตั€ั–", + "Fake screen rounding": "ะคะฐะปัŒัˆะธะฒั– ะทะฐะพะบั€ัƒะณะปะตะฝะฝั ะตะบั€ะฐะฝัƒ", + "When not fullscreen": "ะšะพะปะธ ะฝะต ะฝะฐ ะฒะตััŒ ะตะบั€ะฐะฝ", + "Choose file": "ะ’ะธะฑั€ะฐั‚ะธ ั„ะฐะนะป", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "ะ’ะธะฟะฐะดะบะพะฒั– SFW ะฐะฝั–ะผะต ัˆะฟะฐะปะตั€ะธ ะฒั–ะด Konachan\nะ—ะพะฑั€ะฐะถะตะฝะฝั ะทะฑะตั€ะตะถะตะฝะพ ะดะพ ~/Pictures/Wallpapers", + "Be patient...": "ะŸะพั‚ะตั€ะฟั–ั‚ัŒ...", + "Decorations & Effects": "ะ”ะตะบะพั€ะฐั†ั–ั— ั‚ะฐ ะตั„ะตะบั‚ะธ", + "Tonal Spot": "ะขะพะฝะฐะปัŒะฝะฐ ะฟะปัะผะฐ", + "Shell windows": "ะ’ั–ะบะฝะฐ ะพะฑะพะปะพะฝะบะธ", + "Auto": "ะะฒั‚ะพ", + "Wallpaper": "ะจะฟะฐะปะตั€ะธ", + "Content": "ะ’ะผั–ัั‚", + "Title bar": "ะ—ะฐะณะพะปะพะฒะพะบ", + "Transparency": "ะŸั€ะพะทะพั€ั–ัั‚ัŒ", + "Expressive": "ะ’ะธั€ะฐะทะฝะธะน", + "Yes": "ะขะฐะบ", + "Enable": "ะฃะฒั–ะผะบะฝัƒั‚ะธ", + "Rainbow": "ะ’ะตัะตะปะบะฐ", + "Might look ass. Unsupported.": "ะ’ะธะณะปัะดะฐั‚ะธะผะต ะดัƒั€ะฝะพะฒะฐั‚ะพ. ะะต ะฟั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั.", + "Monochrome": "ะœะพะฝะพั…ั€ะพะผะฝะธะน", + "Random: Konachan": "ะ’ะธะฟะฐะดะบะพะฒะพ: Konachan", + "Center title": "ะะฐะทะฒะฐ ะฟะพ ั†ะตะฝั‚ั€ัƒ", + "Neutral": "ะะตะนั‚ั€ะฐะปัŒะฝะธะน", + "Pick wallpaper image on your system": "ะ’ะธะฑะตั€ั–ั‚ัŒ ะทะพะฑั€ะฐะถะตะฝะฝั ัˆะฟะฐะปะตั€ ะฝะฐ ะฒะฐัˆะพะผัƒ ะบะพะผะฟ'ัŽั‚ะตั€ั–", + "No": "ะั–", + "AI": "ะจะ†", + "Local only": "ะ›ะธัˆะต ะปะพะบะฐะปัŒะฝะพ", + "Policies": "ะŸะพะปั–ั‚ะธะบะฐ", + "Weeb": "ะ’ั–ะฐะฑัƒ", + "Closet": "ะจะฐั„ะฐ", + "Bar style": "ะกั‚ะธะปัŒ ะทะฐะณะพะปะพะฒะบัƒ", + "Show next time": "ะŸะพะบะฐะทะฐั‚ะธ ะฟั–ะทะฝั–ัˆะต", + "Usage": "ะ’ะธะบะพั€ะธัั‚ะฐะฝะฝั", + "Plain rectangle": "ะ—ะฒะธั‡ะฐะนะฝะธะน ะบะฒะฐะดั€ะฐั‚", + "Useless buttons": "ะ‘ะตะทะบะพั€ะธัะฝั– ะบะฝะพะฟะบะธ", + "GitHub": "GitHub", + "Style & wallpaper": "ะกั‚ะธะปัŒ ั‚ะฐ ัˆะฟะฐะปะตั€ะธ", + "Configuration": "ะšะพะฝั„ั–ะณัƒั€ะฐั†ั–ั", + "Change any time later with /dark, /light, /img in the launcher": "ะ—ะผั–ะฝั–ั‚ัŒ ะฑัƒะดัŒ-ะบะพะปะธ ะฟั–ะทะฝั–ัˆะต ะทะฐ ะดะพะฟะพะผะพะณะพัŽ /dark, /light, /img ัƒ ะปะฐัƒะฝั‡ะตั€ั–", + "Keybinds": "ะšะพะผะฑั–ะฝะฐั†ั–ั— ะบะปะฐะฒั–ัˆ", + "Float": "ะŸะปะฐะฒะฐัŽั‡ะต", + "Hug": "ะžะฑั–ะนะผะธ", + "Yooooo hi there": "ะ™ะพะพะพะพ, ะฟั€ะธะฒั–ั‚", + "illogical-impulse Welcome": "illogical-impulse ะ’ั–ั‚ะฐั”ะผะพ", + "Info": "ะ†ะฝั„ะพ", + "Volume limit": "ะžะฑะผะตะถะตะฝะฝั ะณัƒั‡ะฝะพัั‚ั–", + "Prevents abrupt increments and restricts volume limit": "ะ—ะฐะฟะพะฑั–ะณะฐั” ั€ั–ะทะบะพะผัƒ ะทะฑั–ะปัŒัˆะตะฝะฝัŽ ั‚ะฐ ะพะฑะผะตะถัƒั” ะปั–ะผั–ั‚ ะณัƒั‡ะฝะพัั‚ั–", + "Resources": "ะ ะตััƒั€ัะธ", + "12h am/pm": "12ะณ AM/PM", + "Base URL": "ะ‘ะฐะทะพะฒะธะน ะปั–ะฝะบ", + "Audio": "ะัƒะดั–ะพ", + "Networking": "ะœะตั€ะตะถัƒะฒะฐะฝะฝั", + "Format": "ะคะพั€ะผะฐั‚", + "Time": "ะงะฐั", + "Battery": "ะ‘ะฐั‚ะฐั€ะตั", + "Prefixes": "ะŸั€ะตั„ั–ะบัะธ", + "Emojis": "ะ•ะผะพะดะทั–", + "Earbang protection": "ะ—ะฐั…ะธัั‚ ะฝะฐะฒัƒัˆะฝะธะบั–ะฒ", + "Automatically suspends the system when battery is low": "ะะฒั‚ะพะผะฐั‚ะธั‡ะฝะพ ะฟั€ะธะทัƒะฟะธะฝัั” ั€ะพะฑะพั‚ัƒ ัะธัั‚ะตะผะธ, ะบะพะปะธ ะฑะฐั‚ะฐั€ะตั ั€ะพะทั€ัะดะถะฐั”ั‚ัŒัั", + "Automatic suspend": "ะะฒั‚ะพะผะฐั‚ะธั‡ะฝะฐ ะทัƒะฟะธะฝะบะฐ", + "Suspend at": "ะ—ัƒะฟะธะฝัั‚ะธ ะฝะฐ", + "Max allowed increase": "ะœะฐะบัะธะผะฐะปัŒะฝะพ ะดะพะฟัƒัั‚ะธะผะธะน ะฟั€ะธั€ั–ัั‚", + "Web search": "ะŸะพัˆัƒะบ ะฒ ั–ะฝั‚ะตั€ะฝะตั‚ั–", + "Polling interval (ms)": "ะ†ะฝั‚ะตั€ะฒะฐะป ะผั–ะถ ะพะฟะธั‚ัƒะฒะฐะฝะฝัะผะธ (ะผั)", + "Clipboard": "ะ‘ัƒั„ะตั€ ะพะฑะผั–ะฝัƒ", + "Low warning": "ะะตะทะฝะฐั‡ะฝั– ะฟะพะฟะตั€ะตะดะถะตะฝะฝั", + "24h": "24ะณ", + "Use Levenshtein distance-based algorithm instead of fuzzy": "ะ’ะธะบะพั€ะธัั‚ะพะฒัƒะนั‚ะต ะฐะปะณะพั€ะธั‚ะผ ะฝะฐ ะพัะฝะพะฒั– ะฒั–ะดัั‚ะฐะฝั– ะ›ะตะฒะตะฝัˆั‚ะตะนะฝะฐ ะทะฐะผั–ัั‚ัŒ ะฝะตั‡ั–ั‚ะบะพะณะพ", + "System prompt": "ะกะธัั‚ะตะผะฝะธะน ะทะฐะฟะธั‚", + "12h AM/PM": "12ะณ AM/PM", + "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)": "ะ‘ัƒะปะพ ะฑ ะบั€ะฐั‰ะต, ัะบะฑะธ ะฒะธ ะทั€ะพะฑะธะปะธ ะบัƒะฟัƒ ะฟะพะผะธะปะพะบ,\nะฐะปะต ั€ะตะทัƒะปัŒั‚ะฐั‚ะธ ะผะพะถัƒั‚ัŒ ะฑัƒั‚ะธ ะดะธะฒะฝะธะผะธ ั– ะฝะต ะฟั€ะฐั†ัŽะฒะฐั‚ะธ ะท ะฐะฑั€ะตะฒั–ะฐั‚ัƒั€ะฐะผะธ\n(ะฝะฐะฟั€ะธะบะปะฐะด, ยซGIMPยป ะผะพะถะต ะฝะต ะฒะธะฒะตัั‚ะธ ะฟั€ะพะณั€ะฐะผัƒ ะดะปั ะผะฐะปัŽะฒะฐะฝะฝั).", + "Critical warning": "ะšั€ะธั‚ะธั‡ะฝั– ะฟะพะฟะตั€ะตะดะถะตะฝะฝั", + "User agent (for services that require it)": "User agent (ะดะปั ัะตั€ะฒั–ัั–ะฒ ะดะต ั†ะต ะฟะพั‚ั€ั–ะฑะฝะพ)", + "Such regions 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.": "ะขะฐะบะธะผะธ ะพะฑะปะฐัั‚ัะผะธ ะผะพะถัƒั‚ัŒ ะฑัƒั‚ะธ ะทะพะฑั€ะฐะถะตะฝะฝั ะฐะฑะพ ั‡ะฐัั‚ะธะฝะธ ะตะบั€ะฐะฝะฐ, ัะบั– ะผะฐัŽั‚ัŒ ะฟะตะฒะฝั– ะพะฑะผะตะถะตะฝะฝั.\nะœะพะถะต ะฑัƒั‚ะธ ะฝะต ะทะฐะฒะถะดะธ ั‚ะพั‡ะฝะธะผ.\nะฆะต ั€ะพะฑะธั‚ัŒัั ะทะฐ ะดะพะฟะพะผะพะณะพัŽ ะฐะปะณะพั€ะธั‚ะผัƒ ะพะฑั€ะพะฑะบะธ ะทะพะฑั€ะฐะถะตะฝัŒ, ะทะฐะฟัƒั‰ะตะฝะพะณะพ ะปะพะบะฐะปัŒะฝะพ, ั– ะฝะต ะฒะธะบะพั€ะธัั‚ะพะฒัƒั”ั‚ัŒัั ะจะ†.", + "Note: turning off can hurt readability": "ะŸั€ะธะผั–ั‚ะบะฐ: ะฒะธะผะบะฝะตะฝะฝั ะผะพะถะต ะฟะพะณั–ั€ัˆะธั‚ะธ ั‡ะธั‚ะฐะฑะตะปัŒะฝั–ัั‚ัŒ", + "Workspaces shown": "ะŸะพะบะฐะทะฐะฝั– ะฟั€ะพัั‚ะพั€ะธ", + "Dark/Light toggle": "ะŸะตั€ะตะผะธะบะฐั‡ ะกะฒั–ั‚ะปะพั—/ะขะตะผะฝะพั—", + "Dock": "ะ›ะพั‚ะพะบ", + "Weather": "ะŸะพะณะพะดะฐ", + "Pinned on startup": "ะŸั€ะธะบั€ั–ะฟะปะตะฝะพ ะดะพ ะทะฐะฟัƒัะบัƒ", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "ะŸะพั€ะฐะดะฐ: ะฟั€ะธั…ะพะฒัƒะนั‚ะต ั–ะบะพะฝะบะธ ั– ะทะฐะฒะถะดะธ ะฟะพะบะฐะทัƒะนั‚ะต ั†ะธั„ั€ะธ ะดะปั ะบะปะฐัะธั‡ะฝะพะณะพ ะดะพัะฒั–ะดัƒ illogical-impulse", + "Appearance": "ะ’ะธะณะปัะด", + "Always show numbers": "ะ—ะฐะฒะถะดะธ ะฟะพะบะฐะทัƒะฒะฐั‚ะธ ะฝะพะผะตั€ะธ", + "Buttons": "ะšะฝะพะฟะบะธ", + "Keyboard toggle": "ะŸะตั€ะตะผะธะบะฐะฝะฝั ะบะปะฐะฒั–ะฐั‚ัƒั€ะธ", + "Scale (%)": "ะ ะพะทะผั–ั€ (%)", + "Overview": "ะžะณะปัะด", + "Rows": "ะ ัะดะบะธ", + "Borderless": "ะ‘ะตะท ะผะตะถ", + "Screenshot tool": "ะ†ะฝัั‚ั€ัƒะผะตะฝั‚ ัั‚ะฒะพั€ะตะฝะฝั ัะบั€ั–ะฝัˆะพั‚ั–ะฒ", + "Number show delay when pressing Super (ms)": "ะ—ะฐั‚ั€ะธะผะบะฐ ะฒั–ะดะพะฑั€ะฐะถะตะฝะฝั ะฝะพะผะตั€ะฐ ะฟั€ะธ ะฝะฐั‚ะธัะบะฐะฝะฝั– ะบะปะฐะฒั–ัˆั– Super (ะผั)", + "Timeout (ms)": "ะขะฐะนะผ-ะฐัƒั‚ (ms)", + "Show app icons": "ะŸะพะบะฐะทัƒะฒะฐั‚ะธ ั–ะบะพะฝะบะธ ะฟั€ะพะณั€ะฐะผ", + "Workspaces": "ะŸั€ะพัั‚ะพั€ะธ", + "Columns": "ะกั‚ะพะฒะฑั†ั–", + "On-screen display": "ะ•ะบั€ะฐะฝะฝะธะน ะดะธัะฟะปะตะน", + "Screen snip": "ะกะบั€ั–ะฝัˆะพั‚", + "Mic toggle": "ะŸะตั€ะตะผะธะบะฐั‡ ะผั–ะบั€ะพั„ะพะฝัƒ", + "Hover to reveal": "ะะฐะฒะตะดั–ั‚ัŒ ะบัƒั€ัะพั€, ั‰ะพะฑ ะฒั–ะดะบั€ะธั‚ะธ", + "Bar": "ะŸะฐะฝะตะปัŒ", + "Show background": "ะŸะพะบะฐะทัƒะฒะฐั‚ะธ ะทะฐะดะฝั–ะน ั„ะพะฝ", + "Show regions of potential interest": "ะŸะพะบะฐะทะฐั‚ะธ ั€ะตะณั–ะพะฝะธ ะฟะพั‚ะตะฝั†ั–ะนะฝะพะณะพ ั–ะฝั‚ะตั€ะตััƒ", + "Color picker": "ะ’ะธะฑั–ั€ ะบะพะปัŒะพั€ัƒ", + "Help & Support": "ะ”ะพะฟะพะผะพะณะฐ ั‚ะฐ ะฟั–ะดั‚ั€ะธะผะบะฐ", + "Discussions": "ะ”ะธัะบัƒัั–ั—", + "Color generation": "ะ“ะตะฝะตั€ะฐั‚ะพั€ ะบะพะปัŒะพั€ั–ะฒ", + "Dotfiles": "ะ”ะพั‚ั„ะฐะนะปะธ", + "Distro": "ะ”ะธัั‚ั€ะธะฑัŽั‚ะธะฒ", + "Privacy Policy": "ะŸะพะปั–ั‚ะธะบะฐ ะšะพะฝั„ั–ะดะตะฝั†ั–ะนะฝะพัั‚ั–", + "Documentation": "ะ”ะพะบัƒะผะตะฝั‚ะฐั†ั–ั", + "Shell & utilities theming must also be enabled": "ะขะตะผัƒ ะพะฑะพะปะพะฝะบะธ ั‚ะฐ ัƒั‚ะธะปั–ั‚ ั‚ะฐะบะพะถ ัะปั–ะด ัƒะฒั–ะผะบะฝัƒั‚ะธ", + "illogical-impulse": "illogical-impulse", + "Donate": "ะ”ะพะฝะฐั‚", + "Terminal": "ะขะตั€ะผั–ะฝะฐะป", + "Shell & utilities": "ะžะฑะพะปะพะฝะบะฐ ั‚ะฐ ัƒั‚ะธะปั–ั‚ะธ", + "Qt apps": "ะŸั€ะพะณั€ะฐะผะธ Qt", + "Report a Bug": "ะŸะพะฒั–ะดะพะผะธั‚ะธ ะฟั€ะพ ะฟะพะผะธะปะบัƒ", + "Issues": "ะŸั€ะพะฑะปะตะผะธ", + "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit": "ะŸะตั€ะตั‚ัะณะฝั–ั‚ัŒ ะฐะฑะพ ะบะปะฐั†ะฝั–ั‚ัŒ ั€ะตะณั–ะพะฝ - LMB: ะšะพะฟั–ัŽะฒะฐั‚ะธ - RMB: ะ ะตะดะฐะณัƒะฒะฐั‚ะธ", + "Current model: %1\nSet it with %2model MODEL": "ะŸะพั‚ะพั‡ะฝะฐ ะผะพะดะตะปัŒ: %1\nะ—ะฐะดะฐั‚ะธ ั—ั— ะทะฐ ะดะพะฟะพะผะพะณะพัŽ %2model ะœะžะ”ะ•ะ›ะฌ", + "Message the model... \"%1\" for commands": "ะะฐะฟะธัะฐั‚ะธ ะผะพะดะตะปั–... ยซ%1ยป ะดะปั ะบะพะผะฐะฝะด", + "No API key set for %1": "ะะตะผะฐั” ะฒะบะฐะทะฐะฝะพะณะพ API ะบะปัŽั‡ะฐั‡ ะดะปั %1", + "Loaded the following system prompt\n\n---\n\n%1": "ะ—ะฐะฒะฐะฝั‚ะฐะถะธะฒัั ะฝะฐัั‚ัƒะฟะฝะธะน ัะธัั‚ะตะผะฝะธะน ะทะฐะฟะธั‚\n\n---\n\n%1", + "%1 | Right-click to configure": "%1 | ะŸะšะœ ะดะปั ะฝะฐะปะฐัˆั‚ัƒะฒะฐะฝะฝั", + "API key set for %1": "ะบะปัŽั‡ API ะฒะบะฐะทะฐะฝะพ ะดะปั %1", + "Online via %1 | %2's model": "ะžะฝะปะฐะนะฝ ั‡ะตั€ะตะท %1 | %2's ะผะพะดะตะปัŒ", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "ะŸะพั‚ะพั‡ะฝะฐ ะบั–ะฝั†ะตะฒะฐ ั‚ะพั‡ะบะฐ API: %1\nะ’ัั‚ะฐะฝะพะฒั–ั‚ัŒ ั—ั— ะทะฐ ะดะพะฟะพะผะพะณะพัŽ %2mode ะŸะ ะžะ’ะะ™ะขะ•ะ ", + "Go to source (%1)": "ะ”ะพ ะดะถะตั€ะตะปะฐ (%1)", + "Temperature set to %1": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ ะฒะธัั‚ะฐะฒะปะตะฝะฐ ะฝะฐ %1", + "Enter tags, or \"%1\" for commands": "ะะฐะฟะธัˆั–ั‚ัŒ ั‚ะตะณ ะฐะฑะพ \"%1\" ะดะปั ะบะพะผะฐะฝะด", + "%1 queries pending": "%1 ะทะฐะฟะธั‚ั–ะฒ ะฝะฐ ั€ะพะทะณะปัะดั–", + "API key:\n\n```txt\n%1\n```": "ะบะปัŽั‡ API:\n\n```txt\n%1\n```", + "Uptime: %1": "ะะบั‚ะธะฒะฝะพ: %1", + "%1 Safe Storage": "%1 ะะฐะดั–ะนะฝะต ะทะฑะตั€ั–ะณะฐะฝะฝั", + "%1 does not require an API key": "%1 ะฝะต ะฟะพั‚ั€ะตะฑัƒั” API ะบะปัŽั‡ะฐ", + "Temperature: %1": "ั‚ะตะผะฟะตั€ะฐั‚ัƒั€ะฐ: %1", + "Model set to %1": "ะœะพะดะตะปัŒ ะฒัั‚ะฐะฝะพะฒะปะตะฝะฐ ะฝะฐ %1", + "Page %1": "ะกั‚ะพั€ั–ะฝะบะฐ %1", + "Local Ollama model | %1": "ะ›ะพะบะฐะปัŒะฝะฐ ะผะพะดะตะปัŒ ะžะปะปะฐะผะฐ | %1", + "The current system prompt is\n\n---\n\n%1": "ะŸะพั‚ะพั‡ะฝะธะน ะทะฐะฟะธั‚ ัะธัั‚ะตะผะธ\n\n---\n\n%1", + "Unknown function call: %1": "ะะตะฒั–ะดะพะผะธะน ะฒะธะบะปะธะบ ั„ัƒะฝะบั†ั–ั—: %1", + "%1 notifications": "%1 ัะฟะพะฒั–ั‰ะตะฝัŒ", + "Load chat from %1": "ะ—ะฐะฒะฐะฝั‚ะฐะถะตะฝะพ ั‡ะฐั‚ ะท %1", + "Load prompt from %1": "ะ—ะฐะฒะฐะฝั‚ะฐะถะตะฝะพ ะทะฐะฟะธั‚ ะท %1", + "Save chat to %1": "ะ—ะฑะตั€ะตะณั‚ะธ ั‡ะฐั‚ ะดะพ %1", + "Weather Service": "ะกะตั€ะฒั–ั ะฟะพะณะพะดะธ", + "Cannot find a GPS service. Using the fallback method instead.": "ะะต ะฒะดะฐั”ั‚ัŒัั ะทะฝะฐะนั‚ะธ ัะปัƒะถะฑัƒ GPS. ะ—ะฐะผั–ัั‚ัŒ ั†ัŒะพะณะพ ะฒะธะบะพั€ะธัั‚ะพะฒัƒั”ั‚ัŒัั ั€ะตะทะตั€ะฒะฝะธะน ะผะตั‚ะพะด.", + "Critically low battery": "ะšั€ะธั‚ะธั‡ะฝะธะน ั€ั–ะฒะตะฝัŒ ะทะฐั€ัะดัƒ ะฑะฐั‚ะฐั€ะตั—", + "Select output device": "ะ’ะธะฑะตั€ั–ั‚ัŒ ะฒะธั…ั–ะดะฝะธะน ะฟั€ะธัั‚ั€ั–ะน", + "Code saved to file": "ะšะพะด ะทะฑะตั€ะตะถะตะฝะธะน ัƒ ั„ะฐะนะป", + "Online models disallowed\n\nControlled by `policies.ai` config option": "ะžะฝะปะฐะนะฝ-ะผะพะดะตะปั– ะทะฐะฑะพั€ะพะฝะตะฝะพ\n\nะšะตั€ัƒั”ั‚ัŒัั ะฟะฐั€ะฐะผะตั‚ั€ะพะผ ะบะพะฝั„ั–ะณัƒั€ะฐั†ั–ั— `policies.ai`", + "Scroll to change volume": "ะŸั€ะพะบั€ัƒั‚ั–ั‚ัŒ, ั‰ะพะฑ ะทะผั–ะฝะธั‚ะธ ะณัƒั‡ะฝั–ัั‚ัŒ", + "Elements": "ะ•ะปะตะผะตะฝั‚ะธ", + "%1 โ€ข %2 tasks": "%1 โ€ข %2 ะทะฐะฒะดะฐะฝัŒ", + "Download complete": "ะ—ะฐะฒะฐะฝั‚ะฐะถะตะฝะฝั ะทะฐะฒะตั€ัˆะตะฝะพ", + "Please charge!\nAutomatic suspend triggers at %1": "ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ะทะฐั€ัะดั–ั‚ัŒ! \nะะฒั‚ะพะผะฐั‚ะธั‡ะฝะต ะฟั€ะธะทัƒะฟะธะฝะตะฝะฝั ัะฟั€ะฐั†ัŒะพะฒัƒะฒะฐะฝะฝั ะฟั€ะธ %1", + "Cloudflare WARP": "Cloudflare WARP", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Scroll to change brightness": "ะŸั€ะพะบั€ัƒั‚ั–ั‚ัŒ ั‰ะพะฑ ะทะผั–ะฝะธั‚ะธ ััะบั€ะฐะฒั–ัั‚ัŒ", + "Connection failed. Please inspect manually with the warp-cli command": "ะŸะพะผะธะปะบะฐ ะทั”ะดะฝะฐะฝะฝั. ะŸะตั€ะตะฒั–ั€ั‚ะต ัะฐะผะพัั‚ั–ะนะฝะพ ะทะฐ ะดะพะฟะพะผะพะณะพัŽ ะบะพะผะฐะฝะดะธ warp-cli", + "Select input device": "ะ’ะธะฑะตั€ั–ั‚ัŒ ะฒั…ั–ะดะฝะธะน ะฟั€ะธัั‚ั€ั–ะน", + "Registration failed. Please inspect manually with the warp-cli command": "ะŸะพะผะธะปะบะฐ ั€ะตั”ัั‚ั€ะฐั†ั–ั—. ะŸะตั€ะตะฒั–ั€ั‚ะต ัะฐะผะพัั‚ั–ะนะฝะพ ะทะฐ ะดะพะฟะพะผะพะณะพัŽ ะบะพะผะฐะฝะดะธ warp-cli", + "Consider plugging in your device": "ะŸะพะดัƒะผะฐะนั‚ะต ะฟั€ะพ ั‚ะต, ั‰ะพะฑ ะฟั–ะดะบะปัŽั‡ะธั‚ะธ ัะฒั–ะน ะฟั€ะธัั‚ั€ั–ะน", + "Low battery": "ะะธะทัŒะบะธะน ะทะฐั€ัะด ะฑะฐั‚ะฐั€ะตั—", + "Saved to %1": "ะ—ะฑะตั€ะตะถะตะฝะพ ะดะพ %1", + "Sunset": "ะ—ะฐั…ั–ะด ัะพะฝั†ั", + "UV Index": "ะ†ะฝะดะตะบั UV", + "Humidity": "ะ’ะพะปะพะณั–ัั‚ัŒ", + "Wind": "ะ’ั–ั‚ะตั€", + "Sunrise": "ะกะฒั–ั‚ะฐะฝะพะบ", + "Pressure": "ะขะธัะบ", + "Visibility": "ะ’ะธะดะธะผั–ัั‚ัŒ", + "Precipitation": "ะžะฟะฐะดะธ", + "No API key\nSet it with /key YOUR_API_KEY": "ะะตะผะฐั” API ะบะปัŽั‡ะฐ\nะ’ัั‚ะฐะฝะพะฒั–ั‚ัŒ ะนะพะณะพ ะผะพะถะฝะฐ ะบะพะผะฐะฝะดะพัŽ /key YOUR_API_KEY", + "Your package manager is running": "ะ’ะฐัˆ ะฟะฐะบะตั‚ะฝะธะน ะผะตะฝะตะดะถะตั€ ะทะฐะฟัƒั‰ะตะฝะพ", + "Night Light | Right-click to toggle Auto mode": "ะั–ั‡ะฝะต ัะฒั–ั‚ะปะพ | ะŸะšะœ ั‰ะพะฑ ัƒะฒั–ะผะบะฝัƒั‚ะธ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะฝะธะน ั€ะตะถะธะผ", + "Gives the model search capabilities (immediately)": "ะะฐะดะฐั” ะผะพะถะปะธะฒั–ัั‚ัŒ ะฟะพัˆัƒะบัƒ ะผะพะดะตะปั– (ะฝะตะณะฐะนะฝะพ)", + "Depends on workspace": "ะ—ะฐะปะตะถะฝะพ ะฒั–ะด ะฟั€ะพัั‚ะพั€ัƒ", + "Invalid arguments. Must provide `command`.": "ะะตะฟั€ะฐะฒะตะปัŒะฝั– ะฟะฐั€ะฐะผะตั‚ั€ะธ. ะŸะพั‚ั€ั–ะฑะฝะพ ะฒะบะฐะทะฐั‚ะธ `command`.", + "Temperature\nChange with /temp VALUE": "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ\nะ—ะผั–ะฝะธั‚ะธ ะผะพะถะฝะฐ ะบะพะผะฐะฝะดะพัŽ /temp ะ—ะะะงะ•ะะะฏ", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะกัƒั‡ะฐัะฝะฐ ะฑะฐะณะฐั‚ะพั†ั–ะปัŒะพะฒะฐ ะผะพะดะตะปัŒ Google, ัะบะฐ ั‡ัƒะดะพะฒะพ ัะฟั€ะฐะฒะปัั”ั‚ัŒัั ะท ะบะพะดัƒะฒะฐะฝะฝัะผ ั– ัะบะปะฐะดะฝะธะผะธ ะทะฐะฒะดะฐะฝะฝัะผะธ ะฝะฐ ะผั–ั€ะบัƒะฒะฐะฝะฝั.", + "EasyEffects | Right-click to configure": "EasyEffects | ะŸะšะœ ั‰ะพะฑ ะฝะฐะปะฐัˆั‚ัƒะฒะฐั‚ะธ", + "Thought": "ะ”ัƒะผะบะฐ", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google \nะœะพะดะตะปัŒ Gemini 2.5 Flash ะพะฟั‚ะธะผั–ะทะพะฒะฐะฝะฐ ะดะปั ะตะบะพะฝะพะผั–ั‡ะฝะพั— ะตั„ะตะบั‚ะธะฒะฝะพัั‚ั– ั‚ะฐ ะฒะธัะพะบะพั— ะฟั€ะพะฟัƒัะบะฝะพั— ะทะดะฐั‚ะฝะพัั‚ั–.", + "API key is set\nChange with /key YOUR_API_KEY": "API ะบะปัŽั‡ ะฒัั‚ะฐะฝะพะฒะปะตะฝะพ\nะ—ะผั–ะฝะธั‚ะธ ะผะพะถะฝะฐ ะบะพะผะฐะฝะดะพัŽ /key YOUR_API_KEY", + "Current tool: %1\nSet it with %2tool TOOL": "ะŸะพั‚ะพั‡ะฝะธะน ั–ะฝัั‚ั€ัƒะผะตะฝั‚: %1\nะ’ัั‚ะฐะฝะพะฒั–ั‚ัŒ ะนะพะณะพ ะบะพะผะฐะฝะดะพัŽ %2tool TOOL", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**ะ†ะฝัั‚ั€ัƒะบั†ั–ั—**: ะฃะฒั–ะนะดั–ั‚ัŒ ะฒ ะพะฑะปั–ะบะพะฒะธะน ะทะฐะฟะธั Mistral, ะฟะตั€ะตะนะดั–ั‚ัŒ ะดะพ ั€ะพะทะดั–ะปัƒ ยซKeysยป ะฝะฐ ะฑั–ั‡ะฝั–ะน ะฟะฐะฝะตะปั–, ะฝะฐั‚ะธัะฝั–ั‚ัŒ ยซCreate new keyยป", + "Usage: %1tool TOOL_NAME": "ะ’ะธะบะพั€ะธัั‚ะฐะฝะฝั: %1tool TOOL_NAME", + "Online | Google's model\nFast, can perform searches for up-to-date information": "ะžะฝะปะฐะนะฝ | ะผะพะดะตะปัŒ Google \nะจะฒะธะดะบะธะน, ะผะพะถะต ะทะดั–ะนัะฝัŽะฒะฐั‚ะธ ะฟะพัˆัƒะบ ะฐะบั‚ัƒะฐะปัŒะฝะพั— ั–ะฝั„ะพั€ะผะฐั†ั–ั—", + "Approve": "ะกั…ะฒะฐะปะธั‚ะธ", + "Preferred wallpaper zoom (%)": "ะ‘ะฐะถะฐะฝะธะน ะผะฐัˆั‚ะฐะฑ ัˆะฟะฐะปะตั€ (%)", + "Performance Profile toggle": "ะŸะตั€ะตะผะธะบะฐั‡ ะฟั€ะพั„ั–ะปัŽ ะฟั€ะพะดัƒะบั‚ะธะฒะฝะพัั‚ั–", + "Total token count\nInput: %1\nOutput: %2": "ะ—ะฐะณะฐะปัŒะฝะฐ ะบั–ะปัŒะบั–ัั‚ัŒ ั‚ะพะบะตะฝั–ะฒ\nะ’ั…ั–ะดะฝั–: %1\nะ’ะธั…ั–ะดะฝั–: %2", + "Wallpaper parallax": "ะžะฑั”ะผะฝั– ัˆะฟะฐะปะตั€ะธ", + "Invalid tool. Supported tools:\n- %1": "ะะตะฟั€ะฐะฒะตะปัŒะฝะธะน ั–ะฝัั‚ั€ัƒะผะตะฝั‚. ะŸั–ะดั‚ั€ะธะผัƒัŽั‚ัŒัั:\n- %1", + "Usage: %1load CHAT_NAME": "ะ’ะธะพะบั€ะธัั‚ะฐะฝะฝั: %1load CHAT_NAME", + "Reject": "ะ’ั–ะดั…ะธะปะธั‚ะธ", + "Usage: %1save CHAT_NAME": "ะ’ะธะบะพั€ะธัั‚ะฐะฝะฝั: %1save CHAT_NAME", + "Set the tool to use for the model.": "ะ’ะบะฐะถั–ั‚ัŒ ั–ะฝัั‚ั€ัƒะผะตะฝั‚ ะดะปั ั€ะพะฑะพั‚ะธ ะท ะผะพะดะตะปัŒัŽ", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "ะžะฝะปะฐะนะฝ | %1's ะผะพะดะตะปัŒ | ะะฐะดะฐั” ัˆะฒะธะดะบั–, ั‡ัƒะนะฝั– ั‚ะฐ ะดะพะฑั€ะต ะฒั–ะดั„ะพั€ะผะฐั‚ะพะฒะฐะฝั– ะฒั–ะดะฟะพะฒั–ะดั–. ะะตะดะพะปั–ะบะธ: ะฝะต ะดัƒะถะต ะพั…ะพั‡ะต ะฒะธะบะพะฝัƒั” ะทะฐะฒะดะฐะฝะฝั; ะผะพะถะต ะฒะธะณะฐะดัƒะฒะฐั‚ะธ ะฒะธะบะปะธะบะธ ะฝะตะฒั–ะดะพะผะธั… ั„ัƒะฝะบั†ั–ะน", + "Depends on sidebars": "ะ—ะฐะปะตะถะฝะพ ะฒั–ะด ะฑะพะบะพะฒะธั… ะฟะฐะฝะตะปะตะน", + "Command rejected by user": "ะšะพะผะฐะฝะดะฐ ะฒั–ะดั…ะธะปะตะฝะฐ ะบะพั€ะธัั‚ัƒะฒะฐั‡ะตะผ", + "There might be a download in progress": "ะœะพะถะปะธะฒะพ, ั‚ั€ะธะฒะฐั” ะทะฐะฒะฐะฝั‚ะฐะถะตะฝะฝั", + "Disable tools": "ะ’ะธะผะบะฝัƒั‚ะธ ั–ะฝัั‚ั€ัƒะผะตะฝั‚", + "Tool set to: %1": "ะ†ะฝัั‚ั€ัƒะผะตั‚ ะฒะบะฐะทะฐะฝะพ: %1", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "ะšะพะผะฐะฝะดะธ, ั€ะตะดะฐะณัƒะฒะฐะฝะฝั ะบะพะฝั„ั–ะณัƒั€ะฐั†ั–ะน, ะฟะพัˆัƒะบ.\nะ’ะธะบะพะฝัƒั” ะดะพะดะฐั‚ะบะพะฒัƒ ะดั–ัŽ ะดะปั ะฟะตั€ะตั…ะพะดัƒ ะฒ ั€ะตะถะธะผ ะฟะพัˆัƒะบัƒ, ัะบั‰ะพ ั†ะต ะฟะพั‚ั€ั–ะฑะฝะพ", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "ะฉะพะฑ ะทะฐะดะฐั‚ะธ ะบะปัŽั‡ API, ะฟะตั€ะตะดะฐะนั‚ะต ะนะพะณะพ ะบะพะผะฐะฝะดะพัŽ %4\n\nะฉะพะฑ ะฟะตั€ะตะณะปัะฝัƒั‚ะธ ะบะปัŽั‡, ะฟะตั€ะตะดะฐะนั‚ะต \"get\" ะบะพะผะฐะฝะดะพัŽ
\n\n### ะ”ะปั %1:\n\n**ะ›ั–ะฝะบ**: %2\n\n%3", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "ะžะฝะปะฐะนะฝ | ะœะพะดะตะปัŒ Google\nะะพะฒั–ัˆะฐ ะผะพะดะตะปัŒ, ัะบะฐ ะฟะพะฒั–ะปัŒะฝั–ัˆะฐ ะทะฐ ัะฒะพัŽ ะฟะพะฟะตั€ะตะดะฝะธั†ัŽ, ะฐะปะต ะผะฐั” ะฝะฐะดะฐะฒะฐั‚ะธ ัะบั–ัะฝั–ัˆั– ะฒั–ะดะฟะพะฒั–ะดั–" +} diff --git a/configs/quickshell/translations/vi_VN.json b/configs/quickshell/translations/vi_VN.json new file mode 100644 index 0000000..ce77f2b --- /dev/null +++ b/configs/quickshell/translations/vi_VN.json @@ -0,0 +1,373 @@ +{ + "Unknown function call: %1": "Hร m khรดng xรกc ฤ‘แป‹nh: %1", + "Show next time": "Hiแปƒn thแป‹ lแบงn sau", + "Fidelity": "Khรก giแป‘ng gแป‘c", + "Open file link": "MแปŸ liรชn kแบฟt tแป‡p", + "Interrupts possibility of overview being toggled on release.": "Ngฤƒn mแปŸ overview khi nhแบฃ nรบt.", + "No audio source": "Khรดng cรณ nguแป“n รขm thanh", + "Might look ass. Unsupported.": "Cรณ thแปƒ rแบฅt tแป‡. Khรดng ฤ‘ฦฐแปฃc hแป— trแปฃ.", + "Jump to current month": "Nhแบฃy ฤ‘แบฟn thรกng hiแป‡n tแบกi", + "Delete": "Xรณa", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Giรก**: miแป…n phรญ. Dแปฏ liแป‡u ฤ‘ฦฐแปฃc sแปญ dแปฅng cho mแปฅc ฤ‘รญch huแบฅn luyแป‡n. **Hฦฐแป›ng dแบซn**: ฤฤƒng nhแบญp vร o tร i khoแบฃn Google, cho phรฉp AI Studio tแบกo dแปฑ รกn Google Cloud gรฌ ฤ‘รณ, quay lแบกi rแป“i แบฅn Get API key", + "Rainbow": "Cแบงu vแป“ng", + "%1 does not require an API key": "%1 khรดng cแบงn API key", + "Choose model": "Chแปn model", + "Prevents abrupt increments and restricts volume limit": "Chแบทn thay ฤ‘แป•i ฤ‘แป™t ngแป™t vร  giแป›i hแบกn รขm lฦฐแปฃng", + "%1 characters": "%1 kรฝ tแปฑ", + "Change any time later with /dark, /light, /img in the launcher": "Thay ฤ‘แป•i bแบฅt cแปฉ lรบc nร o sau nร y vแป›i /dark, /light, /img trong launcher", + "Tonal Spot": "Tonal Spot", + "Neutral": "Trung tรญnh", + "To Do": "Cแบงn lร m", + "Auto": "Tแปฑ ฤ‘แป™ng", + "Polling interval (ms)": "Thแปi gian lแบทp lแบกi (ms)", + "Center title": "Cฤƒn giแปฏa tiรชu ฤ‘แป", + "Lock": "Khรณa mร n hรฌnh", + "Screen snip": "Chแปฅp mร n hรฌnh (chแปn vรนng)", + "User agent (for services that require it)": "User agent (nแบฟu cแบงn)", + "Report a Bug": "Bรกo lแป—i", + "Shutdown": "Tแบฏt mรกy", + "Keyboard toggle": "MแปŸ/ฤ‘รณng bร n phรญm แบฃo", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "Cรกi nhiแปu hentai nhแบฅt | Sแป‘ lฦฐแปฃng rแบฅt tแป‘t, rแบฅt nhiแปu NSFW, chแบฅt lฦฐแปฃng cรณ thแปƒ khรกc nhau nhiแปu", + "Download": "Tแบฃi xuแป‘ng", + "Note: turning off can hurt readability": "Ghi chรบ: nแบฟu tแบฏt cรณ thแปƒ khรณ ฤ‘แปc", + "Local Ollama model | %1": "Model Ollama trรชn mรกy | %1", + "Silent": "Im lแบทng", + "Columns": "Sแป‘ cแป™t", + "Set with /mode PROVIDER": "Set with /mode PROVIDER", + "Issues": "Cรกc vแบฅn ฤ‘แป", + "Policies": "Chรญnh sรกch", + "Load chat from %1": "Tแบฃi trรฒ chuyแป‡n tแปซ %1", + "Unknown Album": "Album khรดng xรกc ฤ‘แป‹nh", + "Yes": "Cรณ", + "Battery": "Pin", + "Material palette": "Kiแปƒu material", + "Chain of Thought": "Dรฒng suy nghฤฉ", + "This is necessary because GlobalShortcut.onReleased in quickshell triggers whether or not you press something else while holding the key.": "Cแบงn cรกi nร y vรฌ GlobalShortcut.onReleased cho mแป™t phรญm cแปงa Quickshell ฤ‘ฦฐแปฃc kรญch hoแบกt kแปƒ cแบฃ khi ban รขn phรญm khรกc trฦฐแป›c khi thแบฃ phรญm ฤ‘รณ.", + "Low warning": "Cแบฃnh bรกo thแบฅp", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Vแป›i Zerochan:\n- Hรฃy nhแบญp tรชn mแป™t mร u (bแบฑng tiแบฟng Anh)\n- ฤแบทt username Zerochan trong tรนy chแปn `sidebar.booru.zerochan.username`. Bแบกn [cรณ thแปƒ bแป‹ ban nแบฟu khรดng tuรขn thแปง](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "Brightness": "ฤแป™ sรกng", + "Yooooo hi there": "Yooooo chร o bแบกn", + "Colors & Wallpaper": "Mร u sแบฏc & Hรฌnh nแปn", + "No media": "Khรดng cรณ media", + "Critical warning": "Cแบฃnh bรกo rแบฅt thแบฅp", + "Mic toggle": "Bแบญt/tแบฏt mic", + "12h AM/PM": "12h AM/PM", + "Large language models": "Mรด hรฌnh ngรดn ngแปฏ lแป›n", + "Markdown test": "Test markdown", + "Temperature: %1": "Nhiแป‡t ฤ‘แป™: %1", + "Edit": "Sแปญa", + "Waifus only | Excellent quality, limited quantity": "Chแป‰ waifus | Chแบฅt lฦฐแปฃng xuแบฅt sแบฏc, sแป‘ lฦฐแปฃng hแบกn chแบฟ", + "Cheat sheet": "Bแบฃng tra cแปฉu", + "Current model: %1\nSet it with %2model MODEL": "Model ฤ‘ang chแปn: %1\nChแปn vแป›i lแป‡nh %2model MODEL", + "Provider set to": "ฤรฃ ฤ‘แบทt nhร  cung cแบฅp thร nh", + "Clear": "Xรณa hแบฟt", + "GitHub": "GitHub", + "App": "แปจng dแปฅng", + "Title bar": "Thanh tiรชu ฤ‘แป", + "Web search": "Tรฌm kiแบฟm web", + "Invalid model. Supported: \n```": "Model khรดng hแปฃp lแป‡. Cรกc lแปฑa chแปn: \n```", + "Calendar": "Lแป‹ch", + "Done": "ฤรฃ xong", + "Monochrome": "ฤen trแบฏng", + "Show regions of potential interest": "Hiแปƒn thแป‹ vรนng thรดng minh", + "Dark/Light toggle": "Chuyแปƒn chแบฟ ฤ‘แป™ sรกng/tแป‘i", + "Unknown command:": "Lแป‡nh khรดng xรกc ฤ‘แป‹nh:", + "Allow NSFW content": "Cho phรฉp nแป™i dung NSFW", + "Closes cheatsheet on press": "ฤรณng bแบฃng tra cแปฉu khi แบฅn", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Chแป‰nh giรก trแป‹ nhiแป‡t ฤ‘แป™ (sแปฑ ngแบซu nhiรชn) cแปงa model. Giรก trแป‹ 0-2 vแป›i Gemini, 0-1 vแป›i cรกc model khรกc. Mแบทc ฤ‘แป‹nh lร  0.5.", + "Invalid API provider. Supported: \n-": "Nhร  cung cแบฅp API khรดng hแปฃp lแป‡. Cรกc lแปฑa chแปn: \n-", + "Shell windows": "Cแปญa sแป• cแปงa shell", + "Loaded the following system prompt\n\n---\n\n%1": "ฤรฃ tแบฃi chแป‰ dแบซn hแป‡ thแป‘ng sau ฤ‘รขy\n\n---\n\n%1", + "Clipboard": "Clipboard", + "For storing API keys and other sensitive information": "ฤแปƒ lฦฐu trแปฏ API key vร  cรกc thรดng tin nhแบกy cแบฃm khรกc", + "Wallpaper": "Hรฌnh nแปn", + "Decorations & Effects": "Trang trรญ & Hiแป‡u แปฉng", + "AI": "AI", + "Large images | God tier quality, no NSFW.": "แบขnh kรญch thฦฐแป›c lแป›n | Chแบฅt lฦฐแปฃng cแปฑc tแป‘t, khรดng cรณ NSFW.", + "When not fullscreen": "Khi khรดng toร n mร n hรฌnh", + "Resources": "Tร i nguyรชn", + "Light": "Sรกng", + "Weeb": "Wibu", + "Disable NSFW content": "Tแบฏt nแป™i dung NSFW", + "OK": "OK", + "Screenshot tool": "Cรดng cแปฅ chแปฅp mร n hรฌnh", + "Enable": "Bแบญt", + "Select Language": "Chแปn ngรดn ngแปฏ", + "System": "Hแป‡ thแป‘ng", + "Emojis": "Emoji", + "The current system prompt is\n\n---\n\n%1": "Chแป‰ dแบซn hแป‡ thแป‘ng hiแป‡n tแบกi nhฦฐ sau\n\n---\n\n%1", + "Translator": "Dแป‹ch", + "Sleep": "Ngแปง", + "Action": "Hร nh ฤ‘แป™ng", + "Audio": "ร‚m thanh", + "Show background": "Hiแป‡n nแปn", + "All-rounder | Good quality, decent quantity": "Tแป‘t ฤ‘แปu | Chแบฅt lฦฐแปฃng tแป‘t, sแป‘ lฦฐแปฃng แป•n", + "Documentation": "Tร i liแป‡u", + "Terminal": "Terminal", + "Distro": "Distro", + "Clear chat history": "Xรณa lแป‹ch sแปญ trรฒ chuyแป‡n", + "Float": "Nแป•i", + "No further instruction provided": "No further instruction provided", + "Choose file": "Chแปn tแป‡p", + "Set the system prompt for the model.": "ฤแบทt chแป‰ dแบซn hแป‡ thแป‘ng cho model.", + "Unknown Title": "Bร i hรกt khรดng rรต tรชn", + "Math result": "Kแบฟt quแบฃ phรฉp tรญnh", + "Logout": "ฤฤƒng xuแบฅt", + "Privacy Policy": "Chรญnh sรกch quyแปn riรชng tฦฐ", + "Style": "Phong cรกch", + "Borderless": "Khรดng viแปn", + "Set API key": "ฤแบทt API key", + "Clean stuff | Excellent quality, no NSFW": "Sแบกch sแบฝ | Chแบฅt lฦฐแปฃng xuแบฅt sแบฏc, khรดng cรณ NSFW", + "Experimental | Online | Google's model\nCan do a little more but doesn't search quickly": "Thแปญ nghiแป‡m | Trแปฑc tuyแบฟn | Model cแปงa Google\nCรณ thแปƒ lร m nhiแปu hฦกn mแป™t chรบt nhฦฐng khรดng tรฌm kiแบฟm nhanh chรณng", + "Toggles cheatsheet on press": "MแปŸ/ฤ‘รณng bแบฃng tra cแปฉu khi แบฅn", + "Thinking": "ฤang nghฤฉ", + "Earbang protection": "Bแบฃo vแป‡ tai", + "Advanced": "Nรขng cao", + "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)": "Cรณ thแปƒ tแป‘t hฦกn nแบฟu bแบกn gรต lแป‡ch phรญm nhiแปu,\nnhฦฐng kแบฟt quแบฃ cรณ thแปƒ hฦกi lแบก vร  khรดng hoแบกt ฤ‘แป™ng tแป‘t vแป›i tแปซ viแบฟt tแบฏt\n(vรญ dแปฅ tรฌm \"GIMP\" cรณ thแปƒ khรดng ra cรกi chฦฐฦกng trรฌnh vแบฝ)", + "Shell & utilities theming must also be enabled": "Cแบงn Shell & cรดng cแปฅ cลฉng bแบญt", + "Desktop": "Mร n hรฌnh chรญnh", + "Anime": "Anime", + "Qt apps": "Cรกc แปฉng dแปฅng Qt", + "Style & wallpaper": "Phong cรกch & hรฌnh nแปn", + "Finished tasks will go here": "Viแป‡c ฤ‘รฃ xong sแบฝ hiแป‡n แปŸ ฤ‘รขy", + "Weather": "Thแปi tiแบฟt", + "Settings": "Cร i ฤ‘แบทt", + "Shell & utilities": "Shell & tiแป‡n รญch", + "Unfinished": "Chฦฐa xong", + "Random: Konachan": "Ngแบซu nhiรชn: Konachan", + "Pick wallpaper image on your system": "Chแปn hรฌnh nแปn trรชn mรกy", + "Volume": "ร‚m lฦฐแปฃng", + "Add": "Thรชm", + "Hibernate": "Ngแปง ฤ‘รดng", + "Run": "Chแบกy", + "Keep system awake": "Giแปฏ hแป‡ thแป‘ng bแบญt", + "To make sure this works consistently, use binditn = MODKEYS, catchall in an automatically triggered submap that includes everything.": "ฤแปƒ ฤ‘แบฃm bแบฃo luรดn hoแบกt ฤ‘แป™ng, dรนng binditn = MODKEYS, catchall trong mแป™t submap luรดn ฤ‘ฦฐแปฃc kรญch hoแบกt bao trรนm mแปi thแปฉ.", + "Plain rectangle": "Hรฌnh chแปฏ nhแบญt", + "%1 queries pending": "%1 lแป‡nh gแปi ฤ‘ang chแป", + "Temperature set to %1": "Nhiแป‡t ฤ‘แป™ ฤ‘รฃ ฤ‘ฦฐแปฃc ฤ‘แบทt thร nh %1", + "Notifications": "Thรดng bรกo", + "System prompt": "Chแป‰ dแบซn hแป‡ thแป‘ng", + "Hover to reveal": "ฤแบทt chuแป™t vร o ฤ‘แปƒ hiแป‡n", + "No": "Khรดng", + "Bar": "Bar", + "Search the web": "Tรฌm kiแบฟm web", + "Page %1": "Trang %1", + "Reboot": "KhแปŸi ฤ‘แป™ng lแบกi", + "Such regions 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.": "Cรกc vรนng cรณ thแปƒ lร  hรฌnh แบฃnh hoแบทc phแบงn cแปงa mร n hรฌnh cแป› vแบป ฤ‘ฦฐแปฃc bao chแปฉa.\nKhรดng luรดn chรญnh xรกc.\nSแปญ dแปฅng mแป™t thuแบญt toรกn xแปญ lรฝ แบฃnh chแบกy trรชn mรกy, khรดng dรนng AI.", + "Show app icons": "Hiแป‡n biแปƒu tฦฐแปฃng แปฉng dแปฅng", + "Closet": "Nghiแป‡n mร  ngแบกi", + "Set the current API provider": "ฤแบทt nguแป“n cung cแบฅp API", + "Cancel": "Hแปงy", + "Networking": "Mแบกng", + "Overview": "Overview", + "Search, calculate or run": "Tรฌm, tรญnh hoแบทc chแบกy", + "Useless buttons": "Mแบฅy nรบt vรด dแปฅng", + "Transparency": "Sแปฑ trong suแป‘t", + "Temperature must be between 0 and 2": "Nhiแป‡t ฤ‘แป™ phแบฃi trong khoแบฃng tแปซ 0 ฤ‘แบฟn 2", + "Automatically suspends the system when battery is low": "Tแปฑ ฤ‘แป™ng ngแปง khi pin thแบฅp", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Endpoint API hiแป‡n tแบกi: %1\nฤแบทt vแป›i lแป‡nh %2mode PROVIDER", + "Services": "Cรกc dแป‹ch vแปฅ", + "Reload Hyprland & Quickshell": "Tแบฃi lแบกi Hyprland & Quickshell", + "Automatic suspend": "Tแปฑ ฤ‘แป™ng ngแปง", + "illogical-impulse Welcome": "illogical-impulse - Xin chร o", + "Interface": "Giao diแป‡n", + "Load chat": "Tแบฃi cuแป™c trรฒ chuyแป‡n", + "Number show delay when pressing Super (ms)": "Thแปi gian chแป hiแป‡n sแป‘ khi nhแบฅn Super (ms)", + "Clear the current list of images": "Xรณa danh sรกch hรฌnh แบฃnh hiแป‡n tแบกi", + "Fake screen rounding": "Giแบฃ bo trรฒn mร n hรฌnh", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Mแบนo: แบจn biแปƒu tฦฐแปฃng vร  luรดn hiแปƒn thแป‹ sแป‘ nแบฟu\nmuแป‘n giแป‘ng trแบฃi nghiแป‡m illogical-impulse gแป‘c", + "Launch": "Chแบกy", + "%1 notifications": "%1 thรดng bรกo", + "%1 | Right-click to configure": "%1 | แบคn chuแป™t phแบฃi ฤ‘แปƒ chแป‰nh", + "Unknown Artist": "Nghแป‡ sฤฉ khรดng xรกc ฤ‘แป‹nh", + "Appearance": "Giao diแป‡n", + "Task Manager": "Quแบฃn lรญ แปฉng dแปฅng ฤ‘ang chแบกy", + "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "ฤแปƒ ฤ‘แบทt API key, viแบฟt nรณ sau lแป‡nh\n\nฤแปƒ xem lแบกi, viแบฟt \"get\" sau lแป‡nh
\n\n### Vแป›i %1:\n\n**Link**: %2\n\n%3", + "Opens cheatsheet on press": "MแปŸ bแบฃng tra cแปฉu khi แบฅn", + "Invalid arguments. Must provide `key` and `value`.": "Biแบฟn khรดng hแปฃp lแป‡. cแบงn cแบฃ `key` vร  `value`.", + "About": "Giแป›i thiแป‡u", + "illogical-impulse": "illogical-impulse", + "Help & Support": "Trแปฃ giรบp", + "Enter tags, or \"%1\" for commands": "Nhแบญp tag hoแบทc \"%1\" ฤ‘แปƒ xem cรกc lแป‡nh", + "Format": "ฤแป‹nh dแบกng", + "Content": "Giแป‘ng gแป‘c", + "Edit config": "Sแปญa config", + "Bluetooth": "Bluetooth", + "Be patient...": "Bรฌnh tฤฉnh...", + "Discussions": "Thแบฃo luแบญn", + "Anime boorus": "Cรกc booru anime", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "Quแบฃ nร y khรดng ฤ‘ฦฐแปฃc. Mแบนo:\n- Kiแปƒm tra tag vร  cร i ฤ‘แบทt NSFW\n- Nแบฟu khรดng nghฤฉ ra tag nร o cรณ thแปƒ nhแบญp sแป‘ trang", + "Task description": "Mรด tแบฃ cรดng viแป‡c", + "Max allowed increase": "Thay ฤ‘แป•i tแป‘i ฤ‘a", + "Rows": "Sแป‘ hร ng", + "Switched to search mode. Continue with the user's request.": "ฤรฃ chuyแปƒn sang chแบฟ ฤ‘แป™ tรฌm kiแบฟm. Tiแบฟp tแปฅc vแป›i yรชu cแบงu cแปงa ngฦฐแปi dรนng.", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Sแปญ dแปฅng thuแบญt toรกn dรนng khoแบฃng cรกch Levenshtein thay vรฌ fuzzy", + "Copy": "Sao chรฉp", + "12h am/pm": "12h am/pm", + "Unknown": "Khรดng xรกc ฤ‘แป‹nh", + "Waiting for response...": "ฤang chแป phแบฃn hแป“i...", + "Workspace": "Workspace", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Hรฌnh nแปn Anime SFW ngแบซu nhiรชn tแปซ Konachan\nแบขnh ฤ‘ฦฐแปฃc lฦฐu vร o ~/Pictures/Wallpapers", + "Online via %1 | %2's model": "Trแปฑc tuyแบฟn qua %1 | Model cแปงa %2", + "Always show numbers": "Luรดn hiแป‡n sแป‘", + "or": "hoแบทc", + "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit": "Kรฉo thแบฃ hoแบทc chแปn vรนng โ€ข Chuแป™t trรกi: Sao chรฉp โ€ข Chuแป™t phแบฃi: Chแป‰nh sแปญa", + "Local only": "Chแป‰ trรชn mรกy", + "Donate": "แปฆng hแป™", + "Online | Google's model\nGives up-to-date information with search.": "Trแปฑc tuyแบฟn | Model cแปงa Google\nCรณ thแปƒ tรฌm kiแบฟm ฤ‘แปƒ cung cแบฅp thรดng tin cแบญp nhแบญt.", + "Run command": "Chแบกy lแป‡nh", + "Dotfiles": "Dotfiles", + "Volume limit": "Giแป›i hแบกn รขm lฦฐแปฃng", + "On-screen display": "ร‚m lฦฐแปฃng/ฤ‘แป™ sรกng", + "Reboot to firmware settings": "KhแปŸi ฤ‘แป™ng lแบกi vร o cร i ฤ‘แบทt firmware", + "Workspaces shown": "Sแป‘ workspace hiแปƒn thแป‹", + "Save": "Lฦฐu", + "The popular one | Best quantity, but quality can vary wildly": "Phแป• biแบฟn | Sแป‘ lฦฐแปฃng tแป‘t nhแบฅt, nhฦฐng chแบฅt lฦฐแปฃng khรดng biแบฟt ฤ‘รขu vร o ฤ‘รขu", + "Save chat": "Lฦฐu cuแป™c trรฒ chuyแป‡n", + "Intelligence": "Trรญ tuแป‡", + "Translation goes here...": "Bแบฃn dแป‹ch sแบฝ hiแป‡n แปŸ ฤ‘รขy...", + "Toggle clipboard query on overview widget": "MแปŸ/ฤ‘รณng tรฌm kiแบฟm clipboard trรชn overview", + "Search": "Tรฌm kiแบฟm", + "Timeout (ms)": "Thแปi gian chแป (ms)", + "24h": "24h", + "Color picker": "Chแปn mร u", + "Save to Downloads": "Lฦฐu vร o Downloads", + "No notifications": "Khรดng cรณ thรดng bรกo", + "Game mode": "Chแบฟ ฤ‘แป™ game", + "Alternatively use /dark, /light, /img in the launcher": "Cรณ thแปƒ dรนng /dark, /light, /img trong launcher", + "Info": "Thรดng tin", + "Dock": "Dock", + "Pinned on startup": "Ghim khi khแปŸi ฤ‘แป™ng", + "Suspend at": "Tแบกm dแปซng แปŸ", + "Fruit Salad": "Salad hoa quแบฃ", + "API key:\n\n```txt\n%1\n```": "API key:\n\n```txt\n%1\n```", + "API key set for %1": "API key ฤ‘รฃ ฤ‘แบทt cho %1", + "Not visible to model": "Khรดng hiแปƒn thแป‹ cho model", + "Expressive": "Biแปƒu cแบฃm", + "Enter text to translate...": "Nhแบญp vฤƒn bแบฃn ฤ‘แปƒ dแป‹ch...", + "Usage": "Cรกch dรนng", + "Message the model... \"%1\" for commands": "Hแปi model... \"%1\" ฤ‘แปƒ xem lแป‡nh", + "Keybinds": "Phรญm tแบฏt", + "Model set to %1": "ฤรฃ ฤ‘แบทt model thร nh %1", + "Scale (%)": "Tแป‰ lแป‡ (%)", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Gรต /key ฤ‘แปƒ bแบฏt ฤ‘แบงu dรนng cรกc model trแปฑc tuyแบฟn\nCtrl+O ฤ‘แปƒ mแปŸ rแป™ng sidebar\nCtrl+P ฤ‘แปƒ nhแบฅc sidebar thร nh cแปญa sแป•", + "Output": "ฤแบงu ra", + "Uptime: %1": "Mรกy bแบญt ฤ‘ฦฐแปฃc %1", + "For desktop wallpapers | Good quality": "Cho hรฌnh nแปn mรกy tรญnh | Chแบฅt lฦฐแปฃng tแป‘t", + "Nothing here!": "Khรดng cรณ gรฌ แปŸ ฤ‘รขy!", + "Close": "ฤรณng", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Phรญm mลฉi tรชn ฤ‘แปƒ chแปn, Enter ฤ‘แปƒ xรกc nhแบญn\nEsc hoแบทc แบฅn bแบฅt kแปณ ฤ‘รขu ฤ‘แปƒ thoรกt", + "Copy code": "Sao chรฉp code", + "Load prompt from %1": "Tแบฃi chแป‰ dแบซn tแปซ %1", + "Time": "Thแปi gian", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Giรก**: miแป…n phรญ. Chรญnh sรกch sแปญ dแปฅng dแปฏ liแป‡u tรนy thuแป™c vร o cร i ฤ‘แบทt tร i khoแบฃn OpenRouter cแปงa bแบกn.\n\n**Hฦฐแป›ng dแบซn**: ฤฤƒng nhแบญp vร o tร i khoแบฃn OpenRouter, mแปŸ Keys แปŸ menu gรณc trรชn bรชn phแบฃi, แบฅn Create API Key", + "Bar style": "Phong cรกch bar", + "Configuration": "Cร i ฤ‘แบทt", + "Prefixes": "Kรญ tแปฑ ฤ‘แบงu", + "No API key set for %1": "Khรดng cรณ API key cho %1", + "Add task": "Thรชm cรดng viแป‡c", + "Volume mixer": "Trแป™n รขm lฦฐแปฃng", + "Go to source (%1)": "ฤi ฤ‘แบฟn nguแป“n (%1)", + "The current API used. Endpoint:": "API ฤ‘ang sแปญ dแปฅng. Endpoint:", + "View Markdown source": "Xem nguแป“n Markdown", + "Input": "ฤแบงu vร o", + "Allow NSFW": "Cho phรฉp NSFW", + "Session": "Session", + "Detach left sidebar into a window/Attach it back": "Nhแบฅc sidebar trรกi thร nh cแปญa sแป•/ฤแบทt nรณ lแบกi", + "Night Light": "Lแปc รกnh sรกng xanh", + "Workspaces": "Cรกc workspace", + "Dark": "Tแป‘i", + "Base URL": "Base URL", + "Hug": "ร”m", + "Buttons": "Cรกc nรบt", + "Get the next page of results": "Lแบฅy trang kแบฟt quแบฃ tiแบฟp theo", + "%1 Safe Storage": "Lฦฐu trแปฏ an toร n %1", + "Color generation": "Chแป‰nh mร u", + "Select output device": "Chแปn ฤ‘แบงu ra", + "Select input device": "Chแปn ฤ‘แบงu vร o", + "%1 โ€ข %2 tasks": "%1 โ€ข %2 viแป‡c cแบงn lร m", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Model trแปฑc tuyแบฟn khรดng ฤ‘ฦฐแปฃc cho phรฉp\n\nCร i ฤ‘แบทt bแปŸi lแปฑa chแปn `policies.ai`", + "Download complete": "ฤรฃ tแบฃi xong", + "Code saved to file": "Code ฤ‘รฃ lฦฐu vร o file", + "Critically low battery": "Pin rแบฅt thแบฅp", + "Scroll to change brightness": "Cuแป™n ฤ‘แปƒ thay ฤ‘แป•i ฤ‘แป™ sรกng", + "Cloudflare WARP": "Cloudflare WARP", + "Toggles bar on press": "MแปŸ/ฤ‘รณng bar khi แบฅn", + "Saved to %1": "ฤรฃ lฦฐu vร o %1", + "Elements": "Nguyรชn tแป‘", + "Save chat to %1": "Lฦฐu chat vร o %1", + "Connection failed. Please inspect manually with the warp-cli command": "Kแบฟt nแป‘i khรดng thร nh cรดng. Hรฃy xem lแบกi vแป›i lแป‡nh warp-cli", + "Weather Service": "Thแปi tiแบฟt", + "Registration failed. Please inspect manually with the warp-cli command": "ฤฤƒng kรฝ khรดng thร nh cรดng. Hรฃy xem lแบกi vแป›i lแป‡nh warp-cli", + "Consider plugging in your device": "Hรฃy cแบฏm nguแป“n thiแบฟt bแป‹ cแปงa bแบกn", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Cannot find a GPS service. Using the fallback method instead.": "Khรดng tรฌm thแบฅy dแป‹ch vแปฅ GPS. ฤang sแปญ dแปฅng phฦฐฦกng phรกp dแปฑ phรฒng.", + "Opens bar on press": "MแปŸ bar khi แบฅn", + "Low battery": "Pin yแบฟu", + "Scroll to change volume": "Cuแป™n ฤ‘แปƒ thay ฤ‘แป•i รขm lฦฐแปฃng", + "Please charge!\nAutomatic suspend triggers at %1": "Hรฃy sแบกc pin!\nHแป‡ thแป‘ng sแบฝ tแปฑ ฤ‘แป™ng ngแปง khi pin xuแป‘ng %1", + "Closes bar on press": "ฤรณng bar khi แบฅn", + "Mo": "T2/*keep*/", + "Tu": "T3/*keep*/", + "We": "T4/*keep*/", + "Th": "T5/*keep*/", + "Fr": "T6/*keep*/", + "Sa": "T7/*keep*/", + "Su": "CN/*keep*/", + "Approve": "Chแบฅp nhแบญn", + "Set the tool to use for the model.": "Chแปn cรดng cแปฅ ฤ‘แปƒ sแปญ dแปฅng vแป›i model.", + "No API key\nSet it with /key YOUR_API_KEY": "Khรดng cรณ API key\nฤแบทt bแบฑng /key API_KEY", + "API Key": "API Key", + "EasyEffects | Right-click to configure": "EasyEffects | แบคn chuแป™t phแบฃi ฤ‘แปƒ chแป‰nh cร i ฤ‘แบทt", + "API key is set": "API key ฤ‘รฃ ฤ‘แบทt", + "Invalid tool. Supported tools:\n- %1": "Cรดng cแปฅ khรดng hแปฃp lแป‡. Cรกc lแปฑa chแปn:\n- %1", + "Thought": "Suy nghฤฉ", + "Current tool: %1\nSet it with %2tool TOOL": "Cรดng cแปฅ: %1\nฤแบทt bแบฑng %2tool Cร”NG_Cแปค", + "Edit shell config file": "Chแป‰nh file config cแปงa shell", + "A download might be in progress": "Cรณ thแปƒ cรณ tแป‡p ฤ‘ang tแบฃi", + "API key is set\nChange with /key YOUR_API_KEY": "API key ฤ‘รฃ ฤ‘แบทt\nThay ฤ‘แป•i bแบฑng /key API_KEY", + "Temperature\nChange with /temp VALUE": "Nhiแป‡t ฤ‘แป™ (ฤ‘แป™ ngแบซu nhiรชn)\nThay ฤ‘แป•i bแบฑng /temp GIร_TRแปŠ", + "Your package manager is running": "Package manager ฤ‘ang chแบกy", + "Usage: %1load CHAT_NAME": "Hฦฐแป›ng dแบซn: %1load TรŠN_ฤOแบ N_CHAT", + "Cannot switch to search mode from %1": "Khรดng thแปƒ chuyแปƒn sang chแบฟ ฤ‘แป™ tรฌm kiแบฟm tแปซ %1", + "UV Index": "Chแป‰ sแป‘ UV", + "Online | Google's model\nFast, can perform searches for up-to-date information": "Trแปฑc tuyแบฟn | Model cแปงa Google\nNhanh, cรณ thแปƒ tรฌm kiแบฟm Google ฤ‘แปƒ lแบฅy thรดng tin cแบญp nhแบญt", + "Token count": "Sแป‘ lฦฐแปฃng token", + "Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Thแปญ nghiแป‡m | Trแปฑc tuyแบฟn | Model cแปงa Google\nModel Gemini 2.5 Flash tแป‘i ฦฐu hรณa cho hiแป‡u quแบฃ chi phรญ vร  bฤƒng thรดng.", + "Wallpaper parallax": "Hiแป‡u แปฉng parallax (hรฌnh nแปn)", + "Usage: %1tool TOOL_NAME": "Hฦฐแป›ng dแบซn: %1tool TรŠN_Cร”NG_Cแปค", + "Humidity": "ฤแป™ แบฉm", + "Invalid tool. Supported tools: %1": "Cรดng cแปฅ khรดng hแปฃp lแป‡. Cรกc lแปฑa chแปn: %1", + "Sunset": "Hoร ng hรดn", + "Total token count\nInput: %1\nOutput: %2": "Sแป‘ lฦฐแปฃng token\nInput: %1\nOutput: %2", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Trแปฑc tuyแบฟn | Model cแปงa Google\nModel Gemini 2.5 Flash tแป‘i ฦฐu hรณa cho hiแป‡u quแบฃ chi phรญ vร  bฤƒng thรดng.", + "Visibility": "Tแบงm nhรฌn", + "Pressure": "รp suแบฅt", + "Depends on workspace": "Phแปฅ thuแป™c vร o workspace", + "Reject": "Tแปซ chแป‘i", + "Precipitation": "Lฦฐแปฃng mฦฐa", + "Wind": "Giรณ", + "Usage: %1save CHAT_NAME": "Hฦฐแป›ng dแบซn: %1save TรŠN_ฤOแบ N_CHAT", + "Enable EasyEffects": "Bแบญt EasyEffects", + "Night Light | Click to toggle, right-click to toggle automatic mode": "Lแปc รกnh sรกng xanh | แบฅn ฤ‘แปƒ bแบญt/tแบฏt, แบฅn chuแป™t phแบฃi ฤ‘แปƒ bแบญt/tแบฏt chแบฟ ฤ‘แป™ tแปฑ ฤ‘แป™ng", + "Night Light | Right-click to toggle Auto mode": "Lแปc รกnh sรกng xanh | แบคn chuแป™t phแบฃi ฤ‘แปƒ bแบญt/tแบฏt chแบฟ ฤ‘แป™ tแปฑ ฤ‘แป™ng", + "No command provided": "Khรดng cรณ lแป‡nh nร o ฤ‘ฦฐแปฃc cung cแบฅp", + "No API key": "Khรดng cรณ API key", + "Performance Profile toggle": "Nรบt Performance Profile", + "Sunrise": "Bรฌnh minh", + "Online | Google's model\nNewer one that's slower": "Trแปฑc tuyแบฟn | Model cแปงa Google\nMแป›i hฦกn nhฦฐng chแบญm hฦกn", + "Command rejected by user": "Lแป‡nh bแป‹ tแปซ chแป‘i bแปŸi ngฦฐแปi dรนng", + "Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search": "Thแปญ nghiแป‡m | Trแปฑc tuyแบฟn | Model cแปงa Google\nCรณ thแปƒ lร m nhiแปu hฦกn mแป™t chรบt nhฦฐng mแบฅt thรชm mแป™t lฦฐแปฃt ฤ‘แปƒ thแปฑc hiแป‡n tรฌm kiแบฟm", + "Depends on sidebars": "Phแปฅ thuแป™c vร o sidebar", + "Temperature": "Nhiแป‡t ฤ‘แป™", + "There might be a download in progress": "Cรณ thแปƒ cรณ tแป‡p ฤ‘ang tแบฃi", + "EasyEffects": "EasyEffects", + "Token count | Input: %1 | Output: %2": "Sแป‘ lฦฐแปฃng token | Input: %1 | Output: %2", + "Tool set to %1": "Cรดng cแปฅ ฤ‘ฦฐแปฃc ฤ‘แบทt thร nh %1", + "Invalid arguments. Must provide `command`.": "Tham sแป‘ khรดng hแปฃp lแป‡. Phแบฃi cung cแบฅp `command`.", + "A download is in progress": "Cรณ mแป™t tแป‡p ฤ‘ang tแบฃi", + "illogical-impulse Settings": "Cร i ฤ‘แบทt illogical-impulse", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "Trแปฑc tuyแบฟn | Model cแปงa Google\nMแป›i hฦกn nhฦฐng chแบญm hฦกn so vแป›i phiรชn bแบฃn trฦฐแป›c nhฦฐng nรชn cung cแบฅp cรขu trแบฃ lแปi chแบฅt lฦฐแปฃng cao hฦกn", + "Preferred wallpaper zoom (%)": "Tแปท lแป‡ thu phรณng hรฌnh nแปn (%)", + "Function Response": "Phแบฃn hแป“i function" +} \ No newline at end of file diff --git a/configs/quickshell/translations/zh_CN.json b/configs/quickshell/translations/zh_CN.json new file mode 100644 index 0000000..34dc541 --- /dev/null +++ b/configs/quickshell/translations/zh_CN.json @@ -0,0 +1,314 @@ +{ + "Mo": "ไธ€/*keep*/", + "Tu": "ไบŒ/*keep*/", + "We": "ไธ‰/*keep*/", + "Th": "ๅ››/*keep*/", + "Fr": "ไบ”/*keep*/", + "Sa": "ๅ…ญ/*keep*/", + "Su": "ๆ—ฅ/*keep*/", + "%1 characters": "%1 ไธชๅญ—็ฌฆ", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**ไปทๆ ผ**๏ผšๅ…่ดนใ€‚ๆ•ฐๆฎ็”จไบŽ่ฎญ็ปƒใ€‚\n\n**่ฏดๆ˜Ž**๏ผš็™ปๅฝ• Google ่ดฆๆˆท๏ผŒๅ…่ฎธ AI Studio ๅˆ›ๅปบ Google Cloud ้กน็›ฎๆˆ–ๅ…ถไป–่ฆๆฑ‚๏ผŒ็„ถๅŽ่ฟ”ๅ›žๅนถ็‚นๅ‡ป่Žทๅ– API ๅฏ†้’ฅ", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**ไปทๆ ผ**๏ผšๅ…่ดนใ€‚ๆ•ฐๆฎไฝฟ็”จๆ”ฟ็ญ–ๅ–ๅ†ณไบŽๆ‚จ็š„ OpenRouter ่ดฆๆˆท่ฎพ็ฝฎใ€‚\n\n**่ฏดๆ˜Ž**๏ผš็™ปๅฝ• OpenRouter ่ดฆๆˆท๏ผŒๅœจๅณไธŠ่ง’่œๅ•ไธญ้€‰ๆ‹ฉ Keys๏ผŒ็‚นๅ‡ปๅˆ›ๅปบ API ๅฏ†้’ฅ", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "ๆœชๆไพ›่ฟ›ไธ€ๆญฅ่ฏดๆ˜Ž", + "API key set for %1": "ๅทฒไธบ %1 ่ฎพ็ฝฎ API ๅฏ†้’ฅ", + "API key:\n\n```txt\n%1\n```": "API ๅฏ†้’ฅ๏ผš\n\n```txt\n%1\n```", + "Action": "ๆ“ไฝœ", + "Add": "ๆทปๅŠ ", + "Add task": "ๆทปๅŠ ไปปๅŠก", + "All-rounder | Good quality, decent quantity": "ๅ…จ่ƒฝๅž‹ | ่ดจ้‡ๅฅฝ๏ผŒๆ•ฐ้‡้€‚ไธญ", + "Allow NSFW": "ๅ…่ฎธ NSFW", + "Allow NSFW content": "ๅ…่ฎธ NSFW ๅ†…ๅฎน", + "Anime": "ๅŠจๆผซ", + "Anime boorus": "ๅŠจๆผซๅ›พๅบ“", + "App": "ๅบ”็”จ", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "ๆ–นๅ‘้”ฎๅฏผ่ˆช๏ผŒๅ›ž่ฝฆ้€‰ๆ‹ฉ\nEsc ๆˆ–็‚นๅ‡ปไปปๆ„ๅœฐๆ–นๅ–ๆถˆ", + "Bluetooth": "่“็‰™", + "Brightness": "ไบฎๅบฆ", + "Cancel": "ๅ–ๆถˆ", + "Chain of Thought": "ๆ€็ปด้“พ", + "Cheat sheet": "ๅฟซๆท้”ฎ่กจ", + "Choose model": "้€‰ๆ‹ฉๆจกๅž‹", + "Clean stuff | Excellent quality, no NSFW": "ๆธ…ๆดๅ†…ๅฎน | ไผ˜็ง€่ดจ้‡๏ผŒๆ—  NSFW", + "Clear": "ๆธ…้™ค", + "Clear chat history": "ๆธ…้™ค่Šๅคฉ่ฎฐๅฝ•", + "Clear the current list of images": "ๆธ…้™คๅฝ“ๅ‰ๅ›พ็‰‡ๅˆ—่กจ", + "Close": "ๅ…ณ้—ญ", + "Copy": "ๅคๅˆถ", + "Copy code": "ๅคๅˆถไปฃ็ ", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "ๅฝ“ๅ‰ API ็ซฏ็‚น๏ผš%1\nไฝฟ็”จ %2mode PROVIDER ่ฎพ็ฝฎ", + "Delete": "ๅˆ ้™ค", + "Desktop": "ๆกŒ้ข", + "Disable NSFW content": "็ฆ็”จ NSFW ๅ†…ๅฎน", + "Done": "ๅฎŒๆˆ", + "Download": "ไธ‹่ฝฝ", + "Edit": "็ผ–่พ‘", + "Enter text to translate...": "่พ“ๅ…ฅ่ฆ็ฟป่ฏ‘็š„ๆ–‡ๆœฌ...", + "Finished tasks will go here": "ๅทฒๅฎŒๆˆ็š„ไปปๅŠกๅฐ†ๆ˜พ็คบๅœจ่ฟ™้‡Œ", + "For desktop wallpapers | Good quality": "ๆกŒ้ขๅฃ็บธไธ“็”จ | ่ดจ้‡ๅฅฝ", + "For storing API keys and other sensitive information": "็”จไบŽๅญ˜ๅ‚จ API ๅฏ†้’ฅๅ’Œๅ…ถไป–ๆ•ๆ„Ÿไฟกๆฏ", + "Game mode": "ๆธธๆˆๆจกๅผ", + "Get the next page of results": "่Žทๅ–ไธ‹ไธ€้กต็ป“ๆžœ", + "Go to source (%1)": "่ฝฌๅˆฐๆบ (%1)", + "Hibernate": "ไผ‘็œ ", + "Input": "่พ“ๅ…ฅ", + "Intelligence": "ๆ™บ่ƒฝไฝ“", + "Interface": "็•Œ้ข", + "Invalid arguments. Must provide `key` and `value`.": "ๅ‚ๆ•ฐๆ— ๆ•ˆใ€‚ๅฟ…้กปๆไพ› `key` ๅ’Œ `value`ใ€‚", + "Jump to current month": "่ทณ่ฝฌๅˆฐๅฝ“ๅ‰ๆœˆไปฝ", + "Keep system awake": "ไฟๆŒ็ณป็ปŸๅ”ค้†’", + "Large images | God tier quality, no NSFW.": "ๅคงๅฐบๅฏธๅ›พ็‰‡ | ้กถ็บง่ดจ้‡๏ผŒๆ—  NSFW", + "Large language models": "ๅคง่ฏญ่จ€ๆจกๅž‹", + "Launch": "ๅฏๅŠจ", + "Local Ollama model | %1": "ๆœฌๅœฐ Ollama ๆจกๅž‹ | %1", + "Lock": "้”ๅฎš", + "Logout": "ๆณจ้”€", + "Markdown test": "Markdown ๆต‹่ฏ•", + "Math result": "ๆ•ฐๅญฆ็ป“ๆžœ", + "Night Light": "ๆŠค็œผๆจกๅผ", + "No API key set for %1": "ๆœชไธบ %1 ่ฎพ็ฝฎ API ๅฏ†้’ฅ", + "No audio source": "ๆ— ้Ÿณ้ข‘ๆบ", + "No media": "ๆ— ๅช’ไฝ“", + "No notifications": "ๆ— ้€š็Ÿฅ", + "Not visible to model": "ๅฏนๆจกๅž‹ไธๅฏ่ง", + "Nothing here!": "่ฟ™้‡Œไป€ไนˆ้ƒฝๆฒกๆœ‰๏ผ", + "Notifications": "้€š็Ÿฅ", + "OK": "็กฎๅฎš", + "Open file link": "ๆ‰“ๅผ€ๆ–‡ไปถ้“พๆŽฅ", + "Output": "่พ“ๅ‡บ", + "Page %1": "็ฌฌ %1 ้กต", + "Reboot": "้‡ๅฏ", + "Reboot to firmware settings": "้‡ๅฏๅˆฐๅ›บไปถ่ฎพ็ฝฎ", + "Reload Hyprland & Quickshell": "้‡ๆ–ฐๅŠ ่ฝฝ Hyprland ๅ’Œ Quickshell", + "Run": "่ฟ่กŒ", + "Run command": "่ฟ่กŒๅ‘ฝไปค", + "Save": "ไฟๅญ˜", + "Save to Downloads": "ไฟๅญ˜ๅˆฐไธ‹่ฝฝๆ–‡ไปถๅคน", + "Search": "ๆœ็ดข", + "Search the web": "ๅœจ็ฝ‘็ปœไธŠๆœ็ดข", + "Search, calculate or run": "ๆœ็ดขใ€่ฎก็ฎ—ๆˆ–่ฟ่กŒ", + "Select Language": "้€‰ๆ‹ฉ่ฏญ่จ€", + "Session": "ไผš่ฏ", + "Set API key": "่ฎพ็ฝฎ API ๅฏ†้’ฅ", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "่ฎพ็ฝฎๆจกๅž‹็š„ๆธฉๅบฆ๏ผˆ้šๆœบๆ€ง๏ผ‰ใ€‚Gemini ๆจกๅž‹่Œƒๅ›ดไธบ 0 ๅˆฐ 2๏ผŒๅ…ถไป–ๆจกๅž‹ไธบ 0 ๅˆฐ 1ใ€‚้ป˜่ฎคๅ€ผไธบ 0.5ใ€‚", + "Set the current API provider": "่ฎพ็ฝฎๅฝ“ๅ‰ API ๆไพ›ๅ•†", + "Shutdown": "ๅ…ณๆœบ", + "Silent": "้™้Ÿณ", + "Sleep": "็ก็œ ", + "System": "็ณป็ปŸ", + "Task Manager": "ไปปๅŠก็ฎก็†ๅ™จ", + "Task description": "ไปปๅŠกๆ่ฟฐ", + "Temperature must be between 0 and 2": "ๆธฉๅบฆๅฟ…้กปๅœจ 0 ๅˆฐ 2 ไน‹้—ด", + "Temperature set to %1": "ๆธฉๅบฆ่ฎพ็ฝฎไธบ %1", + "Temperature: %1": "ๆธฉๅบฆ๏ผš%1", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "ๆˆไบบๅ‘ | ๆ•ฐ้‡ๅทจๅคง๏ผŒๅคง้‡ NSFW๏ผŒ่ดจ้‡ๅ‚ๅทฎไธ้ฝ", + "The popular one | Best quantity, but quality can vary wildly": "ๆœ€ๅ—ๆฌข่ฟŽ | ๆ•ฐ้‡ๆœ€ๅคš๏ผŒไฝ†่ดจ้‡ๅ‚ๅทฎไธ้ฝ", + "Thinking": "ๆ€่€ƒไธญ", + "Translation goes here...": "็ฟป่ฏ‘็ป“ๆžœไผšๆ˜พ็คบๅœจ่ฟ™้‡Œ...", + "Translator": "็ฟป่ฏ‘ๅ™จ", + "Unfinished": "ๆœชๅฎŒๆˆ", + "Unknown": "ๆœช็Ÿฅ", + "Unknown Album": "ๆœช็Ÿฅไธ“่พ‘", + "Unknown Artist": "ๆœช็Ÿฅ่‰บๆœฏๅฎถ", + "Unknown Title": "ๆœช็Ÿฅๆ ‡้ข˜", + "Unknown function call: %1": "ๆœช็Ÿฅๅ‡ฝๆ•ฐ่ฐƒ็”จ๏ผš%1", + "Uptime: %1": "่ฟ่กŒๆ—ถ้—ด๏ผš%1", + "View Markdown source": "ๆŸฅ็œ‹ Markdown ๆบ็ ", + "Volume": "้Ÿณ้‡", + "Volume mixer": "้Ÿณ้‡ๆททๅˆๅ™จ", + "Waifus only | Excellent quality, limited quantity": "ไป…้™่ง’่‰ฒ | ไผ˜็ง€่ดจ้‡๏ผŒๆ•ฐ้‡ๆœ‰้™", + "Waiting for response...": "็ญ‰ๅพ…ๅ“ๅบ”...", + "Workspace": "ๅทฅไฝœๅŒบ", + "%1 Safe Storage": "%1 ๅฎ‰ๅ…จๅญ˜ๅ‚จ", + "%1 does not require an API key": "%1 ไธ้œ€่ฆ API ๅฏ†้’ฅ", + "%1 queries pending": "%1 ไธชๆŸฅ่ฏข็ญ‰ๅพ…ไธญ", + "%1 | Right-click to configure": "%1 | ๅณ้”ฎ็‚นๅ‡ป่ฟ›่กŒ้…็ฝฎ", + "Set with /mode PROVIDER": "ไฝฟ็”จ /mode PROVIDER ่ฎพ็ฝฎ", + "Invalid API provider. Supported: \n-": "ๆ— ๆ•ˆ็š„ API ๆไพ›ๅ•†ใ€‚ๆ”ฏๆŒ็š„๏ผš\n-", + "Unknown command:": "ๆœช็Ÿฅๅ‘ฝไปค๏ผš", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "่พ“ๅ…ฅ /key ๅผ€ๅง‹ไฝฟ็”จๅœจ็บฟๆจกๅž‹\nCtrl+O ๅฑ•ๅผ€ไพง่พนๆ \nCtrl+P ๅฐ†ไพง่พนๆ ๅˆ†็ฆปไธบ็ช—ๅฃ", + "The current API used. Endpoint:": "ๅฝ“ๅ‰ไฝฟ็”จ็š„ APIใ€‚็ซฏ็‚น๏ผš", + "Provider set to": "ๆไพ›ๅ•†่ฎพ็ฝฎไธบ", + "Invalid model. Supported: \n```": "ๆ— ๆ•ˆๆจกๅž‹ใ€‚ๆ”ฏๆŒ็š„๏ผš\n```", + "Switched to search mode. Continue with the user's request.": "ๅทฒๅˆ‡ๆขๅˆฐๆœ็ดขๆจกๅผใ€‚็ปง็ปญๅค„็†็”จๆˆท่ฏทๆฑ‚ใ€‚", + "Experimental | Online | Google's model\nCan do a little more but doesn't search quickly": "ๅฎž้ชŒๆ€ง | ๅœจ็บฟ | Google ๆจกๅž‹\nๅŠŸ่ƒฝๆ›ดๅคšไฝ†ๆœ็ดข้€Ÿๅบฆ่พƒๆ…ข", + "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "่ฆ่ฎพ็ฝฎ API ๅฏ†้’ฅ๏ผŒ่ฏทๅฐ†ๅ…ถไธŽๅ‘ฝไปคไธ€่ตทไผ ้€’\n\n่ฆๆŸฅ็œ‹ๅฏ†้’ฅ๏ผŒ่ฏทๅฐ† \"get\" ไธŽๅ‘ฝไปคไธ€่ตทไผ ้€’
\n\n### ๅฏนไบŽ %1๏ผš\n\n**้“พๆŽฅ**๏ผš%2\n\n%3", + "Enter tags, or \"%1\" for commands": "่พ“ๅ…ฅๆ ‡็ญพ๏ผŒๆˆ– \"%1\" ๆŸฅ็œ‹ๅ‘ฝไปค", + "Online via %1 | %2's model": "้€š่ฟ‡ %1 ๅœจ็บฟ | %2 ็š„ๆจกๅž‹", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "ๆฒกๆœ‰ๆ‰พๅˆฐ็ป“ๆžœใ€‚ๆ็คบ๏ผš\n- ๆฃ€ๆŸฅๆ‚จ็š„ๆ ‡็ญพๅ’Œ NSFW ่ฎพ็ฝฎ\n- ๅฆ‚ๆžœๆฒกๆœ‰ๆƒณๅˆฐๆ ‡็ญพ๏ผŒ่ฏท่พ“ๅ…ฅ้กต็ ", + "Online | Google's model\nGives up-to-date information with search.": "ๅœจ็บฟ | Google ๆจกๅž‹\n้€š่ฟ‡ๆœ็ดขๆไพ›ๆœ€ๆ–ฐไฟกๆฏใ€‚", + "Settings": "่ฎพ็ฝฎ", + "Save chat": "ไฟๅญ˜ๅฏน่ฏ", + "Load chat": "ๅŠ ่ฝฝๅฏน่ฏ", + "or": "ๆˆ–", + "Set the system prompt for the model.": "ไธบๆจกๅž‹่ฎพ็ฝฎ็ณป็ปŸๆ็คบใ€‚", + "To Do": "ๅพ…ๅŠž", + "Calendar": "ๆ—ฅๅކ", + "Advanced": "้ซ˜็บง", + "About": "ๅ…ณไบŽ", + "Services": "ๆœๅŠก", + "Style": "ๆ ทๅผ", + "Edit config": "็ผ–่พ‘้…็ฝฎ", + "Colors & Wallpaper": "้ขœ่‰ฒๅ’Œๅฃ็บธ", + "Light": "ๆต…่‰ฒ", + "Dark": "ๆทฑ่‰ฒ", + "Material palette": "้ขœ่‰ฒไธป้ข˜", + "Fidelity": "ไฟ็œŸๅบฆ", + "Fruit Salad": "ๆฐดๆžœๆฒ™ๆ‹‰", + "Alternatively use /dark, /light, /img in the launcher": "ๆˆ–่€…ๅœจๅฏๅŠจๅ™จไธญไฝฟ็”จ /darkใ€/lightใ€/img", + "Fake screen rounding": "ไผช้€ ๅฑๅน•ๅœ†่ง’", + "When not fullscreen": "้žๅ…จๅฑๆ—ถ", + "Choose file": "้€‰ๆ‹ฉๆ–‡ไปถ", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "้šๆœบ Konachan SFW ๅŠจๆผซๅฃ็บธ\nๅ›พ็‰‡ไฟๅญ˜ๅˆฐ ~/ๅ›พ็‰‡/Wallpapers", + "Be patient...": "่ฏท่€ๅฟƒ็ญ‰ๅพ…...", + "Decorations & Effects": "่ฃ…้ฅฐไธŽ็‰นๆ•ˆ", + "Tonal Spot": "่‰ฒ่ฐƒ็‚น", + "Shell windows": "Shell ็ช—ๅฃ", + "Auto": "่‡ชๅŠจ", + "Wallpaper": "ๅฃ็บธ", + "Content": "ๅ†…ๅฎน", + "Title bar": "ๆ ‡้ข˜ๆ ", + "Transparency": "้€ๆ˜Žๅบฆ", + "Expressive": "่กจ็ŽฐๅŠ›", + "Yes": "ๆ˜ฏ", + "Enable": "ๅฏ็”จ", + "Rainbow": "ๅฝฉ่™น", + "Might look ass. Unsupported.": "ๅฏ่ƒฝๆ•ˆๆžœๅพˆๅทฎใ€‚ไธๆ”ฏๆŒใ€‚", + "Monochrome": "ๅ•่‰ฒ", + "Random: Konachan": "้šๆœบ๏ผšKonachan", + "Center title": "ๆ ‡้ข˜ๅฑ…ไธญ", + "Neutral": "ไธญๆ€ง", + "Pick wallpaper image on your system": "ๅœจ็ณป็ปŸไธญ้€‰ๆ‹ฉๅฃ็บธๅ›พ็‰‡", + "No": "ๅฆ", + "AI": "AI", + "Local only": "ไป…ๆœฌๅœฐ", + "Policies": "็ญ–็•ฅ", + "Weeb": "ไบŒๆฌกๅ…ƒ", + "Closet": "้š่—", + "Bar style": "ๆกๆ ๆ ทๅผ", + "Show next time": "ไธ‹ๆฌกๆ˜พ็คบ", + "Usage": "็”จๆณ•", + "Plain rectangle": "็บฏ็Ÿฉๅฝข", + "Useless buttons": "ๆ— ็”จๆŒ‰้’ฎ", + "GitHub": "GitHub", + "Style & wallpaper": "ๆ ทๅผไธŽๅฃ็บธ", + "Configuration": "้…็ฝฎ", + "Change any time later with /dark, /light, /img in the launcher": "ไน‹ๅŽๅฏๅœจๅฏๅŠจๅ™จ็”จ /darkใ€/lightใ€/img ๆ›ดๆ”น", + "Keybinds": "ๅฟซๆท้”ฎ", + "Float": "ๆตฎๅŠจ", + "Hug": "่ดดๅˆ", + "Yooooo hi there": "ๅ“Ÿๅ—ฌ๏ผŒๆ‚จๅฅฝๅ‘€", + "illogical-impulse Welcome": "illogical-impulse ๆฌข่ฟŽ้กต", + "Info": "ไฟกๆฏ", + "Volume limit": "้Ÿณ้‡้™ๅˆถ", + "Prevents abrupt increments and restricts volume limit": "้˜ฒๆญข้ชคๅขžๅนถ้™ๅˆถ้Ÿณ้‡", + "Resources": "่ต„ๆบ", + "12h am/pm": "12ๅฐๆ—ถ ไธŠๅˆ/ไธ‹ๅˆ", + "Base URL": "ๅŸบ็ก€ URL", + "Audio": "ๅฃฐ้Ÿณ", + "Networking": "็ฝ‘็ปœ", + "Format": "ๆ ผๅผ", + "Time": "ๆ—ถ้—ด", + "Battery": "็”ตๆฑ ", + "Prefixes": "ๅ‰็ผ€", + "Emojis": "่กจๆƒ…็ฌฆๅท", + "Earbang protection": "้˜ฒ็ˆ†้ŸณไฟๆŠค", + "Automatically suspends the system when battery is low": "็”ตๆฑ ็”ต้‡ไฝŽๆ—ถ่‡ชๅŠจๆŒ‚่ตท็ณป็ปŸ", + "Automatic suspend": "่‡ชๅŠจๆŒ‚่ตท", + "Suspend at": "ๆŒ‚่ตท้˜ˆๅ€ผ", + "Max allowed increase": "ๆœ€ๅคงๅ…่ฎธๅขžๅน…", + "Web search": "็ฝ‘้กตๆœ็ดข", + "Polling interval (ms)": "่ฝฎ่ฏข้—ด้š”๏ผˆๆฏซ็ง’๏ผ‰", + "Clipboard": "ๅ‰ช่ดดๆฟ", + "Low warning": "ไฝŽ็”ต้‡่ญฆๅ‘Š", + "24h": "24ๅฐๆ—ถๅˆถ", + "Use Levenshtein distance-based algorithm instead of fuzzy": "ไฝฟ็”จ Levenshtein ่ท็ฆป็ฎ—ๆณ•ๆ›ฟไปฃๆจก็ณŠๅŒน้…", + "System prompt": "็ณป็ปŸๆ็คบ่ฏ", + "12h AM/PM": "12ๅฐๆ—ถ AM/PM", + "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)": "ๅฆ‚ๆžœไฝ ็ปๅธธๆ‰“้”™ๅญ—ๅฏ่ƒฝๆ›ดๅฅฝ็”จ๏ผŒไฝ†็ป“ๆžœๅฏ่ƒฝๅพˆๅฅ‡ๆ€ช๏ผŒๅนถไธ”ๅฏ่ƒฝๆ— ๆณ•ๅŒน้…็ผฉๅ†™๏ผˆๅฆ‚ \"GIMP\" ๅฏ่ƒฝๆœไธๅˆฐ็ป˜ๅ›พ็จ‹ๅบ๏ผ‰", + "Critical warning": "ไธด็•Œ่ญฆๅ‘Š", + "User agent (for services that require it)": "็”จๆˆทไปฃ็†๏ผˆ้ƒจๅˆ†ๆœๅŠก้œ€่ฆ๏ผ‰", + "Such regions 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.": "่ฟ™ไบ›ๅŒบๅŸŸๅฏ่ƒฝๆ˜ฏๅ›พ็‰‡ๆˆ–ๅฑๅน•ไธญๅ…ทๆœ‰ไธ€ๅฎšๅŒ…ๅฎนๆ€ง็š„้ƒจๅˆ†ใ€‚\nๅฏ่ƒฝๅนถไธๆ€ปๆ˜ฏๅ‡†็กฎใ€‚\n่ฟ™ๆ˜ฏ้€š่ฟ‡ๆœฌๅœฐ่ฟ่กŒ็š„ๅ›พๅƒๅค„็†็ฎ—ๆณ•ๅฎž็Žฐ็š„๏ผŒๆฒกๆœ‰ไฝฟ็”จ AIใ€‚", + "Note: turning off can hurt readability": "ๆณจๆ„๏ผšๅ…ณ้—ญๅŽๅฏ่ƒฝๅฝฑๅ“ๅฏ่ฏปๆ€ง", + "Workspaces shown": "ๆ˜พ็คบ็š„ๅทฅไฝœๅŒบๆ•ฐ", + "Dark/Light toggle": "ๆทฑๆต…่‰ฒๅˆ‡ๆข", + "Dock": "ๅœ้ ๆ ", + "Weather": "ๅคฉๆฐ”", + "Pinned on startup": "ๅฏๅŠจๆ—ถๅ›บๅฎš", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "ๆ็คบ๏ผš้š่—ๅ›พๆ ‡ๅนถๅง‹็ปˆๆ˜พ็คบๆ•ฐๅญ—ไปฅ่Žทๅพ—็ปๅ…ธไฝ“้ชŒ", + "Appearance": "ๅค–่ง‚", + "Always show numbers": "ๆ€ปๆ˜ฏๆ˜พ็คบๆ•ฐๅญ—", + "Buttons": "ๆŒ‰้’ฎ", + "Keyboard toggle": "้”ฎ็›˜ๅˆ‡ๆข", + "Scale (%)": "็ผฉๆ”พๆฏ”ไพ‹(%)", + "Overview": "ๆฆ‚่งˆ", + "Rows": "่กŒๆ•ฐ", + "Borderless": "ๆ— ่พนๆก†", + "Screenshot tool": "ๆˆชๅ›พๅทฅๅ…ท", + "Number show delay when pressing Super (ms)": "ๆŒ‰ไธ‹ Super ๆ—ถๆ•ฐๅญ—ๆ˜พ็คบๅปถ่ฟŸ(ms)", + "Timeout (ms)": "่ถ…ๆ—ถๆ—ถ้—ด(ms)", + "Show app icons": "ๆ˜พ็คบๅบ”็”จๅ›พๆ ‡", + "Workspaces": "ๅทฅไฝœๅŒบ", + "Columns": "ๅˆ—ๆ•ฐ", + "On-screen display": "ๅฑๅน•ๆ˜พ็คบ", + "Screen snip": "ๅฑๅน•ๆˆชๅ›พ", + "Mic toggle": "้บฆๅ…‹้ฃŽๅˆ‡ๆข", + "Hover to reveal": "ๆ‚ฌๅœๆ˜พ็คบ", + "Bar": "ๆกๆ ", + "Show background": "ๆ˜พ็คบ่ƒŒๆ™ฏ", + "Show regions of potential interest": "ๆ˜พ็คบๅฏ่ƒฝๆ„Ÿๅ…ด่ถฃ็š„ๅŒบๅŸŸ", + "Color picker": "ๅ–่‰ฒๅ™จ", + "Help & Support": "ๅธฎๅŠฉไธŽๆ”ฏๆŒ", + "Discussions": "่ฎจ่ฎบๅŒบ", + "Color generation": "้…่‰ฒ็”Ÿๆˆ", + "Dotfiles": "้…็ฝฎๆ–‡ไปถ", + "Distro": "ๅ‘่กŒ็‰ˆ", + "Privacy Policy": "้š็งๆ”ฟ็ญ–", + "Documentation": "ๆ–‡ๆกฃ", + "Shell & utilities theming must also be enabled": "ๅฟ…้กปๅŒๆ—ถๅฏ็”จ Shell ไธŽๅทฅๅ…ทไธป้ข˜", + "illogical-impulse": "illogical-impulse", + "Donate": "ๆๅŠฉ", + "Terminal": "็ปˆ็ซฏ", + "Shell & utilities": "Shell ไธŽๅทฅๅ…ท", + "Qt apps": "Qt ๅบ”็”จ", + "Report a Bug": "ๆŠฅๅ‘Š้—ฎ้ข˜", + "Issues": "้—ฎ้ข˜่ฟฝ่ธช", + "Drag or click a region โ€ข LMB: Copy โ€ข RMB: Edit": "ๆ‹–ๅŠจๆˆ–็‚นๅ‡ปไธ€ไธชๅŒบๅŸŸ โ€ข ้ผ ๆ ‡ๅทฆ้”ฎ๏ผšๅคๅˆถ โ€ข ้ผ ๆ ‡ๅณ้”ฎ๏ผš็ผ–่พ‘", + "Current model: %1\nSet it with %2model MODEL": "ๅฝ“ๅ‰ๆจกๅž‹๏ผš%1\nไฝฟ็”จ %2model MODEL ่ฎพ็ฝฎ", + "Message the model... \"%1\" for commands": "ไธŽๆจกๅž‹ๅฏน่ฏ... \"%1\" ๆŸฅ็œ‹ๅ‘ฝไปค", + "The current system prompt is\n\n---\n\n%1": "ๅฝ“ๅ‰็ณป็ปŸๆ็คบ่ฏไธบ\n\n---\n\n%1", + "Model set to %1": "ๆจกๅž‹ๅทฒ่ฎพ็ฝฎไธบ %1", + "Loaded the following system prompt\n\n---\n\n%1": "ๅทฒๅŠ ่ฝฝไปฅไธ‹็ณป็ปŸๆ็คบ่ฏ\n\n---\n\n%1", + "%1 notifications": "%1 ๆก้€š็Ÿฅ", + "Save chat to %1": "ไฟๅญ˜่Šๅคฉ่ฎฐๅฝ•ๅˆฐ %1", + "Load chat from %1": "ไปŽ %1 ๅŠ ่ฝฝ่Šๅคฉ่ฎฐๅฝ•", + "Load prompt from %1": "ไปŽ %1 ๅŠ ่ฝฝๆ็คบ่ฏ", + "Select output device": "้€‰ๆ‹ฉ่พ“ๅ‡บ่ฎพๅค‡", + "%1 โ€ข %2 tasks": "%1 โ€ข %2 ไธชไปปๅŠก", + "Online models disallowed\n\nControlled by `policies.ai` config option": "็ฆๆญขๅœจ็บฟๆจกๅž‹\n\n็”ฑ `policies.ai` ้…็ฝฎ้กนๆŽงๅˆถ", + "Select input device": "้€‰ๆ‹ฉ่พ“ๅ…ฅ่ฎพๅค‡", + "Low battery": "็”ต้‡ไฝŽ", + "Registration failed. Please inspect manually with the warp-cli command": "ๆณจๅ†Œๅคฑ่ดฅใ€‚่ฏทไฝฟ็”จ warp-cli ๅ‘ฝไปคๆ‰‹ๅŠจๆฃ€ๆŸฅ", + "Code saved to file": "ไปฃ็ ๅทฒไฟๅญ˜ๅˆฐๆ–‡ไปถ", + "Consider plugging in your device": "่ฏท่€ƒ่™‘่ฟžๆŽฅๆ‚จ็š„่ฎพๅค‡", + "Weather Service": "ๅคฉๆฐ”ๆœๅŠก", + "Please charge!\nAutomatic suspend triggers at %1": "่ฏทๅ……็”ต๏ผ\n่‡ชๅŠจๆŒ‚่ตทๅฐ†ๅœจ %1 ๆ—ถ่งฆๅ‘", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Cloudflare WARP": "Cloudflare WARP", + "Download complete": "ไธ‹่ฝฝๅฎŒๆˆ", + "Critically low battery": "็”ต้‡ๆžไฝŽ", + "Scroll to change brightness": "ๆปšๅŠจไปฅ่ฐƒ่Š‚ไบฎๅบฆ", + "Saved to %1": "ๅทฒไฟๅญ˜ๅˆฐ %1", + "Cannot find a GPS service. Using the fallback method instead.": "ๆ— ๆณ•ๆ‰พๅˆฐ GPS ๆœๅŠกใ€‚ๆญฃๅœจไฝฟ็”จๅค‡็”จๆ–นๆณ•ใ€‚", + "Elements": "ๅ…ƒ็ด ", + "Scroll to change volume": "ๆปšๅŠจไปฅ่ฐƒ่Š‚้Ÿณ้‡", + "Connection failed. Please inspect manually with the warp-cli command": "่ฟžๆŽฅๅคฑ่ดฅใ€‚่ฏทไฝฟ็”จ warp-cli ๅ‘ฝไปคๆ‰‹ๅŠจๆฃ€ๆŸฅ", + "UV Index": "็ดซๅค–็บฟๆŒ‡ๆ•ฐ", + "Pressure": "ๆฐ”ๅŽ‹", + "Visibility": "่ƒฝ่งๅบฆ", + "Sunrise": "ๆ—ฅๅ‡บ", + "Sunset": "ๆ—ฅ่ฝ", + "Humidity": "ๆนฟๅบฆ", + "Wind": "้ฃŽ", + "Precipitation": "้™ๆฐด้‡" +} diff --git a/configs/quickshell/welcome.qml b/configs/quickshell/welcome.qml new file mode 100644 index 0000000..df9b0d4 --- /dev/null +++ b/configs/quickshell/welcome.qml @@ -0,0 +1,343 @@ +//@ 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 +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ApplicationWindow { + id: root + property string firstRunFilePath: 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 + visible: true + onClosing: Qt.quit() + title: Translation.tr("illogical-impulse Welcome") + + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + } + + minimumWidth: 600 + minimumHeight: 400 + width: 800 + height: 650 + color: Appearance.m3colors.m3background + + Process { + id: konachanWallProc + property string status: "" + command: ["bash", "-c", Quickshell.shellPath("scripts/colors/random_konachan_wall.sh")] + stdout: SplitParser { + onRead: data => { + console.log(`Konachan wall proc output: ${data}`); + konachanWallProc.status = data.trim(); + } + } + } + + 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("Yooooo hi there") + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + text: Translation.tr("Show next time") + } + StyledSwitch { + id: showNextTimeSwitch + checked: root.showNextTime + scale: 0.6 + Layout.alignment: Qt.AlignVCenter + onCheckedChanged: { + if (checked) { + Quickshell.execDetached(["rm", root.firstRunFilePath]) + } else { + Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(root.firstRunFileContent)}' > '${StringUtils.shellSingleQuoteEscape(root.firstRunFilePath)}'`]) + } + } + } + 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 + + + ContentPage { + id: contentColumn + anchors.fill: parent + + ContentSection { + title: Translation.tr("Bar style") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + configOptionName: "bar.cornerStyle" + onSelected: (newValue) => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { displayName: Translation.tr("Hug"), value: 0 }, + { displayName: Translation.tr("Float"), value: 1 }, + { displayName: Translation.tr("Plain rectangle"), value: 2 } + ] + } + } + + ContentSection { + title: Translation.tr("Style & wallpaper") + + ButtonGroup { + Layout.fillWidth: true + LightDarkPreferenceButton { + dark: false + } + LightDarkPreferenceButton { + dark: true + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + RippleButtonWithIcon { + id: rndWallBtn + Layout.alignment: Qt.AlignHCenter + buttonRadius: Appearance.rounding.small + materialIcon: "wallpaper" + mainText: konachanWallProc.running ? Translation.tr("Be patient...") : Translation.tr("Random: Konachan") + onClicked: { + console.log(konachanWallProc.command.join(" ")) + konachanWallProc.running = true; + } + StyledToolTip { + content: Translation.tr("Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers") + } + } + RippleButtonWithIcon { + materialIcon: "wallpaper" + StyledToolTip { + content: 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" + } + } + } + } + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: Translation.tr("Change any time later with /dark, /light, /img in the launcher") + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + } + } + + ContentSection { + title: Translation.tr("Policies") + + ConfigRow { + ColumnLayout { // Weeb policy + ContentSubsectionLabel { + text: Translation.tr("Weeb") + } + ConfigSelectionArray { + currentValue: Config.options.policies.weeb + configOptionName: "policies.weeb" + onSelected: (newValue) => { + Config.options.policies.weeb = newValue; + } + options: [ + { displayName: Translation.tr("No"), value: 0 }, + { displayName: Translation.tr("Yes"), value: 1 }, + { displayName: Translation.tr("Closet"), value: 2 } + ] + } + } + + ColumnLayout { // AI policy + ContentSubsectionLabel { + text: Translation.tr("AI") + } + ConfigSelectionArray { + currentValue: Config.options.policies.ai + configOptionName: "policies.ai" + onSelected: (newValue) => { + Config.options.policies.ai = newValue; + } + options: [ + { displayName: Translation.tr("No"), value: 0 }, + { displayName: Translation.tr("Yes"), value: 1 }, + { displayName: Translation.tr("Local only"), value: 2 } + ] + } + } + } + } + + ContentSection { + title: Translation.tr("Info") + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "keyboard_alt" + onClicked: { + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "cheatsheet", "toggle"]) + } + mainContentComponent: Component { + RowLayout { + spacing: 10 + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: Translation.tr("Keybinds") + color: Appearance.colors.colOnSecondaryContainer + } + RowLayout { + spacing: 3 + KeyboardKey { + key: "๓ฐ–ณ" + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + key: "/" + } + } + } + } + } + + RippleButtonWithIcon { + materialIcon: "help" + mainText: Translation.tr("Usage") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/02usage/") + } + } + RippleButtonWithIcon { + materialIcon: "construction" + mainText: Translation.tr("Configuration") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/03config/") + } + } + } + } + + ContentSection { + title: Translation.tr("Useless buttons") + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + nerdIcon: "๓ฐŠค" + mainText: Translation.tr("GitHub") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland") + } + } + RippleButtonWithIcon { + materialIcon: "favorite" + mainText: "Funny number" + onClicked: { + Qt.openUrlExternally("https://github.com/sponsors/end-4") + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + } + } + } +} diff --git a/configs/scripts/demo-material-you.sh b/configs/scripts/demo-material-you.sh new file mode 100755 index 0000000..0d41d67 --- /dev/null +++ b/configs/scripts/demo-material-you.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Demo script for Material You theming system + +set -euo pipefail + +echo "๐ŸŽจ dots-hyprland Material You Theming Demo" +echo "==========================================" +echo + +# Check if system is configured +if [[ ! -f "$HOME/.config/home-manager/home.nix" ]]; then + echo "โŒ Home Manager not configured. Please set up dots-hyprland first." + exit 1 +fi + +# Check if wallpaper directory exists +WALLPAPER_DIR="$HOME/Pictures/Wallpapers" +if [[ ! -d "$WALLPAPER_DIR" ]]; then + echo "๐Ÿ“ Creating wallpaper directory: $WALLPAPER_DIR" + mkdir -p "$WALLPAPER_DIR" + echo " Please add some wallpapers to this directory!" +fi + +# Check for wallpapers +WALLPAPER_COUNT=$(find "$WALLPAPER_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \) | wc -l) +if [[ $WALLPAPER_COUNT -eq 0 ]]; then + echo "โš ๏ธ No wallpapers found in $WALLPAPER_DIR" + echo " Please add some wallpapers and run this script again." + exit 1 +fi + +echo "โœ… Found $WALLPAPER_COUNT wallpapers in $WALLPAPER_DIR" +echo + +# Show available commands +echo "๐Ÿ› ๏ธ Available Material You Commands:" +echo " theme-switch [directory] [mode] - Switch to random wallpaper and generate theme" +echo " theme-toggle - Toggle between light/dark mode" +echo " theme-generate [mode] - Generate theme from specific wallpaper" +echo + +# Demo workflow +echo "๐ŸŽฏ Demo Workflow:" +echo "1. Switch to a random wallpaper with dark theme:" +echo " $ theme-switch" +echo +echo "2. Toggle to light mode:" +echo " $ theme-toggle" +echo +echo "3. Generate theme from specific wallpaper:" +echo " $ theme-generate ~/Pictures/Wallpapers/my-wallpaper.jpg dark" +echo + +# Show what gets themed +echo "๐ŸŽจ What Gets Themed:" +echo " โœ… Hyprland (borders, backgrounds, shadows)" +echo " โœ… Fuzzel launcher (colors, selection)" +echo " โœ… Foot terminal (all colors)" +echo " โœ… GTK applications (buttons, headers, etc.)" +echo " โœ… Hyprlock screen (backgrounds, text)" +echo " โœ… Quickshell widgets (when implemented)" +echo + +# Show file locations +echo "๐Ÿ“‚ Generated Files:" +echo " ~/.local/share/dots-hyprland/generated/colors.json" +echo " ~/.config/hypr/colors.conf" +echo " ~/.config/fuzzel/fuzzel_theme.ini" +echo " ~/.config/foot/foot.ini" +echo " ~/.config/gtk-3.0/gtk.css" +echo " ~/.config/gtk-4.0/gtk.css" +echo " ~/.config/hypr/hyprlock.conf" +echo + +# Interactive demo +read -p "๐Ÿš€ Would you like to try switching to a random wallpaper now? (y/n): " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "๐ŸŽจ Switching to random wallpaper..." + + # Find a random wallpaper + RANDOM_WALLPAPER=$(find "$WALLPAPER_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \) | shuf -n 1) + + if [[ -n "$RANDOM_WALLPAPER" ]]; then + echo "๐Ÿ“ธ Selected: $(basename "$RANDOM_WALLPAPER")" + + # Check if theme commands are available + if command -v theme-switch >/dev/null 2>&1; then + theme-switch "$WALLPAPER_DIR" dark + echo "โœ… Theme applied! Check your desktop for the new colors." + else + echo "โš ๏ธ Theme commands not available. Please activate your Home Manager configuration first:" + echo " $ home-manager switch --flake .#example" + fi + else + echo "โŒ No wallpapers found" + fi +else + echo "๐Ÿ‘ Demo complete! Use the commands above to try Material You theming." +fi + +echo +echo "๐ŸŽ‰ Material You theming is ready to use!" diff --git a/configs/scripts/fuzzel-emoji.sh b/configs/scripts/fuzzel-emoji.sh new file mode 100755 index 0000000..2d1aa74 --- /dev/null +++ b/configs/scripts/fuzzel-emoji.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash +# Emoji picker using fuzzel +# Bound to SUPER + PERIOD in Hyprland + +set -euo pipefail + +# Emoji database - common emojis with descriptions +EMOJI_LIST="๐Ÿ˜€ grinning face +๐Ÿ˜ƒ grinning face with big eyes +๐Ÿ˜„ grinning face with smiling eyes +๐Ÿ˜ beaming face with smiling eyes +๐Ÿ˜† grinning squinting face +๐Ÿ˜… grinning face with sweat +๐Ÿคฃ rolling on the floor laughing +๐Ÿ˜‚ face with tears of joy +๐Ÿ™‚ slightly smiling face +๐Ÿ™ƒ upside down face +๐Ÿ˜‰ winking face +๐Ÿ˜Š smiling face with smiling eyes +๐Ÿ˜‡ smiling face with halo +๐Ÿฅฐ smiling face with hearts +๐Ÿ˜ smiling face with heart eyes +๐Ÿคฉ star struck +๐Ÿ˜˜ face blowing a kiss +๐Ÿ˜— kissing face +๐Ÿ˜š kissing face with closed eyes +๐Ÿ˜™ kissing face with smiling eyes +๐Ÿฅฒ smiling face with tear +๐Ÿ˜‹ face savoring food +๐Ÿ˜› face with tongue +๐Ÿ˜œ winking face with tongue +๐Ÿคช zany face +๐Ÿ˜ squinting face with tongue +๐Ÿค‘ money mouth face +๐Ÿค— hugging face +๐Ÿคญ face with hand over mouth +๐Ÿคซ shushing face +๐Ÿค” thinking face +๐Ÿค zipper mouth face +๐Ÿคจ face with raised eyebrow +๐Ÿ˜ neutral face +๐Ÿ˜‘ expressionless face +๐Ÿ˜ถ face without mouth +๐Ÿ˜ smirking face +๐Ÿ˜’ unamused face +๐Ÿ™„ face with rolling eyes +๐Ÿ˜ฌ grimacing face +๐Ÿคฅ lying face +๐Ÿ˜” pensive face +๐Ÿ˜ช sleepy face +๐Ÿคค drooling face +๐Ÿ˜ด sleeping face +๐Ÿ˜ท face with medical mask +๐Ÿค’ face with thermometer +๐Ÿค• face with head bandage +๐Ÿคข nauseated face +๐Ÿคฎ face vomiting +๐Ÿคง sneezing face +๐Ÿฅต hot face +๐Ÿฅถ cold face +๐Ÿฅด woozy face +๐Ÿ˜ต dizzy face +๐Ÿคฏ exploding head +๐Ÿค  cowboy hat face +๐Ÿฅณ partying face +๐Ÿฅธ disguised face +๐Ÿ˜Ž smiling face with sunglasses +๐Ÿค“ nerd face +๐Ÿง face with monocle +๐Ÿ˜• confused face +๐Ÿ˜Ÿ worried face +๐Ÿ™ slightly frowning face +๐Ÿ˜ฎ face with open mouth +๐Ÿ˜ฏ hushed face +๐Ÿ˜ฒ astonished face +๐Ÿ˜ณ flushed face +๐Ÿฅบ pleading face +๐Ÿ˜ฆ frowning face with open mouth +๐Ÿ˜ง anguished face +๐Ÿ˜จ fearful face +๐Ÿ˜ฐ anxious face with sweat +๐Ÿ˜ฅ sad but relieved face +๐Ÿ˜ข crying face +๐Ÿ˜ญ loudly crying face +๐Ÿ˜ฑ face screaming in fear +๐Ÿ˜– confounded face +๐Ÿ˜ฃ persevering face +๐Ÿ˜ž disappointed face +๐Ÿ˜“ downcast face with sweat +๐Ÿ˜ฉ weary face +๐Ÿ˜ซ tired face +๐Ÿฅฑ yawning face +๐Ÿ˜ค face with steam from nose +๐Ÿ˜ก pouting face +๐Ÿ˜  angry face +๐Ÿคฌ face with symbols on mouth +๐Ÿ˜ˆ smiling face with horns +๐Ÿ‘ฟ angry face with horns +๐Ÿ’€ skull +๐Ÿ’ฉ pile of poo +๐Ÿคก clown face +๐Ÿ‘น ogre +๐Ÿ‘บ goblin +๐Ÿ‘ป ghost +๐Ÿ‘ฝ alien +๐Ÿ‘พ alien monster +๐Ÿค– robot +๐Ÿ˜บ grinning cat +๐Ÿ˜ธ grinning cat with smiling eyes +๐Ÿ˜น cat with tears of joy +๐Ÿ˜ป smiling cat with heart eyes +๐Ÿ˜ผ cat with wry smile +๐Ÿ˜ฝ kissing cat +๐Ÿ™€ weary cat +๐Ÿ˜ฟ crying cat +๐Ÿ˜พ pouting cat +โค๏ธ red heart +๐Ÿงก orange heart +๐Ÿ’› yellow heart +๐Ÿ’š green heart +๐Ÿ’™ blue heart +๐Ÿ’œ purple heart +๐Ÿ–ค black heart +๐Ÿค white heart +๐ŸคŽ brown heart +๐Ÿ’” broken heart +โฃ๏ธ heavy heart exclamation +๐Ÿ’• two hearts +๐Ÿ’ž revolving hearts +๐Ÿ’“ beating heart +๐Ÿ’— growing heart +๐Ÿ’– sparkling heart +๐Ÿ’˜ heart with arrow +๐Ÿ’ heart with ribbon +๐Ÿ’Ÿ heart decoration +๐Ÿ‘ thumbs up +๐Ÿ‘Ž thumbs down +๐Ÿ‘Œ ok hand +โœŒ๏ธ victory hand +๐Ÿคž crossed fingers +๐ŸคŸ love you gesture +๐Ÿค˜ sign of the horns +๐Ÿค™ call me hand +๐Ÿ‘ˆ backhand index pointing left +๐Ÿ‘‰ backhand index pointing right +๐Ÿ‘† backhand index pointing up +๐Ÿ‘‡ backhand index pointing down +โ˜๏ธ index pointing up +โœ‹ raised hand +๐Ÿคš raised back of hand +๐Ÿ–๏ธ hand with fingers splayed +๐Ÿ–– vulcan salute +๐Ÿ‘‹ waving hand +๐Ÿค pinching hand +โœŠ raised fist +๐Ÿ‘Š oncoming fist +๐Ÿค› left facing fist +๐Ÿคœ right facing fist +๐Ÿ‘ clapping hands +๐Ÿ™Œ raising hands +๐Ÿ‘ open hands +๐Ÿคฒ palms up together +๐Ÿค handshake +๐Ÿ™ folded hands +๐Ÿ’ช flexed biceps +๐Ÿฆพ mechanical arm +๐Ÿฆฟ mechanical leg +๐Ÿฆต leg +๐Ÿฆถ foot +๐Ÿ‘‚ ear +๐Ÿฆป ear with hearing aid +๐Ÿ‘ƒ nose +๐Ÿง  brain +๐Ÿซ€ anatomical heart +๐Ÿซ lungs +๐Ÿฆท tooth +๐Ÿฆด bone +๐Ÿ‘€ eyes +๐Ÿ‘๏ธ eye +๐Ÿ‘… tongue +๐Ÿ‘„ mouth +๐Ÿ’‹ kiss mark +๐Ÿ”ฅ fire +๐Ÿ’ซ dizzy +โญ star +๐ŸŒŸ glowing star +โœจ sparkles +โšก high voltage +๐Ÿ’ฅ collision +๐Ÿ’ข anger symbol +๐Ÿ’ฆ sweat droplets +๐Ÿ’จ dashing away +๐Ÿ•ณ๏ธ hole +๐Ÿ’ฃ bomb +๐Ÿ’ค zzz +๐ŸŽ‰ party popper +๐ŸŽŠ confetti ball +๐ŸŽˆ balloon +๐ŸŽ wrapped gift +๐Ÿ† trophy +๐Ÿฅ‡ 1st place medal +๐Ÿฅˆ 2nd place medal +๐Ÿฅ‰ 3rd place medal +๐Ÿ… sports medal +๐ŸŽ–๏ธ military medal +๐ŸŽ—๏ธ reminder ribbon +๐ŸŽซ ticket +๐ŸŽŸ๏ธ admission tickets +๐ŸŽช circus tent +๐ŸŽญ performing arts +๐ŸŽจ artist palette +๐ŸŽฌ clapper board +๐ŸŽค microphone +๐ŸŽง headphone +๐ŸŽผ musical score +๐ŸŽต musical note +๐ŸŽถ musical notes +๐ŸŽน musical keyboard +๐Ÿฅ drum +๐ŸŽท saxophone +๐ŸŽบ trumpet +๐ŸŽธ guitar +๐Ÿช• banjo +๐ŸŽป violin +๐ŸŽฒ game die +โ™ ๏ธ spade suit +โ™ฅ๏ธ heart suit +โ™ฆ๏ธ diamond suit +โ™ฃ๏ธ club suit +๐Ÿƒ joker +๐Ÿ€„ mahjong red dragon +๐ŸŽด flower playing cards +๐ŸŽฏ direct hit +๐ŸŽณ bowling +๐ŸŽฎ video game +๐Ÿ•น๏ธ joystick +๐ŸŽฐ slot machine +๐Ÿงฉ puzzle piece +โ™Ÿ๏ธ chess pawn +๐ŸŽฑ pool 8 ball +๐Ÿช€ yo yo +๐Ÿช kite +๐Ÿ”ฎ crystal ball +๐Ÿช„ magic wand +๐Ÿงฟ nazar amulet +๐ŸŽŠ confetti ball +๐ŸŽ‰ party popper +๐ŸŽˆ balloon +๐ŸŽ wrapped gift +๐ŸŽ€ ribbon +๐ŸŽ—๏ธ reminder ribbon +๐ŸŽŸ๏ธ admission tickets +๐ŸŽซ ticket +๐Ÿ† trophy +๐Ÿ… sports medal +๐Ÿฅ‡ 1st place medal +๐Ÿฅˆ 2nd place medal +๐Ÿฅ‰ 3rd place medal +๐ŸŽ–๏ธ military medal +๐Ÿต๏ธ rosette +๐ŸŽ—๏ธ reminder ribbon +๐ŸŽซ ticket +๐ŸŽŸ๏ธ admission tickets +๐ŸŽช circus tent +๐Ÿคน person juggling +๐ŸŽญ performing arts +๐Ÿฉฐ ballet shoes +๐ŸŽจ artist palette +๐ŸŽฌ clapper board +๐ŸŽค microphone +๐ŸŽง headphone +๐ŸŽผ musical score +๐ŸŽต musical note +๐ŸŽถ musical notes +๐ŸŽน musical keyboard +๐Ÿฅ drum +๐Ÿช˜ long drum +๐ŸŽท saxophone +๐ŸŽบ trumpet +๐ŸŽธ guitar +๐Ÿช• banjo +๐ŸŽป violin +๐Ÿช— accordion +๐Ÿชˆ flute +๐Ÿ“ฑ mobile phone +๐Ÿ’ป laptop +๐Ÿ–ฅ๏ธ desktop computer +๐Ÿ–จ๏ธ printer +โŒจ๏ธ keyboard +๐Ÿ–ฑ๏ธ computer mouse +๐Ÿ–ฒ๏ธ trackball +๐Ÿ’ฝ computer disk +๐Ÿ’พ floppy disk +๐Ÿ’ฟ optical disk +๐Ÿ“€ dvd +๐Ÿงฎ abacus +๐ŸŽฅ movie camera +๐Ÿ“น video camera +๐Ÿ“ท camera +๐Ÿ“ธ camera with flash +๐Ÿ“ผ videocassette +๐Ÿ” magnifying glass tilted left +๐Ÿ”Ž magnifying glass tilted right +๐Ÿ•ฏ๏ธ candle +๐Ÿ’ก light bulb +๐Ÿ”ฆ flashlight +๐Ÿฎ red paper lantern +๐Ÿช” diya lamp +๐Ÿ“” notebook with decorative cover +๐Ÿ“• closed book +๐Ÿ“– open book +๐Ÿ“— green book +๐Ÿ“˜ blue book +๐Ÿ“™ orange book +๐Ÿ“š books +๐Ÿ““ notebook +๐Ÿ“’ ledger +๐Ÿ“ƒ page with curl +๐Ÿ“œ scroll +๐Ÿ“„ page facing up +๐Ÿ“ฐ newspaper +๐Ÿ—ž๏ธ rolled up newspaper +๐Ÿ“‘ bookmark tabs +๐Ÿ”– bookmark +๐Ÿท๏ธ label +๐Ÿ’ฐ money bag +๐Ÿช™ coin +๐Ÿ’ด yen banknote +๐Ÿ’ต dollar banknote +๐Ÿ’ถ euro banknote +๐Ÿ’ท pound banknote +๐Ÿ’ธ money with wings +๐Ÿ’ณ credit card +๐Ÿงพ receipt +๐Ÿ’Ž gem stone +โš–๏ธ balance scale +๐Ÿชœ ladder +๐Ÿงฐ toolbox +๐Ÿช› screwdriver +๐Ÿ”ง wrench +๐Ÿ”จ hammer +โ›๏ธ pick +๐Ÿ› ๏ธ hammer and wrench +โš’๏ธ hammer and pick +๐Ÿ’ฃ bomb +๐Ÿชƒ boomerang +๐Ÿน bow and arrow +๐Ÿ›ก๏ธ shield +๐Ÿชš carpentry saw +๐Ÿ”ช kitchen knife +๐Ÿ—ก๏ธ dagger +โš”๏ธ crossed swords +๐Ÿ”ซ water pistol +๐Ÿช„ magic wand +๐ŸŽฃ fishing pole +๐Ÿคฟ diving mask +๐ŸŽฟ skis +๐Ÿ›ท sled +๐ŸฅŒ curling stone +๐ŸŽฏ direct hit +๐Ÿช€ yo yo +๐Ÿช kite +๐ŸŽฑ pool 8 ball +๐Ÿ”ฎ crystal ball +๐Ÿชฉ mirror ball +๐Ÿงฟ nazar amulet +๐ŸŽŠ confetti ball +๐ŸŽ‰ party popper +๐ŸŽˆ balloon +๐ŸŽ wrapped gift +๐ŸŽ€ ribbon +๐ŸŽ—๏ธ reminder ribbon +๐ŸŽŸ๏ธ admission tickets +๐ŸŽซ ticket" + +# Create temporary file for emoji list +TEMP_FILE=$(mktemp) +echo "$EMOJI_LIST" > "$TEMP_FILE" + +# Use fuzzel to select emoji +SELECTED=$(cat "$TEMP_FILE" | fuzzel --dmenu --prompt="Emoji: " --width=50 --lines=15) + +# Clean up temp file +rm "$TEMP_FILE" + +# Extract just the emoji (first character) +if [[ -n "$SELECTED" ]]; then + EMOJI=$(echo "$SELECTED" | cut -d' ' -f1) + + # Copy to clipboard using wl-clipboard + echo -n "$EMOJI" | wl-copy + + # Send notification + notify-send "Emoji Copied" "$EMOJI copied to clipboard" --icon="face-smile" --timeout=2000 + + # Also type it directly (optional - can be disabled) + # wtype "$EMOJI" +fi diff --git a/configs/scripts/generate-colors.sh b/configs/scripts/generate-colors.sh new file mode 100755 index 0000000..6a0083f --- /dev/null +++ b/configs/scripts/generate-colors.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +# Color generation script for quickshell Material You theming +# Based on the original dots-hyprland color generation system + +WALLPAPER="${1:-$HOME/Backgrounds/love-is-love.jpg}" +CACHE_DIR="$HOME/.cache/dots-hyprland/colors" + +echo "๐ŸŽจ Generating Material You colors from: $WALLPAPER" + +# Create cache directory +mkdir -p "$CACHE_DIR" + +# Check if wallpaper exists +if [[ ! -f "$WALLPAPER" ]]; then + echo "โŒ Wallpaper not found: $WALLPAPER" + echo "Using default colors..." + + # Create basic color scheme as fallback + cat > "$CACHE_DIR/colors.json" << 'EOF' +{ + "colors": { + "primary": "#bb86fc", + "onPrimary": "#000000", + "secondary": "#03dac6", + "onSecondary": "#000000", + "surface": "#121212", + "onSurface": "#ffffff", + "background": "#121212", + "onBackground": "#ffffff", + "error": "#cf6679", + "onError": "#000000" + } +} +EOF + exit 0 +fi + +# Generate colors using matugen if available +if command -v matugen >/dev/null 2>&1; then + echo "Using matugen for color generation..." + matugen image "$WALLPAPER" \ + --mode dark \ + --type scheme-content \ + --contrast 0.0 \ + --json > "$CACHE_DIR/colors.json" + + if [[ $? -eq 0 ]]; then + echo "โœ… Colors generated successfully with matugen" + else + echo "โŒ matugen failed, using fallback colors" + fi +else + echo "โš ๏ธ matugen not found, using basic color extraction..." + + # Basic color extraction using imagemagick if available + if command -v convert >/dev/null 2>&1; then + # Extract dominant color + DOMINANT=$(convert "$WALLPAPER" -resize 1x1\! -format '%[pixel:u]' info:-) + echo "Dominant color: $DOMINANT" + + # Create basic color scheme + cat > "$CACHE_DIR/colors.json" << EOF +{ + "colors": { + "primary": "#bb86fc", + "onPrimary": "#000000", + "secondary": "#03dac6", + "onSecondary": "#000000", + "surface": "#1a1b26", + "onSurface": "#ffffff", + "background": "#1a1b26", + "onBackground": "#ffffff", + "error": "#cf6679", + "onError": "#000000" + } +} +EOF + else + echo "โŒ No color generation tools available" + exit 1 + fi +fi + +# Generate quickshell-specific color files +echo "Generating quickshell color configurations..." + +# Extract colors for quickshell +if [[ -f "$CACHE_DIR/colors.json" ]]; then + # Create quickshell color configuration + cat > "$CACHE_DIR/quickshell-colors.qml" << 'EOF' +// Generated Material You colors for quickshell +pragma Singleton +import QtQuick + +QtObject { + // Material You color scheme + readonly property string primary: "#bb86fc" + readonly property string onPrimary: "#000000" + readonly property string secondary: "#03dac6" + readonly property string onSecondary: "#000000" + readonly property string surface: "#1a1b26" + readonly property string onSurface: "#ffffff" + readonly property string background: "#1a1b26" + readonly property string onBackground: "#ffffff" + readonly property string error: "#cf6679" + readonly property string onError: "#000000" + + // Additional colors for UI elements + readonly property string accent: primary + readonly property string outline: "#444444" + readonly property string surfaceVariant: "#2a2b36" + readonly property string onSurfaceVariant: "#cccccc" +} +EOF + + echo "โœ… Quickshell colors generated" +fi + +# Update wallpaper if using swww +if command -v swww >/dev/null 2>&1; then + echo "Updating wallpaper with swww..." + swww img "$WALLPAPER" --transition-type fade --transition-duration 1 +fi + +echo "๐ŸŽจ Color generation complete!" +echo "Colors saved to: $CACHE_DIR/" diff --git a/configs/scripts/record.sh b/configs/scripts/record.sh new file mode 100755 index 0000000..7c3fc28 --- /dev/null +++ b/configs/scripts/record.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Screen recording script using wf-recorder +# Bound to SUPER + ALT + R in Hyprland + +set -euo pipefail + +# Configuration +RECORDINGS_DIR="$HOME/Videos/Recordings" +TEMP_DIR="/tmp/dots-hyprland-recording" +PID_FILE="$TEMP_DIR/recording.pid" +STATUS_FILE="$TEMP_DIR/recording.status" + +# Ensure directories exist +mkdir -p "$RECORDINGS_DIR" +mkdir -p "$TEMP_DIR" + +# Function to show notification +notify_user() { + local title="$1" + local message="$2" + local icon="${3:-video-display}" + local timeout="${4:-3000}" + + notify-send "$title" "$message" --icon="$icon" --timeout="$timeout" +} + +# Function to get current timestamp +get_timestamp() { + date +"%Y-%m-%d_%H-%M-%S" +} + +# Function to start recording +start_recording() { + local mode="$1" + local output_file="$RECORDINGS_DIR/recording_$(get_timestamp()).mp4" + + case "$mode" in + "fullscreen") + notify_user "Screen Recording" "Starting fullscreen recording..." "media-record" + wf-recorder -f "$output_file" & + ;; + "region") + notify_user "Screen Recording" "Select region to record..." "media-record" + # Use slurp to select region + local geometry + geometry=$(slurp 2>/dev/null) || { + notify_user "Recording Cancelled" "No region selected" "dialog-error" + return 1 + } + wf-recorder -g "$geometry" -f "$output_file" & + ;; + "window") + notify_user "Screen Recording" "Click on window to record..." "media-record" + # Get window geometry using hyprctl + local window_info + window_info=$(hyprctl activewindow -j 2>/dev/null) || { + notify_user "Recording Error" "Could not get window info" "dialog-error" + return 1 + } + + # Extract geometry from JSON + local x y width height + x=$(echo "$window_info" | jq -r '.at[0]') + y=$(echo "$window_info" | jq -r '.at[1]') + width=$(echo "$window_info" | jq -r '.size[0]') + height=$(echo "$window_info" | jq -r '.size[1]') + + wf-recorder -g "${x},${y} ${width}x${height}" -f "$output_file" & + ;; + *) + notify_user "Recording Error" "Invalid recording mode: $mode" "dialog-error" + return 1 + ;; + esac + + local pid=$! + echo "$pid" > "$PID_FILE" + echo "$output_file" > "$STATUS_FILE" + + # Wait a moment to ensure recording started + sleep 1 + + if kill -0 "$pid" 2>/dev/null; then + notify_user "Recording Started" "Recording to: $(basename "$output_file")" "media-record" 5000 + echo "Recording started with PID: $pid" + echo "Output file: $output_file" + else + notify_user "Recording Failed" "Could not start recording" "dialog-error" + rm -f "$PID_FILE" "$STATUS_FILE" + return 1 + fi +} + +# Function to stop recording +stop_recording() { + if [[ ! -f "$PID_FILE" ]]; then + notify_user "No Recording" "No active recording found" "dialog-information" + return 1 + fi + + local pid + pid=$(cat "$PID_FILE") + local output_file + output_file=$(cat "$STATUS_FILE" 2>/dev/null || echo "unknown") + + if kill -0 "$pid" 2>/dev/null; then + # Send SIGINT to gracefully stop recording + kill -INT "$pid" + + # Wait for process to finish + local count=0 + while kill -0 "$pid" 2>/dev/null && [[ $count -lt 10 ]]; do + sleep 0.5 + ((count++)) + done + + # Force kill if still running + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + + rm -f "$PID_FILE" "$STATUS_FILE" + + # Check if file was created and has content + if [[ -f "$output_file" ]] && [[ -s "$output_file" ]]; then + local file_size + file_size=$(du -h "$output_file" | cut -f1) + notify_user "Recording Stopped" "Saved: $(basename "$output_file") ($file_size)" "media-record" 5000 + + # Optionally open the recordings directory + # nautilus "$RECORDINGS_DIR" & + else + notify_user "Recording Error" "Recording file is empty or missing" "dialog-error" + fi + else + notify_user "Recording Error" "Recording process not found" "dialog-error" + rm -f "$PID_FILE" "$STATUS_FILE" + fi +} + +# Function to check recording status +check_status() { + if [[ -f "$PID_FILE" ]]; then + local pid + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + local output_file + output_file=$(cat "$STATUS_FILE" 2>/dev/null || echo "unknown") + notify_user "Recording Active" "Currently recording to: $(basename "$output_file")" "media-record" + return 0 + else + # Clean up stale files + rm -f "$PID_FILE" "$STATUS_FILE" + fi + fi + + notify_user "No Recording" "No active recording" "dialog-information" + return 1 +} + +# Function to show recording menu +show_menu() { + local choice + choice=$(echo -e "๐Ÿ”ด Start Fullscreen Recording\n๐Ÿ“ฑ Start Region Recording\n๐ŸชŸ Start Window Recording\nโน๏ธ Stop Recording\n๐Ÿ“Š Check Status\n๐Ÿ“ Open Recordings Folder" | \ + fuzzel --dmenu --prompt="Recording: " --width=40 --lines=6) + + case "$choice" in + "๐Ÿ”ด Start Fullscreen Recording") + start_recording "fullscreen" + ;; + "๐Ÿ“ฑ Start Region Recording") + start_recording "region" + ;; + "๐ŸชŸ Start Window Recording") + start_recording "window" + ;; + "โน๏ธ Stop Recording") + stop_recording + ;; + "๐Ÿ“Š Check Status") + check_status + ;; + "๐Ÿ“ Open Recordings Folder") + nautilus "$RECORDINGS_DIR" & + ;; + "") + # User cancelled + exit 0 + ;; + *) + notify_user "Invalid Choice" "Unknown option selected" "dialog-error" + ;; + esac +} + +# Main logic +main() { + # Check if required tools are available + local missing_tools=() + + command -v wf-recorder >/dev/null || missing_tools+=("wf-recorder") + command -v slurp >/dev/null || missing_tools+=("slurp") + command -v jq >/dev/null || missing_tools+=("jq") + command -v notify-send >/dev/null || missing_tools+=("libnotify") + + if [[ ${#missing_tools[@]} -gt 0 ]]; then + echo "Error: Missing required tools: ${missing_tools[*]}" + notify_user "Recording Error" "Missing tools: ${missing_tools[*]}" "dialog-error" + exit 1 + fi + + # Parse command line arguments + case "${1:-menu}" in + "start"|"fullscreen") + start_recording "fullscreen" + ;; + "region") + start_recording "region" + ;; + "window") + start_recording "window" + ;; + "stop") + stop_recording + ;; + "status") + check_status + ;; + "toggle") + if check_status >/dev/null 2>&1; then + stop_recording + else + show_menu + fi + ;; + "menu"|*) + show_menu + ;; + esac +} + +# Run main function +main "$@" diff --git a/configs/scripts/test-phase4.sh b/configs/scripts/test-phase4.sh new file mode 100755 index 0000000..e75af1f --- /dev/null +++ b/configs/scripts/test-phase4.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash + +# Phase 4 Testing Script +# Tests all advanced features of dots-hyprland + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[$(date +'%H:%M:%S')] $1${NC}" +} + +warn() { + echo -e "${YELLOW}[$(date +'%H:%M:%S')] WARNING: $1${NC}" +} + +error() { + echo -e "${RED}[$(date +'%H:%M:%S')] ERROR: $1${NC}" +} + +info() { + echo -e "${BLUE}[$(date +'%H:%M:%S')] $1${NC}" +} + +phase4() { + echo -e "${PURPLE}[$(date +'%H:%M:%S')] PHASE 4: $1${NC}" +} + +# Test functions +test_build() { + log "Testing Phase 4 build..." + + if nix build "$PROJECT_ROOT#homeConfigurations.example.activationPackage" --no-link; then + log "โœ… Phase 4 build successful" + return 0 + else + error "โŒ Phase 4 build failed" + return 1 + fi +} + +test_ai_integration() { + phase4 "Testing AI Integration" + + # Check if Ollama is available + if command -v ollama >/dev/null 2>&1; then + log "โœ… Ollama available" + else + warn "โš ๏ธ Ollama not found - AI features may not work" + fi + + # Test AI configuration generation + if [[ -f "$PROJECT_ROOT/modules/components/ai.nix" ]]; then + log "โœ… AI module exists" + else + error "โŒ AI module missing" + return 1 + fi +} + +test_advanced_widgets() { + phase4 "Testing Advanced Widget System" + + local widgets=( + "overview/Overview.qml.template" + "sidebarLeft/SidebarLeft.qml.template" + "screenCorners/ScreenCorners.qml.template" + "session/SessionManager.qml.template" + ) + + for widget in "${widgets[@]}"; do + if [[ -f "$PROJECT_ROOT/configs/quickshell/ii/modules/$widget" ]]; then + log "โœ… Widget template exists: $widget" + else + error "โŒ Widget template missing: $widget" + return 1 + fi + done +} + +test_material_you_theming() { + phase4 "Testing Material You Theming System" + + if [[ -f "$PROJECT_ROOT/lib/material-colors.nix" ]]; then + log "โœ… Material You theming system exists" + else + error "โŒ Material You theming system missing" + return 1 + fi + + # Check if matugen is available + if nix shell nixpkgs#matugen -c matugen --version >/dev/null 2>&1; then + log "โœ… matugen available for color generation" + else + warn "โš ๏ธ matugen not available - theming may not work" + fi +} + +test_quality_of_life() { + phase4 "Testing Quality of Life Features" + + local features=( + "screenCorners/ScreenCorners.qml.template" + "session/SessionManager.qml.template" + ) + + for feature in "${features[@]}"; do + if [[ -f "$PROJECT_ROOT/configs/quickshell/ii/modules/$feature" ]]; then + log "โœ… QoL feature exists: $feature" + else + error "โŒ QoL feature missing: $feature" + return 1 + fi + done +} + +test_development_environment() { + phase4 "Testing Development Environment" + + log "Entering development environment..." + if nix develop "$PROJECT_ROOT" -c bash -c "echo 'Development environment working' && which quickshell" >/dev/null 2>&1; then + log "โœ… Development environment functional" + else + error "โŒ Development environment issues" + return 1 + fi +} + +show_phase4_status() { + phase4 "Phase 4 Implementation Status" + echo + echo "๐Ÿค– AI Integration:" + echo " - [x] AI module with Gemini & Ollama support" + echo " - [x] Configurable providers and features" + echo " - [x] Systemd service integration" + echo " - [x] AI chat interface scripts" + echo + echo "๐ŸŽจ Advanced Widget System:" + echo " - [x] Overview with window previews" + echo " - [x] AI-powered left sidebar" + echo " - [x] Interactive components" + echo " - [x] Search functionality" + echo + echo "๐ŸŒˆ Material You Theming:" + echo " - [x] Advanced color generation" + echo " - [x] Multi-application theming" + echo " - [x] Dynamic palette creation" + echo " - [x] Theme application system" + echo + echo "๐Ÿ–ฑ๏ธ Quality of Life:" + echo " - [x] Screen corner interactions" + echo " - [x] Session management" + echo " - [x] Brightness controls" + echo " - [x] Confirmation dialogs" + echo + echo "๐Ÿ“Š Overall Progress: Phase 4 Foundation Complete! ๐ŸŽ‰" +} + +# Main execution +main() { + echo "๐Ÿš€ Phase 4: Advanced Features Testing" + echo "======================================" + echo + + local failed_tests=() + + # Run all tests + if ! test_build; then + failed_tests+=("build") + fi + + if ! test_ai_integration; then + failed_tests+=("ai_integration") + fi + + if ! test_advanced_widgets; then + failed_tests+=("advanced_widgets") + fi + + if ! test_material_you_theming; then + failed_tests+=("material_you_theming") + fi + + if ! test_quality_of_life; then + failed_tests+=("quality_of_life") + fi + + if ! test_development_environment; then + failed_tests+=("development_environment") + fi + + echo + echo "======================================" + + # Show results + if [[ ${#failed_tests[@]} -eq 0 ]]; then + log "๐ŸŽ‰ All Phase 4 tests passed!" + show_phase4_status + echo + log "๐Ÿš€ Ready to enable advanced features!" + echo + echo "Next steps:" + echo "1. Enable AI integration: components.ai = true" + echo "2. Enable theming: components.theming = true" + echo "3. Enable advanced features: features.sidebar = true" + echo "4. Test in VM or real environment" + exit 0 + else + error "โŒ ${#failed_tests[@]} test(s) failed:" + printf '%s\n' "${failed_tests[@]}" + exit 1 + fi +} + +# Run tests +main "$@" diff --git a/configs/scripts/test-phase5.sh b/configs/scripts/test-phase5.sh new file mode 100755 index 0000000..2ac3444 --- /dev/null +++ b/configs/scripts/test-phase5.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash + +# Phase 5 Testing Script +# Tests NixOS-specific adaptations and integration patterns + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[$(date +'%H:%M:%S')] $1${NC}" +} + +warn() { + echo -e "${YELLOW}[$(date +'%H:%M:%S')] WARNING: $1${NC}" +} + +error() { + echo -e "${RED}[$(date +'%H:%M:%S')] ERROR: $1${NC}" +} + +info() { + echo -e "${BLUE}[$(date +'%H:%M:%S')] $1${NC}" +} + +phase5() { + echo -e "${CYAN}[$(date +'%H:%M:%S')] PHASE 5: $1${NC}" +} + +# Test functions +test_build() { + log "Testing Phase 5 build..." + + if nix build "$PROJECT_ROOT#homeConfigurations.example.activationPackage" --no-link; then + log "โœ… Phase 5 build successful" + return 0 + else + error "โŒ Phase 5 build failed" + return 1 + fi +} + +test_nixos_system_integration() { + phase5 "Testing NixOS System Integration" + + if [[ -f "$PROJECT_ROOT/modules/nixos-system.nix" ]]; then + log "โœ… NixOS system module exists" + + # Test module syntax + if nix-instantiate --parse "$PROJECT_ROOT/modules/nixos-system.nix" >/dev/null 2>&1; then + log "โœ… NixOS system module syntax valid" + else + error "โŒ NixOS system module syntax invalid" + return 1 + fi + else + error "โŒ NixOS system module missing" + return 1 + fi + + # Check for key NixOS integration features + local features=( + "hardware support" + "display manager" + "security integration" + "network configuration" + "virtualization support" + "development tools" + ) + + for feature in "${features[@]}"; do + if grep -q "$(echo "$feature" | tr ' ' '.')" "$PROJECT_ROOT/modules/nixos-system.nix"; then + log "โœ… NixOS integration includes: $feature" + else + warn "โš ๏ธ NixOS integration missing: $feature" + fi + done +} + +test_user_customization_system() { + phase5 "Testing User Customization System" + + if [[ -f "$PROJECT_ROOT/modules/components/customization.nix" ]]; then + log "โœ… User customization module exists" + + # Test module syntax + if nix-instantiate --parse "$PROJECT_ROOT/modules/components/customization.nix" >/dev/null 2>&1; then + log "โœ… User customization module syntax valid" + else + error "โŒ User customization module syntax invalid" + return 1 + fi + else + error "โŒ User customization module missing" + return 1 + fi + + # Check for customization features + local features=( + "customConfigs" + "customScripts" + "environmentVariables" + "hooks" + "templateOverrides" + "keybindOverrides" + "widgets" + ) + + for feature in "${features[@]}"; do + if grep -q "$feature" "$PROJECT_ROOT/modules/components/customization.nix"; then + log "โœ… Customization includes: $feature" + else + warn "โš ๏ธ Customization missing: $feature" + fi + done +} + +test_template_processing() { + phase5 "Testing Template Processing Framework" + + if [[ -f "$PROJECT_ROOT/lib/template-processor.nix" ]]; then + log "โœ… Template processor exists" + + # Test template processor syntax + if nix-instantiate --parse "$PROJECT_ROOT/lib/template-processor.nix" >/dev/null 2>&1; then + log "โœ… Template processor syntax valid" + else + error "โŒ Template processor syntax invalid" + return 1 + fi + else + error "โŒ Template processor missing" + return 1 + fi + + # Check for template processing features + local features=( + "processTemplate" + "generateConfigFromTemplate" + "applyColorScheme" + "createTemplateSystem" + "validateConfig" + "createHotReloader" + "processMultiFormat" + ) + + for feature in "${features[@]}"; do + if grep -q "$feature" "$PROJECT_ROOT/lib/template-processor.nix"; then + log "โœ… Template processing includes: $feature" + else + warn "โš ๏ธ Template processing missing: $feature" + fi + done +} + +test_service_management() { + phase5 "Testing Service Management Adaptation" + + if [[ -f "$PROJECT_ROOT/modules/components/service-management-simple.nix" ]]; then + log "โœ… Service management module exists" + + # Test module syntax + if nix-instantiate --parse "$PROJECT_ROOT/modules/components/service-management-simple.nix" >/dev/null 2>&1; then + log "โœ… Service management module syntax valid" + else + error "โŒ Service management module syntax invalid" + return 1 + fi + else + error "โŒ Service management module missing" + return 1 + fi + + # Check for service management features + local features=( + "session target" + "core services" + "quickshell" + "hypridle" + ) + + for feature in "${features[@]}"; do + if grep -q "$(echo "$feature" | tr ' ' '.')" "$PROJECT_ROOT/modules/components/service-management-simple.nix"; then + log "โœ… Service management includes: $feature" + else + warn "โš ๏ธ Service management missing: $feature" + fi + done +} + +test_arch_replacement() { + phase5 "Testing Arch-Specific Element Replacement" + + log "Checking for Arch-specific patterns..." + + # Check that we don't have Arch-specific commands + local arch_patterns=( + "pacman" + "yay" + "systemctl --user enable" + "cp -r .config" + "makepkg" + "PKGBUILD" + ) + + local found_arch=false + for pattern in "${arch_patterns[@]}"; do + if find "$PROJECT_ROOT" -name "*.nix" -exec grep -l "$pattern" {} \; 2>/dev/null | grep -v test; then + warn "โš ๏ธ Found Arch-specific pattern: $pattern" + found_arch=true + fi + done + + if [[ $found_arch == false ]]; then + log "โœ… No Arch-specific patterns found in Nix files" + fi + + # Check for NixOS patterns + local nixos_patterns=( + "xdg.configFile" + "home.packages" + "systemd.user.services" + "programs\." + "services\." + "mkEnableOption" + "mkOption" + ) + + local found_nixos=0 + for pattern in "${nixos_patterns[@]}"; do + if find "$PROJECT_ROOT" -name "*.nix" -exec grep -l "$pattern" {} \; 2>/dev/null | wc -l | grep -q "[1-9]"; then + log "โœ… Found NixOS pattern: $pattern" + ((found_nixos++)) + fi + done + + if [[ $found_nixos -ge 5 ]]; then + log "โœ… Strong NixOS integration patterns detected" + else + warn "โš ๏ธ Limited NixOS integration patterns found" + fi +} + +test_declarative_configuration() { + phase5 "Testing Declarative Configuration Management" + + # Check for declarative patterns + local declarative_features=( + "enable.*mkEnableOption" + "mkOption.*type.*types\." + "config.*mkIf.*cfg\." + "home\.packages.*with.*pkgs" + "systemd\.user\.services" + "xdg\.configFile" + ) + + local found_declarative=0 + for feature in "${declarative_features[@]}"; do + if find "$PROJECT_ROOT" -name "*.nix" -exec grep -l "$feature" {} \; 2>/dev/null | wc -l | grep -q "[1-9]"; then + log "โœ… Found declarative pattern: $feature" + ((found_declarative++)) + fi + done + + if [[ $found_declarative -ge 4 ]]; then + log "โœ… Strong declarative configuration patterns" + else + warn "โš ๏ธ Limited declarative configuration patterns" + fi +} + +test_development_environment() { + phase5 "Testing Development Environment" + + log "Testing development environment..." + if nix develop "$PROJECT_ROOT" -c bash -c "echo 'Development environment working' && which quickshell" >/dev/null 2>&1; then + log "โœ… Development environment functional" + else + error "โŒ Development environment issues" + return 1 + fi +} + +show_phase5_status() { + phase5 "Phase 5 Implementation Status" + echo + echo "๐Ÿ—๏ธ NixOS System Integration:" + echo " - [x] Comprehensive hardware support configuration" + echo " - [x] Display manager integration (GDM, SDDM, LightDM, Greetd)" + echo " - [x] Security framework (PolicyKit, AppArmor, Firewall)" + echo " - [x] Network configuration (NetworkManager, Wireless, VPN)" + echo " - [x] Virtualization support (Docker, Podman, libvirt)" + echo " - [x] Development tools with language support" + echo + echo "๐ŸŽจ User Customization System:" + echo " - [x] Custom configuration file overrides" + echo " - [x] Custom script management with dependencies" + echo " - [x] Environment variable customization" + echo " - [x] Hook system (pre/post-start, color-change, shutdown)" + echo " - [x] Template and keybind overrides" + echo " - [x] Widget configuration system" + echo + echo "๐Ÿ”ง Template Processing Framework:" + echo " - [x] Advanced template system with inheritance" + echo " - [x] Conditional template blocks" + echo " - [x] Multi-format processing (QML, JSON, CSS)" + echo " - [x] Color scheme integration" + echo " - [x] Hot-reloading system" + echo " - [x] Template validation and debugging" + echo + echo "โš™๏ธ Service Management Adaptation:" + echo " - [x] Declarative systemd user service management" + echo " - [x] Session target integration" + echo " - [x] Core and optional service configuration" + echo " - [x] Custom service definition system" + echo " - [x] Service monitoring and health checks" + echo " - [x] Service management CLI tools" + echo + echo "๐Ÿ”„ NixOS Pattern Replacements:" + echo " - [x] Arch package management โ†’ Declarative Nix" + echo " - [x] Manual service management โ†’ systemd user services" + echo " - [x] Direct file copying โ†’ xdg.configFile patterns" + echo " - [x] Imperative configuration โ†’ Declarative options" + echo " - [x] Manual customization โ†’ Structured override system" + echo + echo "๐Ÿ“Š Overall Progress: Phase 5 NixOS Adaptations Complete! ๐ŸŽ‰" +} + +# Main execution +main() { + echo "๐Ÿ”ง Phase 5: NixOS-Specific Adaptations Testing" + echo "==============================================" + echo + + local failed_tests=() + + # Run all tests + if ! test_build; then + failed_tests+=("build") + fi + + if ! test_nixos_system_integration; then + failed_tests+=("nixos_system_integration") + fi + + if ! test_user_customization_system; then + failed_tests+=("user_customization_system") + fi + + if ! test_template_processing; then + failed_tests+=("template_processing") + fi + + if ! test_service_management; then + failed_tests+=("service_management") + fi + + if ! test_arch_replacement; then + failed_tests+=("arch_replacement") + fi + + if ! test_declarative_configuration; then + failed_tests+=("declarative_configuration") + fi + + if ! test_development_environment; then + failed_tests+=("development_environment") + fi + + echo + echo "==============================================" + + # Show results + if [[ ${#failed_tests[@]} -eq 0 ]]; then + log "๐ŸŽ‰ All Phase 5 tests passed!" + show_phase5_status + echo + log "๐Ÿš€ Ready for Phase 6: Testing & Validation!" + echo + echo "Next steps:" + echo "1. Enable advanced components: AI, customization, service management" + echo "2. Test complete integration in VM environment" + echo "3. Validate user customization workflows" + echo "4. Proceed to comprehensive testing phase" + exit 0 + else + error "โŒ ${#failed_tests[@]} test(s) failed:" + printf '%s\n' "${failed_tests[@]}" + exit 1 + fi +} + +# Run tests +main "$@" diff --git a/configs/scripts/zoom.sh b/configs/scripts/zoom.sh new file mode 100755 index 0000000..bcfae48 --- /dev/null +++ b/configs/scripts/zoom.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# Screen magnification script for accessibility +# Bound to SUPER + ALT + = in Hyprland + +set -euo pipefail + +# Configuration +ZOOM_LEVELS=(1.0 1.25 1.5 1.75 2.0 2.5 3.0 4.0 5.0) +CONFIG_DIR="$HOME/.config/dots-hyprland" +STATE_FILE="$CONFIG_DIR/zoom_state" +TEMP_DIR="/tmp/dots-hyprland-zoom" + +# Ensure directories exist +mkdir -p "$CONFIG_DIR" +mkdir -p "$TEMP_DIR" + +# Function to show notification +notify_user() { + local title="$1" + local message="$2" + local icon="${3:-zoom-in}" + local timeout="${4:-2000}" + + notify-send "$title" "$message" --icon="$icon" --timeout="$timeout" +} + +# Function to get current zoom level +get_current_zoom() { + if [[ -f "$STATE_FILE" ]]; then + cat "$STATE_FILE" + else + echo "1.0" + fi +} + +# Function to save zoom level +save_zoom_level() { + local level="$1" + echo "$level" > "$STATE_FILE" +} + +# Function to find next zoom level +get_next_zoom_level() { + local current="$1" + local direction="${2:-up}" # up or down + + # Find current index + local current_index=-1 + for i in "${!ZOOM_LEVELS[@]}"; do + if [[ "${ZOOM_LEVELS[$i]}" == "$current" ]]; then + current_index=$i + break + fi + done + + # If current level not found, start from 1.0 + if [[ $current_index -eq -1 ]]; then + current_index=0 + fi + + # Calculate next index + local next_index + if [[ "$direction" == "up" ]]; then + next_index=$((current_index + 1)) + if [[ $next_index -ge ${#ZOOM_LEVELS[@]} ]]; then + next_index=$((${#ZOOM_LEVELS[@]} - 1)) + fi + else + next_index=$((current_index - 1)) + if [[ $next_index -lt 0 ]]; then + next_index=0 + fi + fi + + echo "${ZOOM_LEVELS[$next_index]}" +} + +# Function to apply zoom using Hyprland +apply_hyprland_zoom() { + local zoom_level="$1" + + # Use hyprctl to set zoom + if command -v hyprctl >/dev/null; then + # Get current monitor + local monitor + monitor=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .name' 2>/dev/null || echo "") + + if [[ -n "$monitor" ]]; then + # Apply zoom to the focused monitor + hyprctl keyword monitor "$monitor,preferred,auto,$zoom_level" 2>/dev/null || { + # Fallback: try to set global zoom + hyprctl keyword misc:cursor_zoom_factor "$zoom_level" 2>/dev/null || true + } + fi + fi +} + +# Function to apply zoom using wlr-randr (fallback) +apply_wlr_zoom() { + local zoom_level="$1" + + if command -v wlr-randr >/dev/null; then + # Get current output + local output + output=$(wlr-randr | grep -E "^[A-Z]" | head -n1 | cut -d' ' -f1) + + if [[ -n "$output" ]]; then + wlr-randr --output "$output" --scale "$zoom_level" 2>/dev/null || true + fi + fi +} + +# Function to set zoom level +set_zoom() { + local zoom_level="$1" + + # Validate zoom level + local valid=false + for level in "${ZOOM_LEVELS[@]}"; do + if [[ "$level" == "$zoom_level" ]]; then + valid=true + break + fi + done + + if [[ "$valid" != "true" ]]; then + notify_user "Zoom Error" "Invalid zoom level: $zoom_level" "dialog-error" + return 1 + fi + + # Apply zoom + apply_hyprland_zoom "$zoom_level" + + # Save state + save_zoom_level "$zoom_level" + + # Show notification + local percentage + percentage=$(echo "$zoom_level * 100" | bc -l | cut -d. -f1) + + if [[ "$zoom_level" == "1.0" ]]; then + notify_user "Zoom Reset" "Zoom level: ${percentage}% (Normal)" "zoom-original" + else + notify_user "Zoom Level" "Zoom level: ${percentage}%" "zoom-in" + fi +} + +# Function to zoom in +zoom_in() { + local current + current=$(get_current_zoom) + local next + next=$(get_next_zoom_level "$current" "up") + + if [[ "$next" == "$current" ]]; then + notify_user "Maximum Zoom" "Already at maximum zoom level" "zoom-in" + else + set_zoom "$next" + fi +} + +# Function to zoom out +zoom_out() { + local current + current=$(get_current_zoom) + local next + next=$(get_next_zoom_level "$current" "down") + + if [[ "$next" == "$current" ]]; then + notify_user "Minimum Zoom" "Already at minimum zoom level" "zoom-out" + else + set_zoom "$next" + fi +} + +# Function to reset zoom +reset_zoom() { + set_zoom "1.0" +} + +# Function to show zoom menu +show_menu() { + local current + current=$(get_current_zoom) + local current_percentage + current_percentage=$(echo "$current * 100" | bc -l | cut -d. -f1) + + # Build menu options + local menu_options="" + for level in "${ZOOM_LEVELS[@]}"; do + local percentage + percentage=$(echo "$level * 100" | bc -l | cut -d. -f1) + local marker="" + + if [[ "$level" == "$current" ]]; then + marker=" โœ“" + fi + + if [[ "$level" == "1.0" ]]; then + menu_options+="๐Ÿ” ${percentage}% (Normal)${marker}\n" + else + menu_options+="๐Ÿ” ${percentage}%${marker}\n" + fi + done + + # Add control options + menu_options+="\nโž• Zoom In\nโž– Zoom Out\n๐Ÿ”„ Reset Zoom\n๐Ÿ“Š Current: ${current_percentage}%" + + local choice + choice=$(echo -e "$menu_options" | fuzzel --dmenu --prompt="Zoom: " --width=30 --lines=12) + + case "$choice" in + *"Zoom In") + zoom_in + ;; + *"Zoom Out") + zoom_out + ;; + *"Reset Zoom") + reset_zoom + ;; + *"Current:"*) + # Just show current status + notify_user "Current Zoom" "Zoom level: ${current_percentage}%" "zoom-in" + ;; + ๐Ÿ”*) + # Extract percentage and convert to decimal + local selected_percentage + selected_percentage=$(echo "$choice" | grep -o '[0-9]\+%' | tr -d '%') + local selected_level + selected_level=$(echo "scale=2; $selected_percentage / 100" | bc -l) + set_zoom "$selected_level" + ;; + "") + # User cancelled + exit 0 + ;; + *) + notify_user "Invalid Choice" "Unknown option selected" "dialog-error" + ;; + esac +} + +# Function to toggle zoom (between 1.0 and 2.0) +toggle_zoom() { + local current + current=$(get_current_zoom) + + if [[ "$current" == "1.0" ]]; then + set_zoom "2.0" + else + set_zoom "1.0" + fi +} + +# Function to show current zoom status +show_status() { + local current + current=$(get_current_zoom) + local percentage + percentage=$(echo "$current * 100" | bc -l | cut -d. -f1) + + notify_user "Zoom Status" "Current zoom level: ${percentage}%" "zoom-in" 3000 +} + +# Main logic +main() { + # Check if required tools are available + local missing_tools=() + + command -v bc >/dev/null || missing_tools+=("bc") + command -v notify-send >/dev/null || missing_tools+=("libnotify") + + if [[ ${#missing_tools[@]} -gt 0 ]]; then + echo "Error: Missing required tools: ${missing_tools[*]}" + notify_user "Zoom Error" "Missing tools: ${missing_tools[*]}" "dialog-error" + exit 1 + fi + + # Parse command line arguments + case "${1:-menu}" in + "in"|"zoom-in"|"+") + zoom_in + ;; + "out"|"zoom-out"|"-") + zoom_out + ;; + "reset"|"normal"|"1") + reset_zoom + ;; + "toggle") + toggle_zoom + ;; + "status") + show_status + ;; + "set") + if [[ -n "${2:-}" ]]; then + set_zoom "$2" + else + echo "Usage: $0 set " + exit 1 + fi + ;; + "menu"|*) + show_menu + ;; + esac +} + +# Run main function +main "$@" diff --git a/configs/touchegg-quickshell.conf b/configs/touchegg-quickshell.conf new file mode 100644 index 0000000..253ff5a --- /dev/null +++ b/configs/touchegg-quickshell.conf @@ -0,0 +1,152 @@ + + + 150 + 20 + auto + auto + + + + + + true + F84A53 + F84A53 + + + + + + + + begin + + + + + + + + begin + + + + + + + hyprctl dispatch fullscreen 0 + false + NONE + begin + + + + + + + hyprctl dispatch fullscreen 1 + false + NONE + begin + + + + + + + quickshell -c "sidebarRight.visible = !sidebarRight.visible" + false + NONE + begin + + + + + + + quickshell -c "sidebarLeft.visible = !sidebarLeft.visible" + false + NONE + begin + + + + + + + quickshell -c "cheatsheet.visible = !cheatsheet.visible" + false + NONE + begin + + + + + + + quickshell -c "overview.visible = !overview.visible" + false + NONE + begin + + + + + + + + + true + Control_L + KP_Subtract + KP_Add + + + + + true + Control_L + KP_Add + KP_Subtract + + + + + + + + true + Control_L + KP_Subtract + KP_Add + + + + + true + Control_L + KP_Add + KP_Subtract + + + + + + + + true + Control_L + KP_Subtract + KP_Add + + + + + true + Control_L + KP_Add + KP_Subtract + + + + diff --git a/examples/gaming-config.nix b/examples/gaming-config.nix new file mode 100644 index 0000000..fc53100 --- /dev/null +++ b/examples/gaming-config.nix @@ -0,0 +1,75 @@ +# Gaming-optimized configuration for dots-hyprland +{ config, lib, pkgs, ... }: + +{ + programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "essential"; + mode = "declarative"; + + # ๐ŸŽฎ Gaming-optimized settings + quickshell = { + appearance = { + transparency = false; # Disable for performance + fakeScreenRounding = 0; # No rounding + }; + + bar = { + bottom = true; # Bottom bar for gaming + showBackground = false; # Minimal UI + verbose = false; # Less clutter + + utilButtons = { + showScreenSnip = false; + showColorPicker = false; + showMicToggle = true; # Useful for gaming + showKeyboardToggle = false; + showDarkModeToggle = false; + showPerformanceProfileToggle = true; # Gaming profiles + }; + + workspaces = { + shown = 3; # Only 3 workspaces for gaming + showAppIcons = false; # Minimal + alwaysShowNumbers = true; + }; + }; + + battery = { + low = 15; # Lower threshold for gaming + critical = 5; + automaticSuspend = false; # Never suspend while gaming + }; + + apps = { + terminal = "foot"; + taskManager = "htop"; # Lightweight task manager + }; + }; + + hyprland = { + general = { + gapsIn = 0; # No gaps for fullscreen gaming + gapsOut = 0; + borderSize = 1; # Minimal borders + allowTearing = true; # Enable for competitive gaming + }; + + decoration = { + rounding = 0; # No rounding for performance + blurEnabled = false; # Disable blur for performance + }; + + gestures = { + workspaceSwipe = false; # Disable gestures + }; + }; + + terminal = { + colors = { + alpha = 1.0; # No transparency for performance + }; + }; + }; +} diff --git a/examples/minimalist-config.nix b/examples/minimalist-config.nix new file mode 100644 index 0000000..0f80a59 --- /dev/null +++ b/examples/minimalist-config.nix @@ -0,0 +1,97 @@ +# Minimalist configuration for dots-hyprland +{ config, lib, pkgs, ... }: + +{ + programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "minimal"; + mode = "declarative"; + + # ๐ŸŽฏ Minimalist settings + quickshell = { + appearance = { + transparency = false; + fakeScreenRounding = 0; # No rounding + }; + + bar = { + bottom = false; + cornerStyle = 2; # Plain rectangle + borderless = true; # No grouping + showBackground = false; # No background + verbose = false; # Minimal info + + utilButtons = { + showScreenSnip = false; + showColorPicker = false; + showMicToggle = false; + showKeyboardToggle = false; + showDarkModeToggle = false; + showPerformanceProfileToggle = false; + }; + + workspaces = { + monochromeIcons = true; + shown = 5; # Only 5 workspaces + showAppIcons = false; # No app icons + alwaysShowNumbers = true; + }; + }; + + battery = { + low = 20; + critical = 5; + automaticSuspend = true; + suspend = 3; + }; + + apps = { + terminal = "foot"; + taskManager = "htop"; + }; + + time = { + format = "HH:mm"; # Simple 24-hour + dateFormat = "dd/MM"; # Minimal date + }; + }; + + hyprland = { + general = { + gapsIn = 2; # Minimal gaps + gapsOut = 4; + borderSize = 1; # Thin borders + allowTearing = false; + }; + + decoration = { + rounding = 0; # No rounding + blurEnabled = false; # No blur + }; + + gestures = { + workspaceSwipe = true; + }; + }; + + terminal = { + scrollback = { + lines = 500; # Smaller scrollback + }; + + cursor = { + style = "block"; # Simple block cursor + blink = false; + }; + + colors = { + alpha = 1.0; # No transparency + }; + + mouse = { + hideWhenTyping = true; # Clean interface + }; + }; + }; +} diff --git a/examples/productivity-config.nix b/examples/productivity-config.nix new file mode 100644 index 0000000..f42bfac --- /dev/null +++ b/examples/productivity-config.nix @@ -0,0 +1,93 @@ +# Productivity-focused configuration for dots-hyprland +{ config, lib, pkgs, ... }: + +{ + programs.dots-hyprland = { + enable = true; + source = ./configs; + packageSet = "essential"; + mode = "declarative"; + + # ๐Ÿ’ผ Productivity-optimized settings + quickshell = { + appearance = { + transparency = true; # Nice visual effects + fakeScreenRounding = 2; + }; + + bar = { + bottom = false; # Top bar for traditional feel + cornerStyle = 1; # Float style + showBackground = true; + verbose = true; # Show detailed info + + utilButtons = { + showScreenSnip = true; # Essential for productivity + showColorPicker = true; # Useful for design work + showMicToggle = true; # For meetings + showKeyboardToggle = true; + showDarkModeToggle = true; + showPerformanceProfileToggle = false; + }; + + workspaces = { + shown = 10; # Many workspaces for organization + showAppIcons = true; + alwaysShowNumbers = true; # Always show for quick navigation + showNumberDelay = 100; # Quick response + }; + }; + + battery = { + low = 25; # Higher threshold for work + critical = 10; + automaticSuspend = true; + suspend = 5; # Longer suspend delay + }; + + apps = { + terminal = "foot"; + taskManager = "plasma-systemmonitor --page-name Processes"; + }; + + time = { + format = "HH:mm:ss"; # 24-hour with seconds + dateFormat = "dddd, MMMM dd, yyyy"; # Full date + }; + }; + + hyprland = { + general = { + gapsIn = 6; # Comfortable gaps + gapsOut = 10; + borderSize = 2; + allowTearing = false; # Smooth visuals + }; + + decoration = { + rounding = 12; # Moderate rounding + blurEnabled = true; # Nice visual effects + }; + + gestures = { + workspaceSwipe = true; # Efficient navigation + }; + }; + + terminal = { + scrollback = { + lines = 10000; # Large scrollback for logs + multiplier = 3.0; + }; + + cursor = { + style = "beam"; + blink = true; # Visible cursor + }; + + colors = { + alpha = 0.90; # Slight transparency + }; + }; + }; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a6e69a1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,47 @@ +{ + "nodes": { + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772380461, + "narHash": "sha256-O3ukj3Bb3V0Tiy/4LUfLlBpWypJ9P0JeUgsKl2nmZZY=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "f140aa04d7d14f8a50ab27f3691b5766b17ae961", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1733940491, + "narHash": "sha256-X+N3DpXiFWAXkPge3LBVsk5SHUmLYkR//8uf37oOJ3Y=", + "owner": "nvmd", + "repo": "nixpkgs", + "rev": "431e58c722adba169c982afcfbe134edc271daab", + "type": "github" + }, + "original": { + "owner": "nvmd", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "home-manager": "home-manager", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a5ca54f --- /dev/null +++ b/flake.nix @@ -0,0 +1,245 @@ +{ + description = "NixOS adaptation of end-4's dots-hyprland - self-contained installer replication"; + + inputs = { + nixpkgs.url = "github:nvmd/nixpkgs"; + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, home-manager, ... }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + + pkgsFor = system: import nixpkgs { + inherit system; + overlays = [ self.overlays.default ]; + }; + in + { + overlays.default = final: prev: { + quickshell = prev.quickshell.overrideAttrs (old: { + buildInputs = (old.buildInputs or []) ++ [ final.qt6.qt5compat final.qt6.qtpositioning ]; + qtWrapperArgs = (old.qtWrapperArgs or []) ++ [ + "--prefix" "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${final.qt6.qt5compat}/lib/qt-6/qml" + "--prefix" "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${final.qt6.qtpositioning}/lib/qt-6/qml" + ]; + }); + + # Patch kde-material-you-colors for non-Plasma systems + kde-material-you-colors = (prev.python312Packages.kde-material-you-colors.overrideAttrs (old: { + pname = "kde-material-you-colors-patched"; + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ prev.makeWrapper ]; + + postInstall = (old.postInstall or "") + '' + # Patch konsole_utils.py to use exist_ok=True + substituteInPlace $out/lib/python*/site-packages/kde_material_you_colors/utils/konsole_utils.py \ + --replace-fail 'os.makedirs(settings.KONSOLE_DIR)' 'os.makedirs(settings.KONSOLE_DIR, exist_ok=True)' + + # Patch kwin_utils.py to skip KWin reload (crashes on non-KDE systems) + substituteInPlace $out/lib/python*/site-packages/kde_material_you_colors/utils/kwin_utils.py \ + --replace-fail 'def reload():' $'def reload():\n return # Skip on non-KDE' + + # Create stub plasma-apply-colorscheme + cat > $out/bin/plasma-apply-colorscheme << 'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x $out/bin/plasma-apply-colorscheme + + # Wrap to use our stub + wrapProgram $out/bin/kde-material-you-colors \ + --prefix PATH : $out/bin + ''; + })); + }; + + packages = forAllSystems (system: + let + pkgs = pkgsFor system; + utilityPackages = import ./packages { inherit pkgs; }; + in utilityPackages // { + default = utilityPackages.update-flake; + } + ); + + devShells = forAllSystems (system: + let + pkgs = pkgsFor system; + utilityPackages = import ./packages { inherit pkgs; }; + in { + default = pkgs.mkShell { + buildInputs = with pkgs; [ + nixpkgs-fmt + nil + git + jq + ] ++ (with utilityPackages; [ + update-flake + test-python-env + test-quickshell + compare-modes + ]); + + shellHook = builtins.readFile ./packages/scripts/dev-shell-hook.sh; + }; + } + ); + + homeManagerModules.default = import ./modules/home-manager.nix; + homeManagerModules.dots-hyprland = self.homeManagerModules.default; + + nixosModules.default = import ./modules/components/system-services.nix; + nixosModules.dots-hyprland = self.nixosModules.default; + + homeConfigurations = { + declarative = home-manager.lib.homeManagerConfiguration { + pkgs = pkgsFor "x86_64-linux"; + modules = [ + self.homeManagerModules.default + { + home.username = "celes"; + home.homeDirectory = "/home/celes"; + home.stateVersion = "24.05"; + + programs.dots-hyprland = { + enable = true; + source = ./configs; # Use local configs + packageSet = "essential"; + mode = "hybrid"; + + # ๐ŸŽจ Quickshell Configuration + quickshell = { + appearance = { + extraBackgroundTint = true; + fakeScreenRounding = 2; # When not fullscreen + transparency = false; + }; + + bar = { + bottom = false; # Top bar + cornerStyle = 0; # Hug style + topLeftIcon = "spark"; + showBackground = true; + verbose = true; + + utilButtons = { + showScreenSnip = true; + showColorPicker = true; # ๐ŸŽฏ Enable color picker! + showMicToggle = false; + showKeyboardToggle = true; + showDarkModeToggle = true; + showPerformanceProfileToggle = false; + }; + + workspaces = { + monochromeIcons = true; + shown = 10; + showAppIcons = true; + alwaysShowNumbers = false; + showNumberDelay = 300; + }; + }; + + battery = { + low = 20; + critical = 5; + automaticSuspend = true; + suspend = 3; + }; + + apps = { + terminal = "foot"; + bluetooth = "kcmshell6 kcm_bluetooth"; + network = "plasmawindowed org.kde.plasma.networkmanagement"; + taskManager = "plasma-systemmonitor --page-name Processes"; + }; + + time = { + format = "hh:mm"; + dateFormat = "ddd, dd/MM"; + }; + }; + + # ๐Ÿ–ฅ๏ธ Hyprland Configuration + hyprland = { + general = { + gapsIn = 4; + gapsOut = 7; + borderSize = 2; + allowTearing = false; + }; + + decoration = { + rounding = 16; + blurEnabled = true; + }; + + gestures = { + workspaceSwipe = true; + }; + + monitors = [ + # Add your monitor config here, e.g.: + # "eDP-1,1920x1080@60,0x0,1" + ]; + }; + + # ๐Ÿ–ฅ๏ธ Terminal Configuration + terminal = { + scrollback = { + lines = 1000; + multiplier = 3.0; + }; + + cursor = { + style = "beam"; + blink = false; + beamThickness = 1.5; + }; + + colors = { + alpha = 0.95; + }; + + mouse = { + hideWhenTyping = false; + alternateScrollMode = true; + }; + }; + }; + } + ]; + }; + + writable = home-manager.lib.homeManagerConfiguration { + pkgs = pkgsFor "x86_64-linux"; + modules = [ + self.homeManagerModules.default + { + home.username = "celes"; + home.homeDirectory = "/home/celes"; + home.stateVersion = "24.05"; + + programs.dots-hyprland = { + enable = true; + source = ./configs; # Use local configs + packageSet = "essential"; + mode = "writable"; + writable = { + stagingDir = ".configstaging"; + setupScript = "initialSetup.sh"; + backupExisting = true; + }; + }; + } + ]; + }; + + example = self.homeConfigurations.declarative; + }; + }; +} diff --git a/modules/components/config-override.nix b/modules/components/config-override.nix new file mode 100644 index 0000000..a86f1a1 --- /dev/null +++ b/modules/components/config-override.nix @@ -0,0 +1,130 @@ +# Configuration override system - allows complete manual control +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland; +in +{ + options.programs.dots-hyprland.overrides = { + # Complete file overrides - when set, completely replaces any generated config + hyprlandConf = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Complete hyprland.conf content. When set, completely overrides: + - Any rich hyprland.* configuration options + - Any copied hyprland.conf from source + - Generates the entire file from this content + ''; + example = '' + # Custom Hyprland configuration + general { + gaps_in = 10 + gaps_out = 20 + } + ''; + }; + + quickshellConfig = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Complete Config.qml content. When set, completely overrides: + - Any rich quickshell.* configuration options + - Any copied Config.qml from source + - Generates the entire file from this content + ''; + }; + + footConfig = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Complete foot.ini content. When set, completely overrides: + - Any rich terminal.* configuration options + - Any copied foot.ini from source + - Generates the entire file from this content + ''; + }; + + toucheggConf = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Complete touchegg.conf content. When set, completely overrides: + - Any copied touchegg.conf from source + - Generates the entire file from this content + ''; + }; + + # Directory-level overrides + hyprDirectory = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Complete hypr directory override. When set, copies entire directory + and ignores all hyprland configuration options. + ''; + }; + + quickshellDirectory = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Complete quickshell directory override. When set, copies entire directory + and ignores all quickshell configuration options. + ''; + }; + }; + + config = mkIf cfg.enable { + # Override warnings + warnings = + (optional (cfg.overrides.hyprlandConf != null && cfg.hyprland != {}) + "dots-hyprland: overrides.hyprlandConf is set, ignoring all hyprland.* options") ++ + (optional (cfg.overrides.quickshellConfig != null && cfg.quickshell != {}) + "dots-hyprland: overrides.quickshellConfig is set, ignoring all quickshell.* options") ++ + (optional (cfg.overrides.footConfig != null && cfg.terminal != {}) + "dots-hyprland: overrides.footConfig is set, ignoring all terminal.* options"); + + # File overrides take absolute priority + xdg.configFile = mkMerge [ + # Hyprland complete override + (mkIf (cfg.overrides.hyprlandConf != null) { + "hypr/hyprland.conf".text = cfg.overrides.hyprlandConf; + }) + + # Quickshell complete override + (mkIf (cfg.overrides.quickshellConfig != null) { + "quickshell/ii/modules/common/Config.qml".text = cfg.overrides.quickshellConfig; + }) + + # Terminal complete override + (mkIf (cfg.overrides.footConfig != null) { + "foot/foot.ini".text = cfg.overrides.footConfig; + }) + + # Touchegg complete override + (mkIf (cfg.overrides.toucheggConf != null) { + "touchegg/touchegg.conf".text = cfg.overrides.toucheggConf; + }) + + # Directory overrides + (mkIf (cfg.overrides.hyprDirectory != null) { + "hypr" = { + source = cfg.overrides.hyprDirectory; + recursive = true; + }; + }) + + (mkIf (cfg.overrides.quickshellDirectory != null) { + "quickshell" = { + source = cfg.overrides.quickshellDirectory; + recursive = true; + }; + }) + ]; + }; +} diff --git a/modules/components/hyprland-config.nix b/modules/components/hyprland-config.nix new file mode 100644 index 0000000..4d1e185 --- /dev/null +++ b/modules/components/hyprland-config.nix @@ -0,0 +1,138 @@ +# Hyprland configuration options for dots-hyprland +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.hyprland; +in +{ + options.programs.dots-hyprland.hyprland = { + # General settings + general = { + gapsIn = mkOption { + type = types.int; + default = 4; + description = "Inner gaps between windows"; + }; + + gapsOut = mkOption { + type = types.int; + default = 7; + description = "Outer gaps around windows"; + }; + + borderSize = mkOption { + type = types.int; + default = 2; + description = "Border width around windows"; + }; + + allowTearing = mkOption { + type = types.bool; + default = false; + description = "Allow screen tearing (useful for gaming)"; + }; + }; + + # Decoration settings + decoration = { + rounding = mkOption { + type = types.int; + default = 16; + description = "Corner rounding radius"; + }; + + blurEnabled = mkOption { + type = types.bool; + default = true; + description = "Enable background blur"; + }; + }; + + # Gesture settings + gestures = { + workspaceSwipe = mkOption { + type = types.bool; + default = true; + description = "Enable workspace swipe gestures"; + }; + }; + + # Monitor configuration + monitors = mkOption { + type = types.listOf types.str; + default = []; + description = "Monitor configuration strings"; + example = [ "eDP-1,1920x1080@60,0x0,1" ]; + }; + }; + + config = mkIf (config.programs.dots-hyprland.enable && config.programs.dots-hyprland.overrides.hyprlandConf == null) { + # Only generate if no manual override is set + xdg.configFile."hypr/general.conf".text = '' + # General Hyprland configuration for dots-hyprland (NixOS-managed) + + ${optionalString (cfg.monitors != []) '' + # Monitor configuration + ${concatMapStringsSep "\n" (monitor: "monitor=${monitor}") cfg.monitors} + ''} + + # Gestures (Hyprland 0.51+ syntax) + gestures { + gesture = 3, horizontal, workspace + } + + general { + # Gaps and border + gaps_in = ${toString cfg.general.gapsIn} + gaps_out = ${toString cfg.general.gapsOut} + gaps_workspaces = 50 + + border_size = ${toString cfg.general.borderSize} + col.active_border = rgba(cba6f7ff) + col.inactive_border = rgba(313244ff) + resize_on_border = true + + no_focus_fallback = true + + allow_tearing = ${boolToString cfg.general.allowTearing} + + snap { + enabled = true + } + } + + dwindle { + preserve_split = true + smart_split = false + smart_resizing = false + } + + decoration { + rounding = ${toString cfg.decoration.rounding} + + blur { + enabled = ${boolToString cfg.decoration.blurEnabled} + xray = true + special = false + new_optimizations = true + size = 14 + passes = 4 + brightness = 1 + noise = 0.01 + contrast = 1 + popups = true + popups_ignorealpha = 0.6 + } + + drop_shadow = true + shadow_ignore_window = true + shadow_offset = 0 2 + shadow_range = 20 + shadow_render_power = 3 + col.shadow = rgba(00000055) + } + ''; + }; +} diff --git a/modules/components/quickshell-config.nix b/modules/components/quickshell-config.nix new file mode 100644 index 0000000..8027025 --- /dev/null +++ b/modules/components/quickshell-config.nix @@ -0,0 +1,436 @@ +# Quickshell configuration options for dots-hyprland +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.quickshell; +in +{ + options.programs.dots-hyprland.quickshell = { + # Appearance settings + appearance = { + extraBackgroundTint = mkOption { + type = types.bool; + default = true; + description = "Enable extra background tint"; + }; + + fakeScreenRounding = mkOption { + type = types.enum [ 0 1 2 ]; + default = 2; + description = "Screen rounding mode: 0=None, 1=Always, 2=When not fullscreen"; + }; + + transparency = mkOption { + type = types.bool; + default = false; + description = "Enable transparency effects"; + }; + }; + + # Bar configuration + bar = { + bottom = mkOption { + type = types.bool; + default = false; + description = "Place bar at bottom instead of top"; + }; + + cornerStyle = mkOption { + type = types.enum [ 0 1 2 ]; + default = 0; + description = "Bar corner style: 0=Hug, 1=Float, 2=Plain rectangle"; + }; + + borderless = mkOption { + type = types.bool; + default = false; + description = "Remove grouping of bar items"; + }; + + topLeftIcon = mkOption { + type = types.enum [ "distro" "spark" ]; + default = "spark"; + description = "Icon to show in top-left of bar"; + }; + + showBackground = mkOption { + type = types.bool; + default = true; + description = "Show bar background"; + }; + + verbose = mkOption { + type = types.bool; + default = true; + description = "Show detailed information in bar"; + }; + + utilButtons = { + showScreenSnip = mkOption { + type = types.bool; + default = true; + description = "Show screen snip button"; + }; + + showColorPicker = mkOption { + type = types.bool; + default = false; + description = "Show color picker button"; + }; + + showMicToggle = mkOption { + type = types.bool; + default = false; + description = "Show microphone toggle button"; + }; + + showKeyboardToggle = mkOption { + type = types.bool; + default = true; + description = "Show keyboard layout toggle"; + }; + + showDarkModeToggle = mkOption { + type = types.bool; + default = true; + description = "Show dark mode toggle"; + }; + + showPerformanceProfileToggle = mkOption { + type = types.bool; + default = false; + description = "Show performance profile toggle"; + }; + }; + + workspaces = { + monochromeIcons = mkOption { + type = types.bool; + default = true; + description = "Use monochrome workspace icons"; + }; + + shown = mkOption { + type = types.int; + default = 10; + description = "Number of workspaces to show"; + }; + + showAppIcons = mkOption { + type = types.bool; + default = true; + description = "Show application icons in workspaces"; + }; + + alwaysShowNumbers = mkOption { + type = types.bool; + default = false; + description = "Always show workspace numbers"; + }; + + showNumberDelay = mkOption { + type = types.int; + default = 300; + description = "Delay before showing workspace numbers (milliseconds)"; + }; + }; + }; + + # Battery settings + battery = { + low = mkOption { + type = types.int; + default = 20; + description = "Low battery threshold (%)"; + }; + + critical = mkOption { + type = types.int; + default = 5; + description = "Critical battery threshold (%)"; + }; + + automaticSuspend = mkOption { + type = types.bool; + default = true; + description = "Enable automatic suspend on critical battery"; + }; + + suspend = mkOption { + type = types.int; + default = 3; + description = "Minutes before suspend on critical battery"; + }; + }; + + # Application settings + apps = { + terminal = mkOption { + type = types.str; + default = "kitty -1"; + description = "Terminal command for shell actions"; + }; + + bluetooth = mkOption { + type = types.str; + default = "kcmshell6 kcm_bluetooth"; + description = "Bluetooth settings command"; + }; + + network = mkOption { + type = types.str; + default = "plasmawindowed org.kde.plasma.networkmanagement"; + description = "Network settings command"; + }; + + taskManager = mkOption { + type = types.str; + default = "plasma-systemmonitor --page-name Processes"; + description = "Task manager command"; + }; + }; + + # Time format + time = { + format = mkOption { + type = types.str; + default = "hh:mm"; + description = "Time format string"; + }; + + dateFormat = mkOption { + type = types.str; + default = "ddd, dd/MM"; + description = "Date format string"; + }; + }; + }; + + config = mkIf (config.programs.dots-hyprland.enable && + config.programs.dots-hyprland.overrides.quickshellConfig == null && + !(config.programs.dots-hyprland.configuration.enable or false)) { + # Only generate if no manual override is set AND configuration copying is disabled + xdg.configFile."quickshell/ii/modules/common/Config.qml".text = '' + pragma Singleton + pragma ComponentBehavior: Bound + import QtQuick + import Quickshell + import Quickshell.Io + + Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: true // Always ready for NixOS-generated config + + function setNestedValue(nestedKey, value) { + // NixOS-managed config - values are set at build time + console.log("NixOS-managed configuration - ignoring runtime changes"); + } + + JsonAdapter { + id: configOptionsJsonAdapter + + property JsonObject policies: JsonObject { + property int ai: 1 + property int weeb: 1 + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a NixOS Linux system\n- Desktop environment: Hyprland + dots-hyprland\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response" + property string tool: "functions" + property list extraModels: [] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: ${boolToString cfg.appearance.extraBackgroundTint} + property int fakeScreenRounding: ${toString cfg.appearance.fakeScreenRounding} + property bool transparency: ${boolToString cfg.appearance.transparency} + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + } + property JsonObject palette: JsonObject { + property string type: "auto" + } + } + + property JsonObject audio: JsonObject { + property JsonObject protection: JsonObject { + property bool enable: true + property real maxAllowedIncrease: 10 + property real maxAllowed: 90 + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "${cfg.apps.bluetooth}" + property string network: "${cfg.apps.network}" + property string networkEthernet: "kcmshell6 kcm_networkmanagement" + property string taskManager: "${cfg.apps.taskManager}" + property string terminal: "${cfg.apps.terminal}" + } + + property JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property string wallpaperPath: "" + property string thumbnailPath: "" + property JsonObject parallax: JsonObject { + property bool enableWorkspace: true + property real workspaceZoom: 1.07 + property bool enableSidebar: true + } + } + + property JsonObject bar: JsonObject { + property bool bottom: ${boolToString cfg.bar.bottom} + property int cornerStyle: ${toString cfg.bar.cornerStyle} + property bool borderless: ${boolToString cfg.bar.borderless} + property string topLeftIcon: "${cfg.bar.topLeftIcon}" + property bool showBackground: ${boolToString cfg.bar.showBackground} + property bool verbose: ${boolToString cfg.bar.verbose} + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: false + } + property list screenList: [] + property JsonObject utilButtons: JsonObject { + property bool showScreenSnip: ${boolToString cfg.bar.utilButtons.showScreenSnip} + property bool showColorPicker: ${boolToString cfg.bar.utilButtons.showColorPicker} + property bool showMicToggle: ${boolToString cfg.bar.utilButtons.showMicToggle} + property bool showKeyboardToggle: ${boolToString cfg.bar.utilButtons.showKeyboardToggle} + property bool showDarkModeToggle: ${boolToString cfg.bar.utilButtons.showDarkModeToggle} + property bool showPerformanceProfileToggle: ${boolToString cfg.bar.utilButtons.showPerformanceProfileToggle} + } + property JsonObject tray: JsonObject { + property bool monochromeIcons: true + } + property JsonObject workspaces: JsonObject { + property bool monochromeIcons: ${boolToString cfg.bar.workspaces.monochromeIcons} + property int shown: ${toString cfg.bar.workspaces.shown} + property bool showAppIcons: ${boolToString cfg.bar.workspaces.showAppIcons} + property bool alwaysShowNumbers: ${boolToString cfg.bar.workspaces.alwaysShowNumbers} + property int showNumberDelay: ${toString cfg.bar.workspaces.showNumberDelay} + } + property JsonObject weather: JsonObject { + property bool enable: false + property bool enableGPS: true + property string city: "" + property bool useUSCS: false + property int fetchInterval: 10 + } + } + + property JsonObject battery: JsonObject { + property int low: ${toString cfg.battery.low} + property int critical: ${toString cfg.battery.critical} + property bool automaticSuspend: ${boolToString cfg.battery.automaticSuspend} + property int suspend: ${toString cfg.battery.suspend} + } + + 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 + property list pinnedApps: ["org.kde.dolphin", "kitty"] + property list ignoredAppRegexes: [] + } + + property JsonObject language: JsonObject { + property JsonObject translator: JsonObject { + property string engine: "auto" + property string targetLanguage: "auto" + property string sourceLanguage: "auto" + } + } + + property JsonObject light: JsonObject { + property JsonObject night: JsonObject { + property bool automatic: true + property string from: "19:00" + property string to: "06:30" + property int colorTemperature: 5000 + } + } + + 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 osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + property JsonObject overview: JsonObject { + property bool enable: true + property real scale: 0.18 + property real rows: 2 + property real columns: 5 + } + + property JsonObject resources: JsonObject { + property int updateInterval: 3000 + } + + property JsonObject search: JsonObject { + property int nonAppResultDelay: 30 + property string engineBaseUrl: "https://www.google.com/search?q=" + property list excludedSites: ["quora.com"] + property bool sloppy: false + property JsonObject prefix: JsonObject { + property string action: "/" + property string clipboard: ";" + property string emojis: ":" + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property int delay: 300 + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + } + + property JsonObject time: JsonObject { + property string format: "${cfg.time.format}" + property string dateFormat: "${cfg.time.dateFormat}" + } + + property JsonObject windows: JsonObject { + property bool showTitlebar: true + property bool centerTitle: true + } + + property JsonObject hacks: JsonObject { + property int arbitraryRaceConditionDelay: 20 + } + + property JsonObject screenshotTool: JsonObject { + property bool showContentRegions: true + } + } + } + ''; + }; +} diff --git a/modules/components/quickshell-service.nix b/modules/components/quickshell-service.nix new file mode 100644 index 0000000..eec0dc8 --- /dev/null +++ b/modules/components/quickshell-service.nix @@ -0,0 +1,248 @@ +# Quickshell service integration with staging system +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.quickshell; + mainCfg = config.programs.dots-hyprland; + + # Service startup script that handles initial setup + # Note: quickshell must be in home.packages + quickshellStartup = pkgs.writeShellScript "quickshell-startup" '' + #!/usr/bin/env bash + set -e + + # Colors for logging + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + RED='\033[0;31m' + NC='\033[0m' + + log() { + echo -e "''${GREEN}[quickshell-service]''${NC} $1" >&2 + } + + warn() { + echo -e "''${YELLOW}[quickshell-service]''${NC} WARNING: $1" >&2 + } + + error() { + echo -e "''${RED}[quickshell-service]''${NC} ERROR: $1" >&2 + } + + STAGING_DIR="$HOME/${mainCfg.writable-mode.stagingDir}" + CONFIG_DIR="$HOME/.config" + SETUP_SCRIPT="$HOME/${mainCfg.writable-mode.setupScript}" + SETUP_MARKER="$HOME/.cache/dots-hyprland/setup-complete" + + # Ensure cache directory exists + mkdir -p "$(dirname "$SETUP_MARKER")" + + # Check if initial setup has been run + if [[ ! -f "$SETUP_MARKER" ]]; then + log "๐Ÿš€ First run detected - running initial setup" + + # Check if staging directory exists + if [[ ! -d "$STAGING_DIR" ]]; then + error "Staging directory not found: $STAGING_DIR" + error "Please run 'home-manager switch' first" + exit 1 + fi + + # Check if setup script exists + if [[ ! -x "$SETUP_SCRIPT" ]]; then + error "Setup script not found or not executable: $SETUP_SCRIPT" + exit 1 + fi + + log "๐Ÿ“‹ Running initial setup script..." + if "$SETUP_SCRIPT"; then + # Mark setup as complete + echo "$(date)" > "$SETUP_MARKER" + log "โœ… Initial setup completed successfully" + else + error "โŒ Initial setup failed" + exit 1 + fi + else + log "โœ… Setup already completed ($(cat "$SETUP_MARKER"))" + fi + + # Verify quickshell configuration exists + if [[ ! -d "$CONFIG_DIR/quickshell" ]]; then + error "Quickshell configuration not found at $CONFIG_DIR/quickshell" + error "Initial setup may have failed" + exit 1 + fi + + # Set up environment variables + export ILLOGICAL_IMPULSE_VIRTUAL_ENV="$HOME/.local/state/quickshell/.venv" + export QT_SCALE_FACTOR="${toString cfg.scaling}" + export QT_QUICK_CONTROLS_STYLE="Basic" + export QT_QUICK_FLICKABLE_WHEEL_DECELERATION="10000" + + # Ensure PATH includes user applications - CRITICAL for app launching + export PATH="${config.home.profileDirectory}/bin:/run/wrappers/bin:${config.home.homeDirectory}/.nix-profile/bin:/etc/profiles/per-user/${config.home.username}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:$PATH" + export XDG_DATA_DIRS="${config.home.profileDirectory}/share:${config.home.homeDirectory}/.nix-profile/share:/etc/profiles/per-user/${config.home.username}/share:/nix/var/nix/profiles/default/share:/run/current-system/sw/share:$XDG_DATA_DIRS" + + # Create application launcher wrapper that quickshell can use + LAUNCHER_WRAPPER="$HOME/.cache/dots-hyprland/app-launcher" + mkdir -p "$(dirname "$LAUNCHER_WRAPPER")" + cat > "$LAUNCHER_WRAPPER" << 'EOF' +#!/usr/bin/env bash +# Application launcher wrapper for quickshell +# Ensures proper PATH and environment for launched applications + +# Use the same PATH that quickshell has +export PATH="${config.home.profileDirectory}/bin:/run/wrappers/bin:${config.home.homeDirectory}/.nix-profile/bin:/etc/profiles/per-user/${config.home.username}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin" +export XDG_DATA_DIRS="${config.home.profileDirectory}/share:${config.home.homeDirectory}/.nix-profile/share:/etc/profiles/per-user/${config.home.username}/share:/nix/var/nix/profiles/default/share:/run/current-system/sw/share" + +# Launch the application +exec "$@" +EOF + chmod +x "$LAUNCHER_WRAPPER" + + # Export the launcher wrapper path for quickshell to use + export DOTS_HYPRLAND_APP_LAUNCHER="$LAUNCHER_WRAPPER" + + # Verify Python virtual environment + if [[ ! -d "$ILLOGICAL_IMPULSE_VIRTUAL_ENV" ]]; then + warn "Python virtual environment not found at $ILLOGICAL_IMPULSE_VIRTUAL_ENV" + warn "Some features may not work correctly" + fi + + log "๐ŸŽฏ Starting quickshell with dots-hyprland configuration" + log "๐Ÿ“ Config: $CONFIG_DIR/quickshell/ii/shell.qml" + log "๐Ÿ Python venv: $ILLOGICAL_IMPULSE_VIRTUAL_ENV" + log "๐Ÿš€ App launcher: $LAUNCHER_WRAPPER" + + # Set up Qt environment for dots-hyprland + # Let quickshell use its own Qt libraries + export XDG_DATA_DIRS="$XDG_DATA_DIRS:${pkgs.gsettings-desktop-schemas}/share" + + # Start quickshell (from PATH - must be in home.packages) + exec quickshell -p "$CONFIG_DIR/quickshell/ii/shell.qml" + ''; + +in +{ + options.programs.dots-hyprland.quickshell = { + enable = mkEnableOption "Quickshell service with staging integration"; + + autoStart = mkEnableOption "Auto-start with Hyprland session" // { default = true; }; + + restartOnFailure = mkEnableOption "Restart service on failure" // { default = true; }; + + scaling = mkOption { + type = types.float; + default = 1.0; + description = "UI scaling factor"; + }; + + logLevel = mkOption { + type = types.enum [ "debug" "info" "warning" "error" ]; + default = "info"; + description = "Logging level for quickshell service"; + }; + }; + + config = mkIf cfg.enable { + # Install service management scripts (quickshell itself must be in home.packages) + home.packages = (with pkgs; [ + (writeShellScriptBin "quickshell-restart" '' + systemctl --user restart quickshell.service + echo "โœ… Quickshell service restarted" + '') + + (writeShellScriptBin "quickshell-status" '' + echo "๐Ÿ” Quickshell Service Status" + echo "==========================" + systemctl --user status quickshell.service --no-pager + echo "" + echo "๐Ÿ“‹ Recent logs:" + journalctl --user -u quickshell.service -n 10 --no-pager + '') + + (writeShellScriptBin "quickshell-logs" '' + echo "๐Ÿ“‹ Following quickshell logs (Ctrl+C to exit):" + journalctl --user -u quickshell.service -f + '') + + (writeShellScriptBin "quickshell-debug" '' + echo "๐Ÿ› Starting quickshell in debug mode..." + systemctl --user stop quickshell.service + QT_LOGGING_RULES="quickshell.*=true" ${quickshellStartup} + '') + ]); + + # Systemd user service for quickshell + systemd.user.services.quickshell = { + Unit = { + Description = "Quickshell - QtQuick based desktop shell with dots-hyprland"; + Documentation = [ "https://quickshell.org" "https://end-4.github.io/dots-hyprland-wiki/" ]; + PartOf = [ "hyprland-session.target" ]; + After = [ "hyprland-session.target" "graphical-session.target" ]; + Wants = [ "hyprland-session.target" ]; + }; + + Service = { + Type = "simple"; + ExecStart = quickshellStartup; + ExecReload = "${pkgs.coreutils}/bin/kill -SIGUSR2 $MAINPID"; + Restart = if cfg.restartOnFailure then "on-failure" else "no"; + RestartSec = 2; + TimeoutStartSec = 30; + TimeoutStopSec = 10; + + # Environment variables - include full user environment + PassEnvironment = [ "HYPRLAND_INSTANCE_SIGNATURE" "WAYLAND_DISPLAY" ]; + Environment = [ + "QT_SCALE_FACTOR=${toString cfg.scaling}" + "QT_QUICK_CONTROLS_STYLE=Basic" + "QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000" + "QT_LOGGING_RULES=${ + if cfg.logLevel == "debug" then "quickshell.*=true" + else if cfg.logLevel == "warning" then "*.warning=true" + else if cfg.logLevel == "error" then "*.critical=true" + else "*.info=true" + }" + # Include user's full PATH so applications can be launched + "PATH=${config.home.profileDirectory}/bin:/run/wrappers/bin:${config.home.homeDirectory}/.nix-profile/bin:/etc/profiles/per-user/${config.home.username}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin" + # Include XDG data directories for application discovery + "XDG_DATA_DIRS=${config.home.profileDirectory}/share:${config.home.homeDirectory}/.nix-profile/share:/etc/profiles/per-user/${config.home.username}/share:/nix/var/nix/profiles/default/share:/run/current-system/sw/share" + # Application launcher wrapper path + "DOTS_HYPRLAND_APP_LAUNCHER=%h/.cache/dots-hyprland/app-launcher" + ]; + + # Working directory + WorkingDirectory = "%h"; + + # Security settings + PrivateNetwork = false; + ProtectSystem = "strict"; + ProtectHome = false; # Need access to home directory + NoNewPrivileges = true; + + # Resource limits + MemoryMax = "2G"; + CPUQuota = "200%"; + }; + + Install = mkIf cfg.autoStart { + WantedBy = [ "hyprland-session.target" ]; + }; + }; + + # Create hyprland session target if it doesn't exist + systemd.user.targets.hyprland-session = { + Unit = { + Description = "Hyprland compositor session"; + Documentation = [ "man:systemd.special(7)" ]; + BindsTo = [ "graphical-session.target" ]; + Wants = [ "graphical-session-pre.target" ]; + After = [ "graphical-session-pre.target" ]; + }; + }; + }; +} diff --git a/modules/components/system-services.nix b/modules/components/system-services.nix new file mode 100644 index 0000000..b07b76d --- /dev/null +++ b/modules/components/system-services.nix @@ -0,0 +1,7 @@ +# System services required for dots-hyprland +{ config, lib, pkgs, ... }: + +{ + # UPower for battery monitoring in quickshell bar + services.upower.enable = lib.mkDefault true; +} diff --git a/modules/components/terminal-config.nix b/modules/components/terminal-config.nix new file mode 100644 index 0000000..40c4158 --- /dev/null +++ b/modules/components/terminal-config.nix @@ -0,0 +1,71 @@ +# Terminal configuration options for dots-hyprland +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.terminal; +in +{ + options.programs.dots-hyprland.terminal = { + # Terminal settings + scrollback = { + lines = mkOption { + type = types.int; + default = 1000; + description = "Number of scrollback lines"; + }; + + multiplier = mkOption { + type = types.float; + default = 3.0; + description = "Scrollback multiplier"; + }; + }; + + cursor = { + style = mkOption { + type = types.enum [ "block" "beam" "underline" ]; + default = "beam"; + description = "Cursor style"; + }; + + blink = mkOption { + type = types.bool; + default = false; + description = "Enable cursor blinking"; + }; + + beamThickness = mkOption { + type = types.float; + default = 1.5; + description = "Beam cursor thickness"; + }; + }; + + colors = { + alpha = mkOption { + type = types.float; + default = 0.95; + description = "Terminal transparency (0.0 - 1.0)"; + }; + }; + + mouse = { + hideWhenTyping = mkOption { + type = types.bool; + default = false; + description = "Hide mouse cursor when typing"; + }; + + alternateScrollMode = mkOption { + type = types.bool; + default = true; + description = "Enable alternate scroll mode"; + }; + }; + }; + + # Foot configuration disabled - let Quickshell transparency system handle it dynamically + config = {}; +} diff --git a/modules/components/touchegg.nix b/modules/components/touchegg.nix new file mode 100644 index 0000000..03fbe5e --- /dev/null +++ b/modules/components/touchegg.nix @@ -0,0 +1,271 @@ +# Touchegg gesture support for dots-hyprland +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.touchegg; + mainCfg = config.programs.dots-hyprland; +in +{ + options.programs.dots-hyprland.touchegg = { + enable = mkEnableOption "Touchegg gesture support"; + + config = mkOption { + type = types.lines; + default = '' + + + 150 + 80 + auto + auto + + + + + + true + F84A53 + F84A53 + + + + + + + + begin + + + + + + + + begin + + + + + + + hyprctl dispatch fullscreen 0 + false + NONE + begin + + + + + + + hyprctl dispatch fullscreen 1 + false + NONE + begin + + + + + + + + + hyprctl dispatch global quickshell:overviewToggle + false + NONE + begin + + + + + + + hyprctl dispatch overview + false + NONE + begin + + + + + + + hyprctl dispatch movewindow l + false + NONE + begin + + + + + + + hyprctl dispatch movewindow r + false + NONE + begin + + + + + + + hyprctl dispatch movewindow u + false + NONE + begin + + + + + + + hyprctl dispatch movewindow d + false + NONE + begin + + + + + + + + + true + Control_L + KP_Subtract + KP_Add + + + + + true + Control_L + KP_Add + KP_Subtract + + + + + + + + true + Control_L + KP_Subtract + KP_Add + + + + + true + Control_L + KP_Add + KP_Subtract + + + + + + + + true + Control_L + KP_Subtract + KP_Add + + + + + true + Control_L + KP_Add + KP_Subtract + + + + + ''; + description = "Touchegg configuration XML"; + }; + }; + + config = mkIf cfg.enable { + # Note: touchegg service needs to be enabled at system level + # Add this to your NixOS configuration: services.touchegg.enable = true; + + # Install touchegg configuration (both user and system locations) + xdg.configFile."touchegg/touchegg.conf" = { + text = cfg.config; + }; + + # Also create system config that touchegg service can read + # Note: This requires the touchegg service to be enabled at system level + home.activation.toucheggSystemConfig = lib.hm.dag.entryAfter ["writeBoundary"] '' + echo "๐Ÿ“„ Creating system-wide touchegg configuration..." + $DRY_RUN_CMD sudo mkdir -p /etc/touchegg + $DRY_RUN_CMD sudo cp ${config.xdg.configHome}/touchegg/touchegg.conf /etc/touchegg/touchegg.conf + echo "โœ… System touchegg config updated" + ''; + + # Create touchegg client service (required for gesture execution) + systemd.user.services.touchegg-client = { + Unit = { + Description = "Touchegg Client"; + After = [ "graphical-session.target" ]; + }; + + Service = { + Type = "simple"; + ExecStart = "${pkgs.touchegg}/bin/touchegg --client"; + Restart = "on-failure"; + RestartSec = 3; + }; + + Install = { + WantedBy = [ "default.target" ]; + }; + }; + + # Install touchegg and management scripts + home.packages = [ pkgs.touchegg ] ++ [ + (pkgs.writeShellScriptBin "touchegg-restart" '' + echo "๐Ÿ”„ Restarting touchegg service..." + sudo systemctl restart touchegg + echo "โœ… Touchegg restarted" + '') + + (pkgs.writeShellScriptBin "touchegg-status" '' + echo "๐Ÿ“Š Touchegg service status:" + systemctl status touchegg --no-pager + echo "" + echo "๐Ÿ“„ Touchegg configuration:" + echo " ~/.config/touchegg/touchegg.conf" + if [[ -f ~/.config/touchegg/touchegg.conf ]]; then + echo " โœ… Configuration file exists" + else + echo " โŒ Configuration file missing" + fi + '') + + (pkgs.writeShellScriptBin "touchegg-reload-config" '' + echo "๐Ÿ”„ Reloading touchegg configuration..." + if systemctl is-active touchegg >/dev/null 2>&1; then + sudo systemctl reload touchegg 2>/dev/null || sudo systemctl restart touchegg + echo "โœ… Touchegg configuration reloaded" + else + echo "โŒ Touchegg service is not running" + echo "๐Ÿ’ก Try: sudo systemctl start touchegg" + fi + '') + ]; + + # Session variables for touchegg + home.sessionVariables = { + TOUCHEGG_CONFIG_PATH = "$HOME/.config/touchegg/touchegg.conf"; + }; + }; +} diff --git a/modules/configuration.nix b/modules/configuration.nix new file mode 100644 index 0000000..3f99bc2 --- /dev/null +++ b/modules/configuration.nix @@ -0,0 +1,154 @@ +# Configuration management for dots-hyprland +# Replicates the installer's rsync behavior exactly +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.configuration; + mainCfg = config.programs.dots-hyprland; +in +{ + options.programs.dots-hyprland.configuration = { + enable = mkEnableOption "dots-hyprland configuration management"; + + source = mkOption { + type = types.path; + description = "Source path for dots-hyprland configuration"; + example = "inputs.dots-hyprland"; + }; + + copyMiscConfig = mkOption { + type = types.bool; + default = true; + description = "Copy miscellaneous config files (everything except fish and hypr)"; + }; + + copyFishConfig = mkOption { + type = types.bool; + default = true; + description = "Copy fish shell configuration"; + }; + + copyHyprlandConfig = mkOption { + type = types.bool; + default = true; + description = "Copy Hyprland configuration"; + }; + + # Individual application enable options + applications = { + foot = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable foot terminal configuration"; + }; + }; + + kitty = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable kitty terminal configuration"; + }; + }; + + fuzzel = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable fuzzel launcher configuration"; + }; + }; + }; + }; + + config = mkIf cfg.enable { + # Replicate installer's MISC config copying + # "for i in $(find .config/ -mindepth 1 -maxdepth 1 ! -name 'fish' ! -name 'hypr' -exec basename {} \;)" + xdg.configFile = mkMerge [ + # MISC configs (everything except fish and hypr) + (mkIf cfg.copyMiscConfig ( + let + # Get all directories in .config except fish, hypr, and quickshell (quickshell handled specially) + # Now with individual enable options + configDirs = lib.optionals cfg.applications.kitty.enable [ "kitty" ] ++ + lib.optionals cfg.applications.foot.enable [ "foot" ] ++ + lib.optionals cfg.applications.fuzzel.enable [ "fuzzel" ] ++ + [ "wlogout" ]; # Always enabled applications + + configFiles = listToAttrs (map (dir: { + name = dir; + value = { + source = "${cfg.source}/.config/${dir}"; + recursive = true; + }; + }) configDirs); + in + configFiles + )) + + # Fish configuration + (mkIf cfg.copyFishConfig { + "fish" = { + source = "${cfg.source}/.config/fish"; + recursive = true; + }; + }) + + # Hyprland configuration (special handling like installer) + (mkIf cfg.copyHyprlandConfig { + # Copy hypr directory excluding specific files + # rsync -av --delete --exclude '/custom' --exclude '/hyprlock.conf' --exclude '/hypridle.conf' --exclude '/hyprland.conf' + "hypr" = { + source = pkgs.runCommand "hypr-config-filtered" {} '' + mkdir -p $out + + # Copy everything from source hypr directory + cp -r ${cfg.source}/.config/hypr/* $out/ 2>/dev/null || true + + # Remove excluded files (replicating installer --exclude logic) + rm -rf $out/custom 2>/dev/null || true + rm -f $out/hyprlock.conf 2>/dev/null || true + rm -f $out/hypridle.conf 2>/dev/null || true + rm -f $out/hyprland.conf 2>/dev/null || true + + # Ensure we have the directory structure + mkdir -p $out + ''; + recursive = true; + }; + + # Copy the main config files separately (installer does this) + "hypr/hyprland.conf" = { + source = "${cfg.source}/.config/hypr/hyprland.conf"; + }; + "hypr/hypridle.conf" = { + source = "${cfg.source}/.config/hypr/hypridle.conf"; + }; + "hypr/hyprlock.conf" = { + source = "${cfg.source}/.config/hypr/hyprlock.conf"; + }; + }) + ]; + + # Copy .local/share files (replicating installer) + home.file = { + ".local/share/icons" = mkIf cfg.copyMiscConfig { + source = "${cfg.source}/.local/share/icons"; + recursive = true; + }; + + # konsole removed - managed by kde-material-you-colors + }; + + # Ensure XDG directories exist (installer creates these) + home.activation.createXdgDirs = lib.hm.dag.entryAfter ["writeBoundary"] '' + $DRY_RUN_CMD mkdir -p $HOME/.local/bin + $DRY_RUN_CMD mkdir -p $HOME/.cache + $DRY_RUN_CMD mkdir -p $HOME/.config + $DRY_RUN_CMD mkdir -p $HOME/.local/share + ''; + }; +} diff --git a/modules/home-manager.nix b/modules/home-manager.nix new file mode 100644 index 0000000..2bf945a --- /dev/null +++ b/modules/home-manager.nix @@ -0,0 +1,237 @@ +# Main Home Manager module for dots-hyprland +# Supports both declarative and writable modes +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland; + packages = import ../packages { inherit pkgs; }; +in +{ + imports = [ + ./python-environment.nix + ./configuration.nix + ./writable-mode.nix + ./components/quickshell-service.nix + ./components/quickshell-config.nix + ./components/hyprland-config.nix + ./components/terminal-config.nix + ./components/touchegg.nix + ./components/config-override.nix + ]; + + options.programs.dots-hyprland = { + enable = mkEnableOption "dots-hyprland desktop environment"; + + source = mkOption { + type = types.path; + description = "Source path for clean dots-hyprland configuration"; + example = "inputs.dots-hyprland"; + }; + + packageSet = mkOption { + type = types.enum [ "minimal" "essential" "all" ]; + default = "essential"; + description = "Which package set to install"; + }; + + mode = mkOption { + type = types.enum [ "declarative" "writable" "hybrid" ]; + default = "hybrid"; + description = '' + Configuration mode: + - hybrid: Hyprland declarative + Quickshell copied (recommended) + - declarative: Files managed by Home Manager (read-only) + - writable: Files staged to .configstaging, user copies and modifies + ''; + }; + + writable = mkOption { + type = types.submodule { + options = { + stagingDir = mkOption { + type = types.str; + default = ".configstaging"; + description = "Directory to stage configuration files"; + }; + + setupScript = mkOption { + type = types.str; + default = "initialSetup.sh"; + description = "Name of the setup script in ~/.local/bin/"; + }; + + backupExisting = mkOption { + type = types.bool; + default = true; + description = "Backup existing configuration files"; + }; + + symlinkMode = mkOption { + type = types.bool; + default = false; + description = "Create symlinks instead of copying files"; + }; + }; + }; + default = {}; + description = "Writable mode configuration"; + }; + }; + + config = mkIf cfg.enable { + # Enable Python environment for color generation + programs.dots-hyprland.python = { + enable = true; + autoSetup = true; + }; + + # Install packages based on selected set + home.packages = + let + packageSets = import ../packages/dots-hyprland-packages.nix { inherit lib pkgs; }; + in + if cfg.packageSet == "minimal" then packageSets.minimalPackages + else if cfg.packageSet == "essential" then packageSets.essentialPackages + else packageSets.allPackages; + + # Enable configuration management based on mode + programs.dots-hyprland.configuration = mkIf (cfg.mode == "declarative" || cfg.mode == "hybrid") { + enable = mkDefault (cfg.mode == "hybrid"); # Enable copying for hybrid mode + source = cfg.source; + # In hybrid mode, copy Quickshell but not Hyprland (use overrides instead) + copyMiscConfig = mkDefault (cfg.mode == "hybrid"); + copyFishConfig = mkDefault true; + copyHyprlandConfig = mkDefault (cfg.mode == "declarative"); # Only copy in pure declarative mode + }; + + # Enable writable mode + programs.dots-hyprland.writable-mode = mkIf (cfg.mode == "writable") { + enable = true; + source = cfg.source; + inherit (cfg.writable) stagingDir setupScript backupExisting symlinkMode; + }; + + # Enable quickshell service (works with both modes) + programs.dots-hyprland.quickshell = { + enable = true; + autoStart = true; + restartOnFailure = true; + logLevel = "info"; + }; + + # Enable touchegg gesture support + programs.dots-hyprland.touchegg = { + enable = true; + }; + + # Enable custom keybindings + + # Set critical environment variables (required for both modes) + home.sessionVariables = { + ILLOGICAL_IMPULSE_VIRTUAL_ENV = "$HOME/.local/state/quickshell/.venv"; + # Ensure GNOME schemas are available for gsettings + XDG_DATA_DIRS = "$XDG_DATA_DIRS:${pkgs.gsettings-desktop-schemas}/share"; + }; + + # Ensure ~/.local/bin is in PATH for user scripts + home.sessionPath = [ "$HOME/.local/bin" ]; + + # Generate qmldir files for all modes (runs after all config is in place) + home.activation.generateQmldirFiles = lib.hm.dag.entryAfter ["linkGeneration"] '' + if [[ -d "$HOME/.config/quickshell/ii" ]]; then + $DRY_RUN_CMD echo "๐Ÿ”ง Generating qmldir files with singleton detection..." + $DRY_RUN_CMD ${packages.generate-qmldir}/bin/generate-qmldir "$HOME/.config/quickshell/ii" + $DRY_RUN_CMD echo "โœ… qmldir files generated successfully for ${cfg.mode} mode" + else + $DRY_RUN_CMD echo "โš ๏ธ Warning: quickshell/ii directory not found, skipping qmldir generation" + fi + ''; + + # Use quickshell directly with proper environment + home.activation.createWorkingQsScript = lib.hm.dag.entryAfter ["linkGeneration"] '' + $DRY_RUN_CMD echo "โœ… Using quickshell directly (no wrapper script needed)" + + # Also install the quickshell reset script + $DRY_RUN_CMD echo "๐Ÿ”ง Installing quickshell reset script..." + $DRY_RUN_CMD cp "${packages.quickshell-reset}/bin/quickshell-reset.sh" "$HOME/.local/bin/" + $DRY_RUN_CMD chmod +x "$HOME/.local/bin/quickshell-reset.sh" + $DRY_RUN_CMD echo "โœ… Quickshell reset script installed successfully" + ''; + + # Custom activation script to copy quickshell configs (needed for relative imports) + home.activation.copyQuickshellConfigs = lib.hm.dag.entryBefore ["linkGeneration"] '' + $DRY_RUN_CMD echo "๐Ÿ”ง Setting up quickshell configuration for ${cfg.mode} mode..." + + # Remove any existing symlinked configs to avoid conflicts with system home-manager + if [[ -L "$HOME/.config/quickshell" ]]; then + $DRY_RUN_CMD rm "$HOME/.config/quickshell" + $DRY_RUN_CMD echo " โ†’ Removed conflicting symlinked quickshell config" + fi + + # Handle conflicting .local/share symlinks that may interfere with home-manager + if [[ -L "$HOME/.local/share/icons" ]]; then + $DRY_RUN_CMD rm "$HOME/.local/share/icons" + $DRY_RUN_CMD echo " โ†’ Removed conflicting symlinked icons directory" + fi + + if [[ -L "$HOME/.local/share/konsole" ]]; then + $DRY_RUN_CMD rm "$HOME/.local/share/konsole" + $DRY_RUN_CMD echo " โ†’ Removed conflicting symlinked konsole directory" + fi + + # Handle conflicting .config directories + if [[ -L "$HOME/.config/fish" ]]; then + $DRY_RUN_CMD rm "$HOME/.config/fish" + $DRY_RUN_CMD echo " โ†’ Removed conflicting symlinked fish config" + fi + + if [[ -L "$HOME/.config/matugen" ]]; then + $DRY_RUN_CMD rm "$HOME/.config/matugen" + $DRY_RUN_CMD echo " โ†’ Removed conflicting symlinked matugen config" + fi + + # Also handle if they exist as regular directories + if [[ -d "$HOME/.local/share/konsole" && ! -L "$HOME/.local/share/konsole" ]]; then + $DRY_RUN_CMD mv "$HOME/.local/share/konsole" "$HOME/.local/share/konsole.backup-$(date +%Y%m%d-%H%M%S)" + $DRY_RUN_CMD echo " โ†’ Backed up existing konsole directory" + fi + + if [[ -d "$HOME/.config/fish" && ! -L "$HOME/.config/fish" ]]; then + $DRY_RUN_CMD mv "$HOME/.config/fish" "$HOME/.config/fish.backup-$(date +%Y%m%d-%H%M%S)" + $DRY_RUN_CMD echo " โ†’ Backed up existing fish config directory" + fi + ''; + + # Copy quickshell config after link generation + home.activation.setupQuickshellConfig = lib.hm.dag.entryAfter ["linkGeneration"] '' + ${optionalString (cfg.mode == "hybrid") '' + # Copy quickshell config to enable relative imports + if [[ ! -d "$HOME/.config/quickshell" ]] || [[ -L "$HOME/.config/quickshell" ]]; then + $DRY_RUN_CMD mkdir -p "$HOME/.config" + $DRY_RUN_CMD cp -r "${cfg.source}/.config/quickshell" "$HOME/.config/" + $DRY_RUN_CMD chmod -R u+w "$HOME/.config/quickshell" + $DRY_RUN_CMD echo "โœ… Quickshell configuration copied successfully" + else + $DRY_RUN_CMD echo "โœ… Quickshell configuration already exists" + # Always update critical scripts that need environment fixes + # Use the flake's own configs directory, not the GitHub source + if [ -f "${./../configs}/quickshell/ii/scripts/colors/switchwall.sh" ]; then + cp "${./../configs}/quickshell/ii/scripts/colors/switchwall.sh" "$HOME/.config/quickshell/ii/scripts/colors/switchwall.sh" + chmod +x "$HOME/.config/quickshell/ii/scripts/colors/switchwall.sh" + $DRY_RUN_CMD echo " โ†’ Updated switchwall.sh script" + fi + fi + + # Ensure quickshell uses the proper environment variables + $DRY_RUN_CMD mkdir -p "$HOME/.local/bin" + $DRY_RUN_CMD echo " โ†’ Ensuring ~/.local/bin is in PATH for hybrid mode" + ''} + ''; + + # Ensure XDG directories exist (installer requirement) + xdg.enable = true; + xdg.userDirs.enable = true; + }; +} diff --git a/modules/python-environment.nix b/modules/python-environment.nix new file mode 100644 index 0000000..abc5d13 --- /dev/null +++ b/modules/python-environment.nix @@ -0,0 +1,268 @@ +# Python Virtual Environment for dots-hyprland +# This replicates the installer's Python setup exactly +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.python; + mainCfg = config.programs.dots-hyprland; + + # Virtual environment setup script that replicates installer behavior + setupVenvScript = pkgs.writeShellScript "setup-dots-hyprland-venv" '' + #!/usr/bin/env bash + set -e + + VENV_PATH="$HOME/.local/state/quickshell/.venv" + + echo "๐Ÿ Setting up dots-hyprland Python virtual environment..." + echo "๐Ÿ“ Target: $VENV_PATH" + + # Create directory structure + mkdir -p "$(dirname "$VENV_PATH")" + + # Only create venv if it doesn't exist + if [[ ! -d "$VENV_PATH" ]]; then + echo "๐Ÿ—๏ธ Creating Python 3.12 virtual environment..." + ${pkgs.python312}/bin/python -m venv "$VENV_PATH" --prompt .venv + else + echo "โœ… Virtual environment already exists at $VENV_PATH" + fi + + # Set up proper library path for Python packages (64-bit only) + export LD_LIBRARY_PATH="${lib.makeLibraryPath (with pkgs; [ + stdenv.cc.cc.lib # provides libstdc++.so.6 + gcc-unwrapped.lib + glibc + zlib + libffi + openssl + bzip2 + xz.out + ncurses + readline + sqlite + ])}" + + # Clear Python path to avoid conflicts + export PYTHONPATH="" + export PYTHONDONTWRITEBYTECODE=1 + + echo "๐Ÿ“š Library path: $LD_LIBRARY_PATH" + + # Activate and install exact requirements from installer + echo "๐Ÿ“ฆ Installing Python packages with proper library linking..." + source "$VENV_PATH/bin/activate" + + # Add build tools to PATH for building Python packages + export PATH="${pkgs.cmake}/bin:${pkgs.pkg-config}/bin:${pkgs.gcc}/bin:${pkgs.gnumake}/bin:$PATH" + export CMAKE_GENERATOR="Unix Makefiles" + export CMAKE_MAKE_PROGRAM="${pkgs.gnumake}/bin/make" + export CC="${pkgs.gcc}/bin/gcc" + export CXX="${pkgs.gcc}/bin/g++" + + # Set wayland protocol path for pywayland + export PKG_CONFIG_PATH="${pkgs.wayland.dev}/lib/pkgconfig:${pkgs.wayland-protocols}/share/pkgconfig:${pkgs.wayland-scanner.dev}/lib/pkgconfig" + export WAYLAND_PROTOCOLS_DIR="${pkgs.wayland-scanner}/share/wayland" + export C_INCLUDE_PATH="${pkgs.wayland.dev}/include:${C_INCLUDE_PATH:-}" + export PATH="${pkgs.wayland-scanner}/bin:$PATH" + + # Upgrade pip first + pip install --upgrade pip + + # Install exact versions from scriptdata/requirements.txt + pip install --no-cache-dir --force-reinstall \ + build==1.2.2.post1 \ + cffi==1.17.1 \ + libsass==0.23.0 \ + material-color-utilities==0.2.1 \ + materialyoucolor==2.0.10 \ + numpy==2.2.2 \ + packaging==24.2 \ + pillow==11.1.0 \ + psutil==6.1.1 \ + pycparser==2.22 \ + pyproject-hooks==1.2.0 \ + setproctitle==1.3.4 \ + setuptools==80.9.0 \ + setuptools-scm==8.1.0 \ + wheel==0.45.1 \ + pywayland==0.4.18 + + # Test critical imports + echo "๐Ÿงช Testing critical package imports..." + python -c " +import sys +print(f'Python: {sys.version}') + +tests = [ + ('materialyoucolor', 'materialyoucolor'), + ('material_color_utilities', 'material_color_utilities'), + ('sass', 'sass'), + ('numpy', 'numpy'), + ('PIL', 'PIL'), + ('pywayland.client', 'pywayland.client'), + ('psutil', 'psutil'), + ('setproctitle', 'setproctitle') +] + +working = 0 +for name, module in tests: + try: + __import__(module) + print(f'โœ… {name}') + working += 1 + except Exception as e: + print(f'โŒ {name}: {e}') + +print(f'๐Ÿ“Š {working}/{len(tests)} packages working') +if working == len(tests): + print('๐ŸŽ‰ All critical packages imported successfully!') +else: + print('โš ๏ธ Some packages failed - may need additional system libraries') +" + + deactivate + + echo "โœ… Python virtual environment setup complete!" + echo "๐Ÿ”— Environment variable: ILLOGICAL_IMPULSE_VIRTUAL_ENV=$VENV_PATH" + echo "๐Ÿ“š Library path configured for NixOS compatibility" + ''; + + # Test script to verify the Python environment works + testVenvScript = pkgs.writeShellScript "test-dots-hyprland-venv" '' + #!/usr/bin/env bash + + VENV_PATH="$HOME/.local/state/quickshell/.venv" + + echo "๐Ÿงช Testing dots-hyprland Python virtual environment..." + + if [[ ! -d "$VENV_PATH" ]]; then + echo "โŒ Virtual environment not found at $VENV_PATH" + exit 1 + fi + + source "$VENV_PATH/bin/activate" + + # Test critical packages + echo "๐Ÿ“ฆ Testing Python packages..." + python -c "import material_color_utilities; print('โœ… material-color-utilities')" || echo "โŒ material-color-utilities" + python -c "import materialyoucolor; print('โœ… materialyoucolor')" || echo "โŒ materialyoucolor" + python -c "import pywayland; print('โœ… pywayland')" || echo "โŒ pywayland" + python -c "import PIL; print('โœ… pillow')" || echo "โŒ pillow" + python -c "import numpy; print('โœ… numpy')" || echo "โŒ numpy" + python -c "import psutil; print('โœ… psutil')" || echo "โŒ psutil" + + deactivate + + echo "๐ŸŽ‰ Python environment test complete!" + ''; +in +{ + options.programs.dots-hyprland.python = { + enable = mkEnableOption "Python virtual environment for dots-hyprland"; + + venvPath = mkOption { + type = types.str; + default = "$HOME/.local/state/quickshell/.venv"; + description = "Path to Python virtual environment"; + }; + + autoSetup = mkOption { + type = types.bool; + default = true; + description = "Automatically set up virtual environment on activation"; + }; + }; + + config = mkIf cfg.enable { + # Install system Python and required build dependencies + test script + home.packages = with pkgs; [ + python312 + python312Packages.pip + python312Packages.virtualenv + + # System dependencies for Python packages (from illogical-impulse-python PKGBUILD) + clang + gtk4 + libadwaita + libsoup_3 + libportal-gtk4 + gobject-introspection + sassc + opencv4 + + # Critical system libraries for Python packages (64-bit) + gcc-unwrapped.lib # Provides proper libstdc++.so.6 + glibc + zlib + libffi + openssl + + # Additional libraries that might be needed + bzip2 + xz + ncurses + readline + sqlite + + # Development tools + pkg-config + cairo + gdk-pixbuf + glib + + # Test script + (writeShellScriptBin "test-dots-hyprland-venv" '' + ${testVenvScript} + '') + ]; + + # Set up virtual environment on Home Manager activation + # Only rebuilds if packages change + home.activation.setupDotsHyprlandVenv = mkIf cfg.autoSetup ( + lib.hm.dag.entryAfter ["writeBoundary"] '' + VENV_PATH="${cfg.venvPath}" + MARKER_FILE="$VENV_PATH/.nix-built" + EXPECTED_HASH="${builtins.hashString "sha256" (builtins.readFile setupVenvScript)}" + + # Only rebuild if venv doesn't exist or script changed + if [[ ! -f "$MARKER_FILE" ]] || [[ "$(cat "$MARKER_FILE" 2>/dev/null)" != "$EXPECTED_HASH" ]]; then + echo "๐Ÿ Building Python venv (this takes ~10 minutes on first run)..." + $DRY_RUN_CMD ${setupVenvScript} + $DRY_RUN_CMD echo "$EXPECTED_HASH" > "$MARKER_FILE" + else + echo "โœ… Python venv already up to date" + fi + '' + ); + + # Set critical environment variable and library paths + home.sessionVariables = { + ILLOGICAL_IMPULSE_VIRTUAL_ENV = cfg.venvPath; + # Ensure Python packages can find system libraries (64-bit only) + LD_LIBRARY_PATH = lib.makeLibraryPath (with pkgs; [ + gcc-unwrapped.lib + glibc + zlib + libffi + openssl + bzip2 + xz.out + ncurses + readline + sqlite + ]); + # Additional environment variables for Python + PYTHONPATH = ""; # Clear to avoid conflicts + PYTHONDONTWRITEBYTECODE = "1"; # Prevent .pyc files + + # QML import paths for quickshell + QML2_IMPORT_PATH = lib.concatStringsSep ":" (with pkgs; [ + "${kdePackages.qt5compat}/lib/qt-6/qml" + "${kdePackages.qtdeclarative}/lib/qt-6/qml" + "${kdePackages.qtwayland}/lib/qt-6/qml" + ]); + }; + }; +} diff --git a/modules/writable-mode.nix b/modules/writable-mode.nix new file mode 100644 index 0000000..d472e0b --- /dev/null +++ b/modules/writable-mode.nix @@ -0,0 +1,348 @@ +# Writable mode for dots-hyprland +# Stages configuration to .configstaging and provides setup script +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.dots-hyprland.writable-mode; + mainCfg = config.programs.dots-hyprland; + + # Create the initial setup script + setupScript = pkgs.writeShellScript "dots-hyprland-setup" '' + #!/usr/bin/env bash + set -e + + # Colors for output + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' # No Color + + log() { + echo -e "''${GREEN}[dots-hyprland]''${NC} $1" + } + + warn() { + echo -e "''${YELLOW}[dots-hyprland]''${NC} WARNING: $1" + } + + error() { + echo -e "''${RED}[dots-hyprland]''${NC} ERROR: $1" + } + + info() { + echo -e "''${BLUE}[dots-hyprland]''${NC} $1" + } + + STAGING_DIR="$HOME/${cfg.stagingDir}" + CONFIG_DIR="$HOME/.config" + BACKUP_DIR="$HOME/.config-backup-$(date +%Y%m%d-%H%M%S)" + + log "๐Ÿš€ dots-hyprland Initial Setup" + log "๐Ÿ“ Staging: $STAGING_DIR" + log "๐ŸŽฏ Target: $CONFIG_DIR" + + # Check if staging directory exists + if [[ ! -d "$STAGING_DIR" ]]; then + error "Staging directory not found: $STAGING_DIR" + error "Please run 'home-manager switch' first to create the staging area" + exit 1 + fi + + # Backup existing configuration if requested + ${optionalString cfg.backupExisting '' + if [[ -d "$CONFIG_DIR" ]]; then + log "๐Ÿ’พ Creating backup at $BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + + # Backup specific directories that will be overwritten + for dir in quickshell hypr fish foot kitty fuzzel wlogout matugen; do + if [[ -d "$CONFIG_DIR/$dir" ]]; then + info " Backing up $dir" + cp -r "$CONFIG_DIR/$dir" "$BACKUP_DIR/" 2>/dev/null || true + fi + done + + log "โœ… Backup complete" + fi + ''} + + # Function to copy or symlink files + copy_config() { + local src="$1" + local dst="$2" + local name="$(basename "$src")" + + if [[ -d "$src" ]]; then + info "๐Ÿ“‚ Processing directory: $name" + mkdir -p "$dst" + + ${if cfg.symlinkMode then '' + # Create symlink + if [[ -L "$dst" ]]; then + rm "$dst" + elif [[ -d "$dst" ]]; then + rm -rf "$dst" + fi + ln -sf "$src" "$dst" + info " ๐Ÿ”— Symlinked: $name" + '' else '' + # Copy files + cp -rf "$src"/* "$dst/" 2>/dev/null || true + info " ๐Ÿ“‹ Copied: $name" + ''} + elif [[ -f "$src" ]]; then + info "๐Ÿ“„ Processing file: $name" + mkdir -p "$(dirname "$dst")" + + ${if cfg.symlinkMode then '' + # Create symlink + if [[ -L "$dst" ]] || [[ -f "$dst" ]]; then + rm "$dst" + fi + ln -sf "$src" "$dst" + info " ๐Ÿ”— Symlinked: $name" + '' else '' + # Copy file + cp "$src" "$dst" + info " ๐Ÿ“‹ Copied: $name" + ''} + fi + } + + # Copy/symlink all staged configuration + log "๐Ÿ”„ ${if cfg.symlinkMode then "Symlinking" else "Copying"} configuration files..." + + # Process all directories in staging + for item in "$STAGING_DIR"/*; do + if [[ -e "$item" ]]; then + name="$(basename "$item")" + copy_config "$item" "$CONFIG_DIR/$name" + fi + done + + # Copy .local/share files if they exist + if [[ -d "$STAGING_DIR/.local/share" ]]; then + log "๐Ÿ“ฆ Processing .local/share files..." + mkdir -p "$HOME/.local/share" + copy_config "$STAGING_DIR/.local/share/icons" "$HOME/.local/share/icons" + copy_config "$STAGING_DIR/.local/share/konsole" "$HOME/.local/share/konsole" + fi + + log "โœ… Configuration setup complete!" + log "" + log "๐Ÿ“‹ Next steps:" + log " 1. Your configuration is now ${if cfg.symlinkMode then "symlinked" else "copied"} to ~/.config/" + log " 2. ${if cfg.symlinkMode then "Files are symlinked - changes to staging will reflect immediately" else "Files are copied - you can now modify them freely"}" + log " 3. Test quickshell: quickshell" + log " 4. Test Python environment: test-dots-hyprland-venv" + ${optionalString cfg.backupExisting '' + log " 5. Your original config was backed up to: $BACKUP_DIR" + ''} + log "" + log "๐ŸŽ‰ Enjoy your dots-hyprland setup!" + ''; + + # Create a status/info script + statusScript = pkgs.writeShellScript "dots-hyprland-status" '' + #!/usr/bin/env bash + + GREEN='\033[0;32m' + RED='\033[0;31m' + YELLOW='\033[1;33m' + NC='\033[0m' + + echo -e "''${GREEN}dots-hyprland Status''${NC}" + echo "====================" + + # Check staging directory + STAGING_DIR="$HOME/${cfg.stagingDir}" + if [[ -d "$STAGING_DIR" ]]; then + echo -e "โœ… Staging directory: ''${GREEN}$STAGING_DIR''${NC}" + echo " $(find "$STAGING_DIR" -type f | wc -l) files staged" + else + echo -e "โŒ Staging directory: ''${RED}Not found''${NC}" + fi + + # Check Python virtual environment + VENV_PATH="$HOME/.local/state/quickshell/.venv" + if [[ -d "$VENV_PATH" ]]; then + echo -e "โœ… Python venv: ''${GREEN}$VENV_PATH''${NC}" + if [[ -f "$VENV_PATH/bin/python" ]]; then + VERSION=$("$VENV_PATH/bin/python" --version 2>&1) + echo " $VERSION" + fi + else + echo -e "โŒ Python venv: ''${RED}Not found''${NC}" + fi + + # Check quickshell config + if [[ -d "$HOME/.config/quickshell" ]]; then + echo -e "โœ… Quickshell config: ''${GREEN}~/.config/quickshell''${NC}" + if [[ -L "$HOME/.config/quickshell" ]]; then + echo " (symlinked to staging)" + else + echo " (copied from staging)" + fi + else + echo -e "โŒ Quickshell config: ''${RED}Not found''${NC}" + fi + + # Check environment variable + if [[ -n "$ILLOGICAL_IMPULSE_VIRTUAL_ENV" ]]; then + echo -e "โœ… Environment variable: ''${GREEN}ILLOGICAL_IMPULSE_VIRTUAL_ENV''${NC}" + echo " $ILLOGICAL_IMPULSE_VIRTUAL_ENV" + else + echo -e "โŒ Environment variable: ''${RED}ILLOGICAL_IMPULSE_VIRTUAL_ENV not set''${NC}" + fi + + echo "" + echo "Commands:" + echo " ${cfg.setupScript} - Run initial setup" + echo " dots-hyprland-status - Show this status" + echo " test-dots-hyprland-venv - Test Python environment" + ''; +in +{ + options.programs.dots-hyprland.writable-mode = { + enable = mkEnableOption "Writable mode for dots-hyprland configuration"; + + source = mkOption { + type = types.path; + description = "Source path for dots-hyprland configuration"; + }; + + stagingDir = mkOption { + type = types.str; + default = ".configstaging"; + description = "Directory to stage configuration files"; + }; + + setupScript = mkOption { + type = types.str; + default = "initialSetup.sh"; + description = "Name of the setup script"; + }; + + backupExisting = mkOption { + type = types.bool; + default = true; + description = "Backup existing configuration files"; + }; + + symlinkMode = mkOption { + type = types.bool; + default = false; + description = "Create symlinks instead of copying files"; + }; + }; + + config = mkIf cfg.enable { + # Stage all configuration files and install scripts + home.file = + let + # Get all config directories from source + configDirs = [ + "quickshell" "hypr" "fish" "foot" "kitty" "fuzzel" "wlogout" "matugen" + ]; + + # Create staging entries for each config directory + stagingEntries = listToAttrs (map (dir: { + name = "${cfg.stagingDir}/${dir}"; + value = { + source = "${cfg.source}/.config/${dir}"; + recursive = true; + }; + }) configDirs); + + # Add NixOS-specific patches + nixosPatches = { + }; + + # Add .local/share files to staging + localShareEntries = { + "${cfg.stagingDir}/.local/share/icons" = { + source = "${cfg.source}/.local/share/icons"; + recursive = true; + }; + "${cfg.stagingDir}/.local/share/konsole" = { + source = "${cfg.source}/.local/share/konsole"; + recursive = true; + }; + }; + + # Scripts and utilities + scriptEntries = { + ".local/bin/${cfg.setupScript}" = { + source = setupScript; + executable = true; + }; + + ".local/bin/dots-hyprland-status" = { + source = statusScript; + executable = true; + }; + + "${cfg.stagingDir}/README.md" = { + text = '' + # dots-hyprland Configuration Staging + + This directory contains the staged configuration files from the original dots-hyprland repository. + + ## Setup + + Run the setup script to copy/symlink these files to your ~/.config directory: + + ```bash + ~/.local/bin/${cfg.setupScript} + ``` + + ## Mode: ${if cfg.symlinkMode then "Symlink" else "Copy"} + + ${if cfg.symlinkMode then '' + **Symlink Mode**: Files will be symlinked to ~/.config/ + - Changes to files in staging will reflect immediately + - Useful for development and testing + - Files remain managed by Home Manager + '' else '' + **Copy Mode**: Files will be copied to ~/.config/ + - You can modify the copied files freely + - Changes won't affect the staging area + - Full user control over configuration + ''} + + ## Status + + Check the current status with: + + ```bash + dots-hyprland-status + ``` + + ## Files Staged + + - quickshell/ - Widget system configuration + - hypr/ - Hyprland window manager configuration + - fish/ - Fish shell configuration + - foot/ - Foot terminal configuration + - kitty/ - Kitty terminal configuration + - fuzzel/ - Fuzzel launcher configuration + - wlogout/ - Logout menu configuration + - .local/share/icons/ - Custom icons + - .local/share/konsole/ - Konsole profiles + + ## Python Environment + + The Python virtual environment is managed separately and will be created at: + `~/.local/state/quickshell/.venv` + + Test it with: `test-dots-hyprland-venv` + ''; + }; + }; + in + stagingEntries // localShareEntries // scriptEntries // nixosPatches; + }; +} diff --git a/packages/default.nix b/packages/default.nix new file mode 100644 index 0000000..1ad51ac --- /dev/null +++ b/packages/default.nix @@ -0,0 +1,24 @@ +# Package definitions for dots-hyprland utilities +{ pkgs }: + +{ + update-flake = pkgs.writeShellScriptBin "update-flake" + (builtins.readFile ./scripts/update-flake.sh); + + test-python-env = pkgs.writeShellScriptBin "test-python-env" + (builtins.readFile ./scripts/test-python-env.sh); + + test-quickshell = pkgs.writeShellScriptBin "test-quickshell" + (builtins.readFile ./scripts/test-quickshell.sh); + + compare-modes = pkgs.writeShellScriptBin "compare-modes" + (builtins.readFile ./scripts/compare-modes.sh); + + # QML directory generator for quickshell + generate-qmldir = pkgs.writeShellScriptBin "generate-qmldir" + (builtins.readFile ./scripts/generate-qmldir.sh); + + # Quickshell reset script + quickshell-reset = pkgs.writeShellScriptBin "quickshell-reset.sh" + (builtins.readFile ./scripts/quickshell-reset.sh); +} diff --git a/packages/dots-hyprland-packages.nix b/packages/dots-hyprland-packages.nix new file mode 100644 index 0000000..a881581 --- /dev/null +++ b/packages/dots-hyprland-packages.nix @@ -0,0 +1,165 @@ +# Package mappings from dots-hyprland meta-packages to nixpkgs +# Direct mapping from PKGBUILD files in arch-packages/ +{ lib, pkgs }: + +let + # Import utility packages + utilityPackages = import ./default.nix { inherit pkgs; }; + + # illogical-impulse-basic PKGBUILD + basicPackages = with pkgs; [ + axel + bc + coreutils + cliphist + cmake + curl + rsync + wget + ripgrep + jq + meson + xdg-user-dirs + ]; + + # illogical-impulse-widgets PKGBUILD + widgetPackages = with pkgs; [ + fuzzel + glib # for gsettings + gsettings-desktop-schemas # GNOME schemas for non-GNOME environments + hypridle + hyprutils + hyprlock + hyprpicker + networkmanagerapplet # nm-connection-editor + pkgs.quickshell + translate-shell + wlogout + + # Qt modules needed for quickshell widgets + kdePackages.qt5compat # For Qt5Compat.GraphicalEffects + kdePackages.qtdeclarative # For QML + kdePackages.kdialog + kdePackages.qtwayland # For Wayland support + kdePackages.qtpositioning # For Weather service location features + kdePackages.qtlocation # Additional location services for QtPositioning + + # KDE components for bluetooth and network management + kdePackages.kcmutils # Provides kcmshell6 + kdePackages.kde-cli-tools # KDE CLI tools + kdePackages.bluez-qt # Bluetooth QML module + kdePackages.bluedevil # KDE bluetooth manager + kdePackages.plasma-nm # KDE network manager + kdePackages.networkmanager-qt # NetworkManager QML bindings + kdePackages.modemmanager-qt # ModemManager QML bindings + kdePackages.kconfig # KDE config module + kdePackages.kirigami # KDE UI framework + kdePackages.kcoreaddons # KDE core addons + kdePackages.ki18n # KDE internationalization + + # Wrapper scripts for KDE tools with proper QML paths + (pkgs.writeShellScriptBin "kcmshell6-bluetooth" '' + export QML2_IMPORT_PATH="${pkgs.kdePackages.bluez-qt}/lib/qt-6/qml:${pkgs.kdePackages.bluedevil}/lib/qt-6/qml:${pkgs.kdePackages.plasma-nm}/lib/qt-6/qml:${pkgs.kdePackages.kconfig}/lib/qt-6/qml:${pkgs.kdePackages.kirigami}/lib/qt-6/qml" + exec ${pkgs.kdePackages.kcmutils}/bin/kcmshell6 kcm_bluetooth + '') + (pkgs.writeShellScriptBin "kcmshell6-network" '' + export QML2_IMPORT_PATH="${pkgs.kdePackages.plasma-nm}/lib/qt-6/qml:${pkgs.kdePackages.kconfig}/lib/qt-6/qml:${pkgs.kdePackages.kirigami}/lib/qt-6/qml:${pkgs.kdePackages.networkmanager-qt}/lib/qt-6/qml:${pkgs.kdePackages.modemmanager-qt}/lib/qt-6/qml:${pkgs.kdePackages.kcoreaddons}/lib/qt-6/qml:${pkgs.kdePackages.ki18n}/lib/qt-6/qml" + exec ${pkgs.kdePackages.kcmutils}/bin/kcmshell6 kcm_networkmanagement + '') + (pkgs.writeShellScriptBin "plasmawindowed-network" '' + export QML2_IMPORT_PATH="${pkgs.kdePackages.plasma-nm}/lib/qt-6/qml:${pkgs.kdePackages.kconfig}/lib/qt-6/qml:${pkgs.kdePackages.kirigami}/lib/qt-6/qml:${pkgs.kdePackages.networkmanager-qt}/lib/qt-6/qml:${pkgs.kdePackages.modemmanager-qt}/lib/qt-6/qml:${pkgs.kdePackages.kcoreaddons}/lib/qt-6/qml:${pkgs.kdePackages.ki18n}/lib/qt-6/qml" + exec ${pkgs.kdePackages.kde-cli-tools}/bin/plasmawindowed org.kde.plasma.networkmanagement + '') + ]; + + # illogical-impulse-hyprland PKGBUILD + # Note: hyprland itself should be installed system-wide via programs.hyprland.enable + hyprlandPackages = with pkgs; [ + hypridle + hyprcursor + # hyprland # Removed - use system hyprland instead + hyprland-qtutils + # hyprland-qt-support -> might be in hyprland-qtutils + hyprlang + hyprlock + hyprpicker + hyprsunset + hyprutils + hyprwayland-scanner + xdg-desktop-portal-hyprland + wl-clipboard + ]; + + # illogical-impulse-python PKGBUILD (system dependencies) + pythonSystemPackages = with pkgs; [ + clang + # uv -> not needed in NixOS approach, we use pip directly + gtk4 + libadwaita + libsoup_3 # libsoup3 + libportal-gtk4 + gobject-introspection + sassc + opencv4 # python-opencv + + # Additional system libraries needed for Python packages + stdenv.cc.cc.lib # provides libstdc++.so.6 + glibc + zlib + libffi + openssl + bzip2 + xz + ncurses + readline + sqlite + ]; + + # Additional packages that might be needed + audioPackages = with pkgs; [ + pipewire + wireplumber + pavucontrol + playerctl + ]; + + # Font packages (from installer analysis) + fontPackages = with pkgs; [ + # Rubik font (installer sets this as default) + # Note: might need to add custom font derivation + noto-fonts + noto-fonts-cjk-sans + noto-fonts-color-emoji + font-awesome + material-design-icons + nerd-fonts.jetbrains-mono + nerd-fonts.fira-code + ]; + + # Theme and appearance packages + themePackages = with pkgs; [ + matugen # for Material You color generation + # Additional theme packages as needed + ]; +in +{ + inherit + basicPackages + widgetPackages + hyprlandPackages + pythonSystemPackages + audioPackages + fontPackages + themePackages; + + # Combined package sets for different use cases + essentialPackages = basicPackages ++ widgetPackages ++ hyprlandPackages ++ + (builtins.attrValues utilityPackages); + + allPackages = basicPackages ++ widgetPackages ++ hyprlandPackages ++ + pythonSystemPackages ++ audioPackages ++ fontPackages ++ themePackages ++ + (builtins.attrValues utilityPackages); + + # Minimal set for testing + minimalPackages = basicPackages ++ widgetPackages ++ (builtins.attrValues utilityPackages); +} diff --git a/packages/scripts/compare-modes.sh b/packages/scripts/compare-modes.sh new file mode 100755 index 0000000..08dce7f --- /dev/null +++ b/packages/scripts/compare-modes.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +echo "๐Ÿ” dots-hyprland Configuration Modes" +echo "====================================" +echo "" +echo "๐Ÿ“‹ Available modes:" +echo "" +echo "1. ๐Ÿ”’ DECLARATIVE MODE" +echo " โ€ข Files managed by Home Manager" +echo " โ€ข Read-only configuration" +echo " โ€ข Automatic updates with 'home-manager switch'" +echo " โ€ข Best for: Set-and-forget users" +echo " โ€ข Build: nix build .#homeConfigurations.declarative.activationPackage" +echo "" +echo "2. โœ๏ธ WRITABLE MODE" +echo " โ€ข Files staged to ~/.configstaging" +echo " โ€ข User copies/modifies configuration" +echo " โ€ข Full control over files" +echo " โ€ข Best for: Customization and development" +echo " โ€ข Build: nix build .#homeConfigurations.writable.activationPackage" +echo "" +echo "๐Ÿš€ Quick start:" +echo " # For declarative mode:" +echo " nix build .#homeConfigurations.declarative.activationPackage && ./result/activate" +echo "" +echo " # For writable mode:" +echo " nix build .#homeConfigurations.writable.activationPackage && ./result/activate" +echo " ~/.local/bin/initialSetup.sh" diff --git a/packages/scripts/dev-shell-hook.sh b/packages/scripts/dev-shell-hook.sh new file mode 100755 index 0000000..6af5708 --- /dev/null +++ b/packages/scripts/dev-shell-hook.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +echo "๐Ÿš€ dots-hyprland self-contained installer replication" +echo "" +echo "๐Ÿ“‹ Available commands:" +echo " update-flake - Manage flake inputs" +echo " compare-modes - Compare declarative vs writable modes" +echo " test-python-env - Test Python virtual environment" +echo " test-quickshell - Test quickshell with config" +echo "" +echo "๐Ÿ”„ Flake management:" +echo " update-flake status - Show current flake status" +echo " update-flake update - Update flake inputs" +echo " update-flake verify - Test configurations build" +echo "" +echo "๐ŸŽฏ Build configurations:" +echo " nix build .#homeConfigurations.declarative.activationPackage" +echo " nix build .#homeConfigurations.writable.activationPackage" +echo "" +echo "๐Ÿ“ Local structure:" +echo " ./configs/ - All dots-hyprland configurations" +echo " ./modules/ - NixOS/Home Manager modules" +echo " ./packages/ - Utility scripts and packages" +echo "" +echo "๐Ÿ”‘ Self-contained: No external dependencies on dots-hyprland repo!" +echo "๐Ÿ“ Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')" +echo "" +echo "๐Ÿ’ก Run 'update-flake help' for full options" diff --git a/packages/scripts/generate-qmldir.sh b/packages/scripts/generate-qmldir.sh new file mode 100755 index 0000000..224c0e5 --- /dev/null +++ b/packages/scripts/generate-qmldir.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Dynamic qmldir generator for quickshell configurations +# This script automatically generates qmldir files based on actual QML files present + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[qmldir-gen]${NC} $1" >&2 +} + +warn() { + echo -e "${YELLOW}[qmldir-gen]${NC} WARNING: $1" >&2 +} + +error() { + echo -e "${RED}[qmldir-gen]${NC} ERROR: $1" >&2 +} + +# Function to check if a QML file is a singleton +is_singleton() { + local file="$1" + grep -q "pragma Singleton" "$file" 2>/dev/null +} + +# Function to generate qmldir for a directory +generate_qmldir() { + local dir="$1" + local module_name="$2" + local qmldir_file="$dir/qmldir" + + if [[ ! -d "$dir" ]]; then + warn "Directory $dir does not exist, skipping" + return 0 + fi + + log "Generating qmldir for $dir (module: $module_name)" + + # Start with module declaration + echo "module $module_name" > "$qmldir_file" + echo "" >> "$qmldir_file" + + # Find all QML files and add them + local count=0 + local singleton_count=0 + while IFS= read -r -d '' file; do + local basename=$(basename "$file" .qml) + # Skip files that start with lowercase (usually internal components) + if [[ "$basename" =~ ^[A-Z] ]]; then + if is_singleton "$file"; then + echo "singleton $basename 1.0 $(basename "$file")" >> "$qmldir_file" + ((singleton_count++)) + else + echo "$basename 1.0 $(basename "$file")" >> "$qmldir_file" + fi + ((count++)) + fi + done < <(find "$dir" -maxdepth 1 -name "*.qml" -type f -print0 | sort -z) || true + + log " โ†’ Registered $count components ($singleton_count singletons) in $qmldir_file" +} + +# Main function +main() { + local quickshell_dir="${1:-$HOME/.config/quickshell/ii}" + + if [[ ! -d "$quickshell_dir" ]]; then + error "Quickshell directory $quickshell_dir does not exist" + exit 1 + fi + + log "Generating qmldir files for quickshell configuration at $quickshell_dir" + + # Generate main qmldir (root level components) + log "Processing root directory..." + generate_qmldir "$quickshell_dir" "qs" + + # Copy main qmldir to qs subdirectory for proper module resolution + if [[ -f "$quickshell_dir/qmldir" && -d "$quickshell_dir/qs" ]]; then + log "Copying main qmldir to qs subdirectory for module resolution..." + cp "$quickshell_dir/qmldir" "$quickshell_dir/qs/qmldir" + log " โ†’ Main qmldir copied to qs/qmldir" + + # Also copy the root-level QML files that are referenced in the qmldir + log "Copying root-level QML files to qs subdirectory..." + while IFS= read -r qml_file; do + if [[ -f "$quickshell_dir/$qml_file" ]]; then + cp "$quickshell_dir/$qml_file" "$quickshell_dir/qs/$qml_file" + log " โ†’ Copied $qml_file to qs/$qml_file" + fi + done < <(grep "\.qml$" "$quickshell_dir/qmldir" | awk '{print $NF}' || true) + fi + + # Generate qmldir for modules directory + if [[ -d "$quickshell_dir/modules" ]]; then + log "Processing modules directory..." + generate_qmldir "$quickshell_dir/modules" "qs.modules" + + # Generate qmldir for each module subdirectory + while IFS= read -r -d '' module_dir; do + local module_name=$(basename "$module_dir") + log "Processing module: $module_name" + generate_qmldir "$module_dir" "qs.modules.$module_name" + + # Handle nested subdirectories (like common/functions, common/widgets) + while IFS= read -r -d '' nested_dir; do + local nested_name=$(basename "$nested_dir") + log "Processing nested module: $module_name/$nested_name" + generate_qmldir "$nested_dir" "qs.modules.$module_name.$nested_name" + done < <(find "$module_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) || true + done < <(find "$quickshell_dir/modules" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) + fi + + # Generate qmldir for services directory + if [[ -d "$quickshell_dir/services" ]]; then + log "Processing services directory..." + generate_qmldir "$quickshell_dir/services" "qs.services" + + # Generate qmldir for each service subdirectory + while IFS= read -r -d '' service_dir; do + local service_name=$(basename "$service_dir") + log "Processing service: $service_name" + generate_qmldir "$service_dir" "qs.services.$service_name" + done < <(find "$quickshell_dir/services" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) + fi + + # Generate qmldir for qs directory (if it exists) + if [[ -d "$quickshell_dir/qs" ]]; then + log "Processing qs directory..." + # Don't overwrite the main qmldir file we copied earlier + # Only generate qmldir for subdirectories of qs + + # Generate qmldir for qs subdirectories + while IFS= read -r -d '' qs_subdir; do + local subdir_name=$(basename "$qs_subdir") + log "Processing qs/$subdir_name" + generate_qmldir "$qs_subdir" "qs.qs.$subdir_name" + + # Handle nested qs subdirectories (like qs/modules/common, qs/services/ai) + while IFS= read -r -d '' nested_qs_dir; do + local nested_name=$(basename "$nested_qs_dir") + log "Processing nested qs: $subdir_name/$nested_name" + generate_qmldir "$nested_qs_dir" "qs.qs.$subdir_name.$nested_name" + + # Handle deeply nested directories (like qs/modules/common/functions) + while IFS= read -r -d '' deep_nested_dir; do + local deep_name=$(basename "$deep_nested_dir") + log "Processing deep nested qs: $subdir_name/$nested_name/$deep_name" + generate_qmldir "$deep_nested_dir" "qs.qs.$subdir_name.$nested_name.$deep_name" + done < <(find "$nested_qs_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) || true + done < <(find "$qs_subdir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) || true + done < <(find "$quickshell_dir/qs" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) + fi + + log "โœ… qmldir generation complete!" + log "๐Ÿ“Š Summary:" + find "$quickshell_dir" -name "qmldir" -type f | while read -r qmldir_file; do + local component_count=$(grep -c "\.qml$" "$qmldir_file" || echo "0") + local singleton_count=$(grep -c "^singleton" "$qmldir_file" || echo "0") + local relative_path=${qmldir_file#$quickshell_dir/} + log " โ†’ $relative_path: $component_count components ($singleton_count singletons)" + done +} + +# Help function +show_help() { + cat << EOF +Dynamic qmldir Generator for Quickshell + +USAGE: + $(basename "$0") [QUICKSHELL_DIR] + +ARGUMENTS: + QUICKSHELL_DIR Path to quickshell configuration directory + Default: \$HOME/.config/quickshell/ii + +DESCRIPTION: + This script automatically generates qmldir files for quickshell configurations + by scanning for QML files and registering them appropriately. This ensures + that all components are properly registered and available for import. + + The script handles: + - Root level components (ReloadPopup, etc.) + - Module components (bar, overview, etc.) + - Service components + - Nested subdirectories (functions, widgets, etc.) + - Automatic singleton detection via "pragma Singleton" + - Complex nested directory structures (qs/modules/common/functions) + + Singleton Detection: + The script automatically detects QML files with "pragma Singleton" declarations + and registers them with the "singleton" keyword in qmldir files. This is + essential for proper Material You theming and service registration. + +EXAMPLES: + # Generate for default location + $(basename "$0") + + # Generate for custom location + $(basename "$0") /path/to/quickshell/config + + # Use in automation + $(basename "$0") && quickshell -c ii + +EOF +} + +# Parse arguments +case "${1:-}" in + -h|--help) + show_help + exit 0 + ;; + *) + main "$@" + ;; +esac diff --git a/packages/scripts/quickshell-reset.sh b/packages/scripts/quickshell-reset.sh new file mode 100644 index 0000000..471941a --- /dev/null +++ b/packages/scripts/quickshell-reset.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# Quickshell reset script for end-4-flakes +# This script resets quickshell and regenerates qmldir files + +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log() { + echo -e "${GREEN}[quickshell-reset]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[quickshell-reset]${NC} WARNING: $1" +} + +error() { + echo -e "${RED}[quickshell-reset]${NC} ERROR: $1" +} + +log "Starting quickshell reset..." + +# Kill any running quickshell processes +log "Stopping quickshell processes..." +pkill -f quickshell || true +pkill -f qs || true + +# Wait a moment for processes to stop +sleep 1 + +# Regenerate qmldir files if the generator exists +if command -v generate-qmldir >/dev/null 2>&1; then + if [[ -d "$HOME/.config/quickshell/ii" ]]; then + log "Regenerating qmldir files..." + generate-qmldir "$HOME/.config/quickshell/ii" + log "qmldir files regenerated successfully" + else + warn "Quickshell config directory not found, skipping qmldir generation" + fi +else + warn "qmldir generator not found in PATH, skipping regeneration" +fi + +# Clear any cached QML modules +log "Clearing QML cache..." +rm -rf "$HOME/.cache/quickshell" 2>/dev/null || true +rm -rf "$HOME/.cache/qml" 2>/dev/null || true + +# Using quickshell directly (no wrapper script needed) +log "Using quickshell directly" + +log "Quickshell reset complete!" +log "You can now start quickshell with: qs" diff --git a/packages/scripts/test-python-env.sh b/packages/scripts/test-python-env.sh new file mode 100755 index 0000000..cbf9007 --- /dev/null +++ b/packages/scripts/test-python-env.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +echo "๐Ÿงช Testing dots-hyprland Python environment..." + +VENV_PATH="$HOME/.local/state/quickshell/.venv" + +if [[ ! -d "$VENV_PATH" ]]; then + echo "โŒ Virtual environment not found at $VENV_PATH" + echo "๐Ÿ’ก Run: home-manager switch" + exit 1 +fi + +source "$VENV_PATH/bin/activate" +python -c " +import sys +print(f'โœ… Python {sys.version}') + +try: + import material_color_utilities + print('โœ… material-color-utilities') +except ImportError: + print('โŒ material-color-utilities') + +try: + import materialyoucolor + print('โœ… materialyoucolor') +except ImportError: + print('โŒ materialyoucolor') + +try: + import pywayland + print('โœ… pywayland') +except ImportError: + print('โŒ pywayland') +" +deactivate diff --git a/packages/scripts/test-quickshell.sh b/packages/scripts/test-quickshell.sh new file mode 100755 index 0000000..157749c --- /dev/null +++ b/packages/scripts/test-quickshell.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +echo "๐Ÿงช Testing quickshell with dots-hyprland config..." + +if [[ ! -d "$HOME/.config/quickshell" ]]; then + echo "โŒ No quickshell configuration found" + echo "๐Ÿ’ก Run: home-manager switch" + exit 1 +fi + +cd "$HOME/.config/quickshell" +echo "๐Ÿš€ Starting quickshell (timeout 10s)..." +timeout 10 quickshell 2>&1 | head -20 diff --git a/packages/scripts/update-flake.sh b/packages/scripts/update-flake.sh new file mode 100755 index 0000000..731dae6 --- /dev/null +++ b/packages/scripts/update-flake.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# dots-hyprland Flake Update Utility +# Manages flake input updates for self-contained installer replication + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[update-flake]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[update-flake]${NC} WARNING: $1" +} + +error() { + echo -e "${RED}[update-flake]${NC} ERROR: $1" + exit 1 +} + +info() { + echo -e "${BLUE}[update-flake]${NC} $1" +} + +header() { + echo -e "${CYAN}=== $1 ===${NC}" +} + +show_help() { + cat << EOF +dots-hyprland Flake Update Utility (Self-Contained) + +USAGE: + update-flake [OPTIONS] [COMMAND] + +COMMANDS: + update Update all flake inputs (default) + status Show current flake input status + verify Verify flake builds after update + help Show this help message + +OPTIONS: + --auto-verify Automatically verify builds after update + --dry-run Show what would be done without executing + +EXAMPLES: + update-flake # Update all inputs + update-flake status # Show current status + update-flake update --auto-verify # Update and verify builds + +NOTE: This flake is now self-contained and doesn't depend on external + dots-hyprland repository. All configs are included locally. + +EOF +} + +get_current_commit() { + git rev-parse HEAD +} + +get_current_branch() { + git branch --show-current +} + +show_status() { + header "Flake Status" + + local current_commit=$(get_current_commit) + local current_branch=$(get_current_branch) + + echo "๐Ÿ“ Project Directory: $(pwd)" + echo "๐ŸŒฟ Current Branch: $current_branch" + echo "๐Ÿ“ Current Commit: ${current_commit:0:12}..." + echo "๐ŸŽฏ Mode: Self-contained (no external dependencies)" + echo "" + + log "โœ… Flake is self-contained with local configs" + info "All dots-hyprland configurations are included in ./configs/" +} + +update_all_inputs() { + header "Updating All Flake Inputs" + + log "Running nix flake update..." + if nix flake update; then + log "โœ… All inputs updated successfully" + info "Updated: nixpkgs, home-manager, quickshell" + else + error "Failed to update flake inputs" + fi +} + +verify_builds() { + header "Verifying Flake Builds" + + local configs=("declarative" "writable") + local success=true + + for config in "${configs[@]}"; do + info "๐Ÿ”จ Testing $config configuration..." + if nix build ".#homeConfigurations.$config.activationPackage" --no-link --quiet; then + log "โœ… $config configuration builds successfully" + else + error "โŒ $config configuration failed to build" + success=false + fi + done + + if $success; then + log "๐ŸŽ‰ All configurations build successfully!" + else + error "Some configurations failed to build" + fi +} + +# Parse command line arguments +AUTO_VERIFY=false +DRY_RUN=false +COMMAND="update" + +while [[ $# -gt 0 ]]; do + case $1 in + --auto-verify) + AUTO_VERIFY=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + update|status|verify|help) + COMMAND="$1" + shift + ;; + *) + if [[ "$1" != -* ]]; then + COMMAND="$1" + shift + else + error "Unknown option: $1" + fi + ;; + esac +done + +# Main execution +case "$COMMAND" in + help) + show_help + ;; + status) + show_status + ;; + update) + if $DRY_RUN; then + info "DRY RUN: Would update all flake inputs" + show_status + else + update_all_inputs + if $AUTO_VERIFY; then + verify_builds + fi + fi + ;; + verify) + verify_builds + ;; + *) + show_help + ;; +esac diff --git a/scripts/kcmshell6-bluetooth-wrapper.sh b/scripts/kcmshell6-bluetooth-wrapper.sh new file mode 100644 index 0000000..bc4790c --- /dev/null +++ b/scripts/kcmshell6-bluetooth-wrapper.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +export QML2_IMPORT_PATH="/nix/store/dpcsk75hmiyiqjx5xqrrcd4vqz04g0sx-bluez-qt-6.20.0/lib/qt-6/qml:/nix/store/4lqcd9h2zjqbw9fslaz1013hsxsqpyq5-bluedevil-6.5.3/lib/qt-6/qml:/nix/store/glsmwvihb0zxx1fp7ajdrznj2md9g25a-plasma-nm-6.5.3/lib/qt-6/qml:/nix/store/shlx6p5760n95svcili68pmj8dfzrgfm-kconfig-6.20.0/lib/qt-6/qml:/nix/store/227q5lxrfk76rxmvv0d2hfg38in2yvky-kirigami-wrapped-6.20.0/lib/qt-6/qml" +exec kcmshell6 kcm_bluetooth diff --git a/scripts/kcmshell6-network-wrapper.sh b/scripts/kcmshell6-network-wrapper.sh new file mode 100644 index 0000000..dbfc536 --- /dev/null +++ b/scripts/kcmshell6-network-wrapper.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +export QML2_IMPORT_PATH="/nix/store/glsmwvihb0zxx1fp7ajdrznj2md9g25a-plasma-nm-6.5.3/lib/qt-6/qml:/nix/store/shlx6p5760n95svcili68pmj8dfzrgfm-kconfig-6.20.0/lib/qt-6/qml:/nix/store/227q5lxrfk76rxmvv0d2hfg38in2yvky-kirigami-wrapped-6.20.0/lib/qt-6/qml" +exec kcmshell6 kcm_networkmanagement