diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..9bc8f1e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,86 @@ +name: Bug Report +description: Report a bug in Modus +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug! Please fill out the sections below. + + - type: input + id: version + attributes: + label: Modus version + description: "Output of `git rev-parse --short HEAD` or release tag" + placeholder: "e.g. v2.0.0 or abc1234" + validations: + required: true + + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Modus is affected? + options: + - Panel / Top Bar + - Dock + - Spotlight / Search + - App Switcher + - Lock Screen + - Notifications / Control Center + - Desktop Widgets + - Global Menu + - Settings / Config + - Installation / Dependencies + - Other + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug description + description: What happened? What did you expect? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug + placeholder: | + 1. Open Modus with `uv run start` + 2. Press Super+D + 3. Type ... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Paste any error output or journalctl logs + render: shell + + - type: textarea + id: environment + attributes: + label: Environment + description: | + - Distro / Window Manager: + - Python version: + - GPU / Driver: + value: | + - Distro: + - Python: + - GPU: + validations: + required: true + + - type: textarea + id: config + attributes: + label: Config files + description: "Paste contents of `config/config.toml` if relevant (redact sensitive values)" + render: toml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..859a6ce2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Discord + url: https://discord.gg/tRFxkbQ3Zq + about: Chat with the community + - name: Documentation + url: https://github.com/S4NKALP/Modus/tree/main/docs + about: Read the docs before opening an issue diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 00000000..e6427997 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,50 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Describe the feature you'd like to see in Modus. + + - type: dropdown + id: component + attributes: + label: Component + description: Which area does this relate to? + options: + - Panel / Top Bar + - Dock + - Spotlight / Search + - App Switcher + - Lock Screen + - Notifications / Control Center + - Desktop Widgets + - Global Menu + - Settings / Config + - New Widget + - Spotlight Plugin + - Installation / Packaging + - Other + validations: + required: true + + - type: textarea + id: description + attributes: + label: Feature description + description: What should it do? How should it work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workarounds or similar features you know of? + + - type: textarea + id: mockups + attributes: + label: Mockups / Screenshots + description: If applicable, attach reference images or screenshots diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000..ccdf0491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,22 @@ +name: Question +description: Ask a question about Modus +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Before opening, check the [docs](https://github.com/S4NKALP/Modus/tree/main/docs) and [FAQs](https://github.com/S4NKALP/Modus/blob/main/docs/faqs_tips.md). + + - type: textarea + id: question + attributes: + label: Your question + description: What do you want to know? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: Any relevant details about your setup or what you've tried diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..055865cd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,44 @@ +## Summary + + + +## Related Issue + + + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Refactor +- [ ] Documentation +- [ ] CI / Build +- [ ] Config / Assets + +## What Changed + + + +## Component(s) Affected + + + +## Testing + + + +- [ ] `uv run pre-commit run --all-files` passes +- [ ] Tested locally on Hyprland +- [ ] Existing functionality not broken + +## Screenshots / Videos + + + +## Checklist + +- [ ] Code follows project style (`ruff` lint + format) +- [ ] No new warnings or errors +- [ ] Docs updated (if applicable) +- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..d1a9d48c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..9c4bf18e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,58 @@ +core: + - label: "bug" + title: "bug" + sync: true + + - label: "enhancement" + title: "enhancement" + sync: true + + - label: "question" + title: "question" + sync: true + + - label: "documentation" + changed-files: + - any-glob-to-any-file: + - "docs/**" + - "**/*.md" + + - label: "python" + changed-files: + - any-glob-to-any-file: + - "src/**/*.py" + - "config/plugins/**" + + - label: "config" + changed-files: + - any-glob-to-any-file: + - "config/**" + + - label: "c-backend" + changed-files: + - any-glob-to-any-file: + - "src/window/switcher/app-capture/**" + - "src/window/globalmenu/**.c" + + - label: "styling" + changed-files: + - any-glob-to-any-file: + - "src/styles/**" + + - label: "ci" + changed-files: + - any-glob-to-any-file: + - ".github/**" + + - label: "assets" + changed-files: + - any-glob-to-any-file: + - "src/assets/**" + + - label: "installation" + changed-files: + - any-glob-to-any-file: + - "install.sh" + - "pyproject.toml" + - "requirements.txt" + - "uv.lock" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..67c65331 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: CodeQL + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [python] + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..b2bc28ac --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,17 @@ +name: Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..af38932f --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,26 @@ +name: Pre-commit + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +permissions: + contents: read + +jobs: + pre-commit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore index f732789b..e2e1fa03 100644 --- a/.gitignore +++ b/.gitignore @@ -3,18 +3,12 @@ *.pyc *.pyo *.pyd -# config/assets/config.json -config/assets/dock.json -config/assets/accounts.json -config/assets/passwords.json -config/assets/bookmarks.json -# config/hypr/modus.conf +*.so +config/config.toml +config/dock.toml config/hypr/colors.conf -fabric/** -fabric/ -# styles/colors.css +src/styles/colors.css .aider* .venv/* .venv/ - -AGENTS.md +config/plugins/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..d434388d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.2 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..aacd31a6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,242 @@ +# Contributing to Modus + +Thanks for your interest in contributing to Modus! This guide covers everything you need to get started. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Setup](#setup) +- [Project Structure](#project-structure) +- [Development Workflow](#development-workflow) +- [Code Style](#code-style) +- [Commit Messages](#commit-messages) +- [Pull Requests](#pull-requests) +- [Reporting Bugs](#reporting-bugs) +- [Requesting Features](#requesting-features) + +## Prerequisites + +- Arch Linux with [Hyprland](https://hyprland.org/) (Wayland compositor) +- Python 3.13+ +- [uv](https://docs.astral.sh/uv/) package manager +- [Meson](https://mesonbuild.com/) + Ninja (for C backends) +- GCC (for Global Menu shim) + +Install all dependencies: + +```bash +paru -S uv fabric-cli-git cliphist gnome-bluetooth-3.0 slurp ffmpeg hypridle hyprsunset hyprpicker hyprshot grim libnotify matugen-bin playerctl gtk-session-lock awww apple-fonts swappy wl-clipboard webp-pixbuf-loader wf-recorder acpi brightnessctl power-profiles-daemon uwsm cinnamon-desktop ddcutil at-spi2-core gcc make pkgconf appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 meson ninja wayland-protocols --needed +``` + +## Setup + +```bash +# Fork and clone +git clone https://github.com//Modus.git ~/.config/Modus +cd ~/.config/Modus + +# Install dependencies +uv sync + +# Compile App Switcher C backend +cd src/window/switcher/app-capture +meson setup builddir +meson compile -C builddir +cd ../../../.. + +# Compile Global Menu shim +cd src/window/globalmenu +gcc -shared -fPIC -O2 -o libmenu_button_shim.so libmenu_button_shim.c $(pkg-config --cflags --libs gtk+-3.0) -ldl +cd ../../.. + +# Run +uv run start +``` + +## Project Structure + +``` +Modus/ +├── src/ +│ ├── main.py # App bootstrap +│ ├── services/ # System service monitors (DBus, signals) +│ ├── shared/ # Reusable widgets and base window classes +│ ├── utils/ # Utility modules +│ ├── window/ # All UI windows +│ │ ├── panel/ # Top bar +│ │ ├── dock/ # macOS-style dock +│ │ ├── controlcenter/ # Quick settings panel +│ │ ├── notification/ # Notification system +│ │ ├── switcher/ # Alt-Tab app switcher (C backend) +│ │ ├── spotlight/ # Super+D search (plugin system) +│ │ ├── osd/ # Volume/brightness on-screen display +│ │ ├── screencapture/ # Screen capture/recording +│ │ ├── desktop/ # Desktop widgets +│ │ ├── settings/ # Settings window +│ │ ├── globalmenu/ # macOS global menu bar +│ │ └── lock.py # Session lock screen +│ └── styles/ # GTK CSS stylesheets +├── config/ # User-facing config (TOML) +│ ├── config.toml # Main settings +│ ├── mods.toml # Custom panel buttons +│ ├── dock.toml # Pinned dock apps +│ └── plugins/ # User-installed spotlight plugins +├── examples/ # Example plugins +├── docs/ # Documentation +├── pyproject.toml # Project config + Ruff linting +└── start.py # Entry point +``` + +## Development Workflow + +1. **Create a branch** from `dev`: + ```bash + git checkout dev + git checkout -b feat/my-feature + ``` + +2. **Make your changes** and ensure they pass linting: + ```bash + # Lint + .venv/bin/ruff check + + # Auto-fix lint issues + .venv/bin/ruff check --fix + + # Format + .venv/bin/ruff format + + # Check formatting without changing files + .venv/bin/ruff format --check + ``` + +3. **Test your changes** by running the app: + ```bash + uv run start + ``` + +4. **Commit** with a descriptive message (see [Commit Messages](#commit-messages)). + +5. **Push** and open a Pull Request against `dev`. + +## Code Style + +Modus uses [Ruff](https://docs.astral.sh/ruff/) for both linting and formatting. The full config is in `pyproject.toml`. + +### Key Rules + +- **Formatter**: Double quotes, 4-space indentation, 88 char line length +- **Import sorting**: Grouped as stdlib / third-party / first-party, alphabetical within groups +- **Type hints**: Use `Optional[X]` and `typing.List`, `typing.Dict` style (not PEP 604 unions) +- **Logging**: Use `logger` from `fabric.utils`, not `print()` (except in CLI entry points) +- **Docstrings**: Not enforced, but encouraged for public APIs + +### Enforced via Ruff + +| Rule | What it catches | +|------|----------------| +| `E`, `W` | Pycodestyle style errors | +| `F` | Pyflakes (unused imports, undefined names) | +| `I` | Import sorting (isort) | +| `UP` | Python upgrade suggestions | +| `B` | Bugbear (common bugs) | +| `SIM` | Simplification suggestions | +| `C4` | Unnecessary comprehensions | +| `RET` | Return statement issues | +| `RUF` | Ruff-specific rules | +| `T20` | `print()` detection | + +### Running Ruff + +```bash +# Check for issues +.venv/bin/ruff check + +# Auto-fix safe issues +.venv/bin/ruff check --fix + +# Auto-fix including unsafe changes +.venv/bin/ruff check --fix --unsafe-fixes + +# Format all files +.venv/bin/ruff format + +# Check formatting (CI-friendly) +.venv/bin/ruff format --check +``` + +## Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): +``` + +### Types + +| Type | Use for | +|------|---------| +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code restructuring without behavior change | +| `perf` | Performance improvement | +| `docs` | Documentation changes | +| `style` | Code style changes (formatting, no logic change) | +| `chore` | Build, CI, tooling, dependencies | +| `test` | Adding or updating tests | + +### Scopes + +Use the module name: `services`, `window`, `dock`, `panel`, `spotlight`, `notification`, `switcher`, `osd`, `config`, `docs`, etc. + +### Examples + +``` +feat(dock): add magnifier hover effect +fix(notification): prevent crash on empty body +refactor(services): extract battery polling to separate thread +docs: update installation guide for Arch Linux +style: enforce consistent codestyle via ruff config +``` + +### Rules + +- Use imperative mood: "add" not "added" or "adds" +- Keep subject line under 72 characters +- Reference issues when applicable: `Closes #42` +- Body is optional — use it only to explain **why**, not **what** + +## Pull Requests + +### Before Submitting + +- [ ] Code passes `ruff check` with no errors +- [ ] Code passes `ruff format --check` +- [ ] Changes are tested on a live Hyprland session +- [ ] Commit messages follow [Conventional Commits](#commit-messages) + +### PR Guidelines + +- Target the `dev` branch (not `main`) +- Keep PRs focused — one feature or fix per PR +- Write a clear description of what changed and why +- Include screenshots or screen recordings for UI changes +- Reference related issues + +## Reporting Bugs + +Open an issue with: + +1. **Description**: What happened vs. what you expected +2. **Steps to reproduce**: Exact commands or actions +3. **Environment**: OS, Hyprland version, Python version +4. **Logs**: Relevant output from terminal or journal + +## Requesting Features + +Open an issue with: + +1. **Use case**: What problem does this solve? +2. **Proposed solution**: How should it work? +3. **Alternatives considered**: Other approaches you thought about diff --git a/LICENSE b/LICENSE index 13fdf8f0..1e6e8deb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,20 @@ +Modus - A hackable shell for Hyprland +Copyright (C) 2025 Sankalp Tharu (S4NKALP) and contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +------------------------------------------------------------------------ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 diff --git a/README.md b/README.md index 3a9df24c..290ab0e5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Logo + Logo

@@ -26,19 +26,14 @@

Home Screen:

- fabric + fabric

Lock Screen:

- fabric + fabric

- -## Installation - -> [!CAUTION] -> - You need a working installation of hyprland and knowledge of how it works -> - There may not be all packages in your system install them accordingly +## Quick Start ```bash git clone https://github.com/S4NKALP/Modus ~/.config/Modus @@ -47,51 +42,129 @@ cd ~/.config/Modus ``` > [!TIP] +> > ## Post Installation +> > - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors)
> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config -> - For Lock Screen Bind keys to `python lock.py` - -

Rocket Todo

+> - For Lock Screen Bind keys to `uv run lock` -## Manual Installation (WIP) +## Manual Installation ```bash -paru -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --needed +paru -S uv fabric-cli-git cliphist gnome-bluetooth-3.0 slurp ffmpeg hypridle hyprsunset hyprpicker hyprshot grim libnotify matugen-bin playerctl gtk-session-lock awww apple-fonts swappy wl-clipboard webp-pixbuf-loader wf-recorder acpi brightnessctl power-profiles-daemon uwsm cinnamon-desktop ddcutil at-spi2-core gcc make pkgconf appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 meson ninja wayland-protocols --needed git clone https://github.com/S4NKALP/Modus ~/.config/Modus cd ~/.config/Modus -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pip install --no-deps git+https://github.com/Fabric-Development/fabric.git +uv sync + +# Compile App Switcher C-backend +cd src/window/switcher/app-capture +meson setup builddir +meson compile -C builddir +cd ../../../.. + +# Compile Global Menu shim +cd src/window/globalmenu +gcc -shared -fPIC -O2 -o libmenu_button_shim.so libmenu_button_shim.c $(pkg-config --cflags --libs gtk+-3.0) -ldl +cd ../../.. + +uv run start +``` + +## Global Menu Compatibility + +MODUS injects the GTK3 menu-button shim (`libmenu_button_shim.so`) **only into +GTK3 apps**, and only when launched through MODUS (dock, spotlight, panel). +GTK4 apps are never touched, so they launch normally — a global `LD_PRELOAD` +of this shim would otherwise crash every GTK4 process. + +### Terminal-launched GTK3 apps + +Apps you start directly from a terminal don't go through MODUS, so they don't +get the shim. They still work fine; they just miss the synthetic +button-release fix the shim provides. To get it back without a dangerous +system-wide `LD_PRELOAD`, use a thin wrapper that detects GTK3 and injects the +shim only for that process: + +```bash +#!/usr/bin/env bash +# ~/.local/bin/modus-launch — GTK3-aware launcher for the global menu shim +SHIM=~/.config/Modus/src/window/globalmenu/libmenu_button_shim.so +BIN="$(command -v "$1")" +if [[ -n "$BIN" ]] && readelf -d "$BIN" 2>/dev/null | grep -q 'libgtk-3\.so\.0'; then + LD_PRELOAD="$SHIM" "$@" +else + "$@" +fi +``` + +Make it executable (`chmod +x ~/.local/bin/modus-launch`) and run GTK3 apps as +`modus-launch gedit` from the terminal. GTK4 apps fall through to a normal +launch. + +## Documentation + +| Doc | What's inside | +| --------------------------------------------- | --------------------------------------------------- | +| [Installation Guide](docs/installation.md) | Automated & manual install, dependencies | +| [Configuration Guide](docs/configuration.md) | `config.toml`, `mods.toml`, `dock.toml`, keybinds | +| [Styling Guide](docs/styling.md) | Matugen colors, CSS customization | +| [Architecture Overview](docs/architecture.md) | App Switcher C-backend, Spotlight engine, Services | +| [Spotlight Plugins](docs/plugins.md) | Install/customize plugins, write your own, examples | +| [FAQs & Tips](docs/faqs_tips.md) | Troubleshooting, shortcuts, live reload | +| [Contributing](CONTRIBUTING.md) | Setup, code style, commit conventions | + +## Configuration + +Config files in `config/`, using TOML format: + +| File | Purpose | +| ------------- | -------------------------------------------------- | +| `config.toml` | Main settings (switcher, wallpaper, notifications) | +| `mods.toml` | Custom panel buttons | +| `dock.toml` | Pinned dock apps | + +See [Configuration Guide](docs/configuration.md) for full reference. + +## Custom Mods + +Define panel buttons in `config/mods.toml`: + +```toml +[Mods.terminal] +icon = "terminal.svg" +icon-size = 16 +order = 1 +on-clicked = "kitty" +``` + +- `icon` — SVG filename in `src/assets/icons/` +- `icon-size` — defaults to 16 +- `order` — button position (lower = first) +- `on-clicked` — shell command on click +- `on-left`, `on-middle`, `on-right` — per-mouse-button commands +- `options` — dropdown menu entries (each with `label` + `on-clicked`) +- Full shell syntax (`&&`, `|`) via `sh -c` +- Live reload — edit and changes apply instantly + +## Spotlight Plugins + +The spotlight search (Super+D) is powered by plugins. Drop a `.py` file in +`config/plugins/` and it works instantly. + +```bash +cp -r examples/plugins/hello.py config/plugins/hello.py +# Hit Super+D, type "hi" — greeting appears +``` + +Plugin development: + +```bash +nvim config/plugins/hello.py +fabric-cli exec modus1 'deep_reload hello' # no restart needed ``` -- [x] Launcher -- [x] Lock Screen -- [x] Dock -- [x] Notification -- [x] Control Center -- [x] Music Player -- [x] Desktop Widgets -- [x] New Launcher (like Spotlight) -- [ ] Settings -- [x] ~~Magnifier hover effect on Dock~~ -- [x] ~~New Application Switcher~~ -- [x] Panel Widget -- [x] MacOS like Widget -- [x] Expandable Notification Centre -- [ ] Installation Script -- [ ] Proper Documentation -- [ ] Pomodoro Timer Widget -- [x] To-do List Widget - -## Bug Fixes (the bug found till now) - -- [x] WiFi -- [x] wifi off button looks bigger -- [x] Metadata Changes delay in Media Player -- [x] Active Window Title showing `Unknown` when no active window -- [x] Notification Escape Char Issue +See [Spotlight Plugins](docs/plugins.md) for full docs and examples. ## Team @@ -104,7 +177,7 @@ A big thank you to the following people for their incredible help with code and - [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible. - [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort. -- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldn’t resist borrowing. +- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldn't resist borrowing. - [E3nviction](https://github.com/E3nviction/): for code snippets and ideas that were incredibly helpful. I truly appreciate your support diff --git a/assets/default.png b/assets/default.png deleted file mode 100644 index 774a4e08..00000000 Binary files a/assets/default.png and /dev/null differ diff --git a/config/assets/config.json b/config/assets/config.json deleted file mode 100644 index 3750756b..00000000 --- a/config/assets/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "wallpapers_dir": "~/Pictures/Wallpapers/", - "dock_position": "Bottom", - "terminal_command": "kitty -e", - "dock_enabled": true, - "dock_auto_hide": true, - "dock_always_occluded": false, - "dock_icon_size": 52, - "window_switcher_items_per_row": 10, - "hide_special_workspace": true, - "dock_hide_special_workspace_apps": true, - "dock_preview_apps": false, - "notification_timeout": "5s", - "notification_ignored_apps": ["Hyprshot"], - "notification_limited_apps_history": ["Spotify"] -} diff --git a/config/assets/emoji.json b/config/assets/emoji.json deleted file mode 100644 index fa174ffb..00000000 --- a/config/assets/emoji.json +++ /dev/null @@ -1,15509 +0,0 @@ -{ - "😀": { - "name": "grinning face", - "slug": "grinning_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😃": { - "name": "grinning face with big eyes", - "slug": "grinning_face_with_big_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😄": { - "name": "grinning face with smiling eyes", - "slug": "grinning_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😁": { - "name": "beaming face with smiling eyes", - "slug": "beaming_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😆": { - "name": "grinning squinting face", - "slug": "grinning_squinting_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😅": { - "name": "grinning face with sweat", - "slug": "grinning_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤣": { - "name": "rolling on the floor laughing", - "slug": "rolling_on_the_floor_laughing", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "😂": { - "name": "face with tears of joy", - "slug": "face_with_tears_of_joy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🙂": { - "name": "slightly smiling face", - "slug": "slightly_smiling_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🙃": { - "name": "upside-down face", - "slug": "upside_down_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🫠": { - "name": "melting face", - "slug": "melting_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "😉": { - "name": "winking face", - "slug": "winking_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😊": { - "name": "smiling face with smiling eyes", - "slug": "smiling_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😇": { - "name": "smiling face with halo", - "slug": "smiling_face_with_halo", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥰": { - "name": "smiling face with hearts", - "slug": "smiling_face_with_hearts", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "😍": { - "name": "smiling face with heart-eyes", - "slug": "smiling_face_with_heart_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤩": { - "name": "star-struck", - "slug": "star_struck", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "😘": { - "name": "face blowing a kiss", - "slug": "face_blowing_a_kiss", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😗": { - "name": "kissing face", - "slug": "kissing_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "☺️": { - "name": "smiling face", - "slug": "smiling_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😚": { - "name": "kissing face with closed eyes", - "slug": "kissing_face_with_closed_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😙": { - "name": "kissing face with smiling eyes", - "slug": "kissing_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥲": { - "name": "smiling face with tear", - "slug": "smiling_face_with_tear", - "group": "Smileys & Emotion", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "😋": { - "name": "face savoring food", - "slug": "face_savoring_food", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😛": { - "name": "face with tongue", - "slug": "face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😜": { - "name": "winking face with tongue", - "slug": "winking_face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤪": { - "name": "zany face", - "slug": "zany_face", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "😝": { - "name": "squinting face with tongue", - "slug": "squinting_face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤑": { - "name": "money-mouth face", - "slug": "money_mouth_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🤗": { - "name": "smiling face with open hands", - "slug": "smiling_face_with_open_hands", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🤭": { - "name": "face with hand over mouth", - "slug": "face_with_hand_over_mouth", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🫢": { - "name": "face with open eyes and hand over mouth", - "slug": "face_with_open_eyes_and_hand_over_mouth", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🫣": { - "name": "face with peeking eye", - "slug": "face_with_peeking_eye", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🤫": { - "name": "shushing face", - "slug": "shushing_face", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🤔": { - "name": "thinking face", - "slug": "thinking_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🫡": { - "name": "saluting face", - "slug": "saluting_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🤐": { - "name": "zipper-mouth face", - "slug": "zipper_mouth_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🤨": { - "name": "face with raised eyebrow", - "slug": "face_with_raised_eyebrow", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "😐": { - "name": "neutral face", - "slug": "neutral_face", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "😑": { - "name": "expressionless face", - "slug": "expressionless_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😶": { - "name": "face without mouth", - "slug": "face_without_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🫥": { - "name": "dotted line face", - "slug": "dotted_line_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "😶‍🌫️": { - "name": "face in clouds", - "slug": "face_in_clouds", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "😏": { - "name": "smirking face", - "slug": "smirking_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😒": { - "name": "unamused face", - "slug": "unamused_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🙄": { - "name": "face with rolling eyes", - "slug": "face_with_rolling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😬": { - "name": "grimacing face", - "slug": "grimacing_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😮‍💨": { - "name": "face exhaling", - "slug": "face_exhaling", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "🤥": { - "name": "lying face", - "slug": "lying_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🫨": { - "name": "shaking face", - "slug": "shaking_face", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🙂‍↔️": { - "name": "head shaking horizontally", - "slug": "head_shaking_horizontally", - "group": "Smileys & Emotion", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🙂‍↕️": { - "name": "head shaking vertically", - "slug": "head_shaking_vertically", - "group": "Smileys & Emotion", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "😌": { - "name": "relieved face", - "slug": "relieved_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😔": { - "name": "pensive face", - "slug": "pensive_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😪": { - "name": "sleepy face", - "slug": "sleepy_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤤": { - "name": "drooling face", - "slug": "drooling_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "😴": { - "name": "sleeping face", - "slug": "sleeping_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😷": { - "name": "face with medical mask", - "slug": "face_with_medical_mask", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤒": { - "name": "face with thermometer", - "slug": "face_with_thermometer", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🤕": { - "name": "face with head-bandage", - "slug": "face_with_head_bandage", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🤢": { - "name": "nauseated face", - "slug": "nauseated_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🤮": { - "name": "face vomiting", - "slug": "face_vomiting", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🤧": { - "name": "sneezing face", - "slug": "sneezing_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥵": { - "name": "hot face", - "slug": "hot_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥶": { - "name": "cold face", - "slug": "cold_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥴": { - "name": "woozy face", - "slug": "woozy_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "😵": { - "name": "face with crossed-out eyes", - "slug": "face_with_crossed_out_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😵‍💫": { - "name": "face with spiral eyes", - "slug": "face_with_spiral_eyes", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "🤯": { - "name": "exploding head", - "slug": "exploding_head", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🤠": { - "name": "cowboy hat face", - "slug": "cowboy_hat_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥳": { - "name": "partying face", - "slug": "partying_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥸": { - "name": "disguised face", - "slug": "disguised_face", - "group": "Smileys & Emotion", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "😎": { - "name": "smiling face with sunglasses", - "slug": "smiling_face_with_sunglasses", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🤓": { - "name": "nerd face", - "slug": "nerd_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🧐": { - "name": "face with monocle", - "slug": "face_with_monocle", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "😕": { - "name": "confused face", - "slug": "confused_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🫤": { - "name": "face with diagonal mouth", - "slug": "face_with_diagonal_mouth", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "😟": { - "name": "worried face", - "slug": "worried_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🙁": { - "name": "slightly frowning face", - "slug": "slightly_frowning_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "☹️": { - "name": "frowning face", - "slug": "frowning_face", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "😮": { - "name": "face with open mouth", - "slug": "face_with_open_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😯": { - "name": "hushed face", - "slug": "hushed_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😲": { - "name": "astonished face", - "slug": "astonished_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😳": { - "name": "flushed face", - "slug": "flushed_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥺": { - "name": "pleading face", - "slug": "pleading_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥹": { - "name": "face holding back tears", - "slug": "face_holding_back_tears", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "😦": { - "name": "frowning face with open mouth", - "slug": "frowning_face_with_open_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😧": { - "name": "anguished face", - "slug": "anguished_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😨": { - "name": "fearful face", - "slug": "fearful_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😰": { - "name": "anxious face with sweat", - "slug": "anxious_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😥": { - "name": "sad but relieved face", - "slug": "sad_but_relieved_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😢": { - "name": "crying face", - "slug": "crying_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😭": { - "name": "loudly crying face", - "slug": "loudly_crying_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😱": { - "name": "face screaming in fear", - "slug": "face_screaming_in_fear", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😖": { - "name": "confounded face", - "slug": "confounded_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😣": { - "name": "persevering face", - "slug": "persevering_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😞": { - "name": "disappointed face", - "slug": "disappointed_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😓": { - "name": "downcast face with sweat", - "slug": "downcast_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😩": { - "name": "weary face", - "slug": "weary_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😫": { - "name": "tired face", - "slug": "tired_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥱": { - "name": "yawning face", - "slug": "yawning_face", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "😤": { - "name": "face with steam from nose", - "slug": "face_with_steam_from_nose", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😡": { - "name": "enraged face", - "slug": "enraged_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😠": { - "name": "angry face", - "slug": "angry_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤬": { - "name": "face with symbols on mouth", - "slug": "face_with_symbols_on_mouth", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "😈": { - "name": "smiling face with horns", - "slug": "smiling_face_with_horns", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "👿": { - "name": "angry face with horns", - "slug": "angry_face_with_horns", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💀": { - "name": "skull", - "slug": "skull", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☠️": { - "name": "skull and crossbones", - "slug": "skull_and_crossbones", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💩": { - "name": "pile of poo", - "slug": "pile_of_poo", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤡": { - "name": "clown face", - "slug": "clown_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "👹": { - "name": "ogre", - "slug": "ogre", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👺": { - "name": "goblin", - "slug": "goblin", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👻": { - "name": "ghost", - "slug": "ghost", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👽": { - "name": "alien", - "slug": "alien", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👾": { - "name": "alien monster", - "slug": "alien_monster", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤖": { - "name": "robot", - "slug": "robot", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "😺": { - "name": "grinning cat", - "slug": "grinning_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😸": { - "name": "grinning cat with smiling eyes", - "slug": "grinning_cat_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😹": { - "name": "cat with tears of joy", - "slug": "cat_with_tears_of_joy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😻": { - "name": "smiling cat with heart-eyes", - "slug": "smiling_cat_with_heart_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😼": { - "name": "cat with wry smile", - "slug": "cat_with_wry_smile", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😽": { - "name": "kissing cat", - "slug": "kissing_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🙀": { - "name": "weary cat", - "slug": "weary_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😿": { - "name": "crying cat", - "slug": "crying_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "😾": { - "name": "pouting cat", - "slug": "pouting_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🙈": { - "name": "see-no-evil monkey", - "slug": "see_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🙉": { - "name": "hear-no-evil monkey", - "slug": "hear_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🙊": { - "name": "speak-no-evil monkey", - "slug": "speak_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💌": { - "name": "love letter", - "slug": "love_letter", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💘": { - "name": "heart with arrow", - "slug": "heart_with_arrow", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💝": { - "name": "heart with ribbon", - "slug": "heart_with_ribbon", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💖": { - "name": "sparkling heart", - "slug": "sparkling_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💗": { - "name": "growing heart", - "slug": "growing_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💓": { - "name": "beating heart", - "slug": "beating_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💞": { - "name": "revolving hearts", - "slug": "revolving_hearts", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💕": { - "name": "two hearts", - "slug": "two_hearts", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💟": { - "name": "heart decoration", - "slug": "heart_decoration", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❣️": { - "name": "heart exclamation", - "slug": "heart_exclamation", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💔": { - "name": "broken heart", - "slug": "broken_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❤️‍🔥": { - "name": "heart on fire", - "slug": "heart_on_fire", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "❤️‍🩹": { - "name": "mending heart", - "slug": "mending_heart", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "❤️": { - "name": "red heart", - "slug": "red_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🩷": { - "name": "pink heart", - "slug": "pink_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🧡": { - "name": "orange heart", - "slug": "orange_heart", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "💛": { - "name": "yellow heart", - "slug": "yellow_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💚": { - "name": "green heart", - "slug": "green_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💙": { - "name": "blue heart", - "slug": "blue_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🩵": { - "name": "light blue heart", - "slug": "light_blue_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "💜": { - "name": "purple heart", - "slug": "purple_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤎": { - "name": "brown heart", - "slug": "brown_heart", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🖤": { - "name": "black heart", - "slug": "black_heart", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🩶": { - "name": "grey heart", - "slug": "grey_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🤍": { - "name": "white heart", - "slug": "white_heart", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "💋": { - "name": "kiss mark", - "slug": "kiss_mark", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💯": { - "name": "hundred points", - "slug": "hundred_points", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💢": { - "name": "anger symbol", - "slug": "anger_symbol", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💥": { - "name": "collision", - "slug": "collision", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💫": { - "name": "dizzy", - "slug": "dizzy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💦": { - "name": "sweat droplets", - "slug": "sweat_droplets", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💨": { - "name": "dashing away", - "slug": "dashing_away", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕳️": { - "name": "hole", - "slug": "hole", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "💬": { - "name": "speech balloon", - "slug": "speech_balloon", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👁️‍🗨️": { - "name": "eye in speech bubble", - "slug": "eye_in_speech_bubble", - "group": "Smileys & Emotion", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🗨️": { - "name": "left speech bubble", - "slug": "left_speech_bubble", - "group": "Smileys & Emotion", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🗯️": { - "name": "right anger bubble", - "slug": "right_anger_bubble", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "💭": { - "name": "thought balloon", - "slug": "thought_balloon", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💤": { - "name": "ZZZ", - "slug": "zzz", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👋": { - "name": "waving hand", - "slug": "waving_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤚": { - "name": "raised back of hand", - "slug": "raised_back_of_hand", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🖐️": { - "name": "hand with fingers splayed", - "slug": "hand_with_fingers_splayed", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "✋": { - "name": "raised hand", - "slug": "raised_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🖖": { - "name": "vulcan salute", - "slug": "vulcan_salute", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🫱": { - "name": "rightwards hand", - "slug": "rightwards_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🫲": { - "name": "leftwards hand", - "slug": "leftwards_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🫳": { - "name": "palm down hand", - "slug": "palm_down_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🫴": { - "name": "palm up hand", - "slug": "palm_up_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🫷": { - "name": "leftwards pushing hand", - "slug": "leftwards_pushing_hand", - "group": "People & Body", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.0" - }, - "🫸": { - "name": "rightwards pushing hand", - "slug": "rightwards_pushing_hand", - "group": "People & Body", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.0" - }, - "👌": { - "name": "OK hand", - "slug": "ok_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤌": { - "name": "pinched fingers", - "slug": "pinched_fingers", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "🤏": { - "name": "pinching hand", - "slug": "pinching_hand", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "✌️": { - "name": "victory hand", - "slug": "victory_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤞": { - "name": "crossed fingers", - "slug": "crossed_fingers", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🫰": { - "name": "hand with index finger and thumb crossed", - "slug": "hand_with_index_finger_and_thumb_crossed", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🤟": { - "name": "love-you gesture", - "slug": "love_you_gesture", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🤘": { - "name": "sign of the horns", - "slug": "sign_of_the_horns", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤙": { - "name": "call me hand", - "slug": "call_me_hand", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "👈": { - "name": "backhand index pointing left", - "slug": "backhand_index_pointing_left", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👉": { - "name": "backhand index pointing right", - "slug": "backhand_index_pointing_right", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👆": { - "name": "backhand index pointing up", - "slug": "backhand_index_pointing_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🖕": { - "name": "middle finger", - "slug": "middle_finger", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👇": { - "name": "backhand index pointing down", - "slug": "backhand_index_pointing_down", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "☝️": { - "name": "index pointing up", - "slug": "index_pointing_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🫵": { - "name": "index pointing at the viewer", - "slug": "index_pointing_at_the_viewer", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "👍": { - "name": "thumbs up", - "slug": "thumbs_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👎": { - "name": "thumbs down", - "slug": "thumbs_down", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "✊": { - "name": "raised fist", - "slug": "raised_fist", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👊": { - "name": "oncoming fist", - "slug": "oncoming_fist", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤛": { - "name": "left-facing fist", - "slug": "left_facing_fist", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤜": { - "name": "right-facing fist", - "slug": "right_facing_fist", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "👏": { - "name": "clapping hands", - "slug": "clapping_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙌": { - "name": "raising hands", - "slug": "raising_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🫶": { - "name": "heart hands", - "slug": "heart_hands", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "👐": { - "name": "open hands", - "slug": "open_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤲": { - "name": "palms up together", - "slug": "palms_up_together", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🤝": { - "name": "handshake", - "slug": "handshake", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🙏": { - "name": "folded hands", - "slug": "folded_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "✍️": { - "name": "writing hand", - "slug": "writing_hand", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "💅": { - "name": "nail polish", - "slug": "nail_polish", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤳": { - "name": "selfie", - "slug": "selfie", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "💪": { - "name": "flexed biceps", - "slug": "flexed_biceps", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🦾": { - "name": "mechanical arm", - "slug": "mechanical_arm", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦿": { - "name": "mechanical leg", - "slug": "mechanical_leg", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦵": { - "name": "leg", - "slug": "leg", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🦶": { - "name": "foot", - "slug": "foot", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "👂": { - "name": "ear", - "slug": "ear", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🦻": { - "name": "ear with hearing aid", - "slug": "ear_with_hearing_aid", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👃": { - "name": "nose", - "slug": "nose", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🧠": { - "name": "brain", - "slug": "brain", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🫀": { - "name": "anatomical heart", - "slug": "anatomical_heart", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🫁": { - "name": "lungs", - "slug": "lungs", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🦷": { - "name": "tooth", - "slug": "tooth", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦴": { - "name": "bone", - "slug": "bone", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "👀": { - "name": "eyes", - "slug": "eyes", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👁️": { - "name": "eye", - "slug": "eye", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "👅": { - "name": "tongue", - "slug": "tongue", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👄": { - "name": "mouth", - "slug": "mouth", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫦": { - "name": "biting lip", - "slug": "biting_lip", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "👶": { - "name": "baby", - "slug": "baby", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🧒": { - "name": "child", - "slug": "child", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "👦": { - "name": "boy", - "slug": "boy", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👧": { - "name": "girl", - "slug": "girl", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🧑": { - "name": "person", - "slug": "person", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "👱": { - "name": "person blond hair", - "slug": "person_blond_hair", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👨": { - "name": "man", - "slug": "man", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🧔": { - "name": "person beard", - "slug": "person_beard", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧔‍♂️": { - "name": "man beard", - "slug": "man_beard", - "group": "People & Body", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "🧔‍♀️": { - "name": "woman beard", - "slug": "woman_beard", - "group": "People & Body", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👨‍🦰": { - "name": "man red hair", - "slug": "man_red_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "👨‍🦱": { - "name": "man curly hair", - "slug": "man_curly_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "👨‍🦳": { - "name": "man white hair", - "slug": "man_white_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "👨‍🦲": { - "name": "man bald", - "slug": "man_bald", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "👩": { - "name": "woman", - "slug": "woman", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👩‍🦰": { - "name": "woman red hair", - "slug": "woman_red_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🧑‍🦰": { - "name": "person red hair", - "slug": "person_red_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👩‍🦱": { - "name": "woman curly hair", - "slug": "woman_curly_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🧑‍🦱": { - "name": "person curly hair", - "slug": "person_curly_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👩‍🦳": { - "name": "woman white hair", - "slug": "woman_white_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🧑‍🦳": { - "name": "person white hair", - "slug": "person_white_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👩‍🦲": { - "name": "woman bald", - "slug": "woman_bald", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🧑‍🦲": { - "name": "person bald", - "slug": "person_bald", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👱‍♀️": { - "name": "woman blond hair", - "slug": "woman_blond_hair", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👱‍♂️": { - "name": "man blond hair", - "slug": "man_blond_hair", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧓": { - "name": "older person", - "slug": "older_person", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "👴": { - "name": "old man", - "slug": "old_man", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👵": { - "name": "old woman", - "slug": "old_woman", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙍": { - "name": "person frowning", - "slug": "person_frowning", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙍‍♂️": { - "name": "man frowning", - "slug": "man_frowning", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙍‍♀️": { - "name": "woman frowning", - "slug": "woman_frowning", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙎": { - "name": "person pouting", - "slug": "person_pouting", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙎‍♂️": { - "name": "man pouting", - "slug": "man_pouting", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙎‍♀️": { - "name": "woman pouting", - "slug": "woman_pouting", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙅": { - "name": "person gesturing NO", - "slug": "person_gesturing_no", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙅‍♂️": { - "name": "man gesturing NO", - "slug": "man_gesturing_no", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙅‍♀️": { - "name": "woman gesturing NO", - "slug": "woman_gesturing_no", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙆": { - "name": "person gesturing OK", - "slug": "person_gesturing_ok", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙆‍♂️": { - "name": "man gesturing OK", - "slug": "man_gesturing_ok", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙆‍♀️": { - "name": "woman gesturing OK", - "slug": "woman_gesturing_ok", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💁": { - "name": "person tipping hand", - "slug": "person_tipping_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "💁‍♂️": { - "name": "man tipping hand", - "slug": "man_tipping_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💁‍♀️": { - "name": "woman tipping hand", - "slug": "woman_tipping_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙋": { - "name": "person raising hand", - "slug": "person_raising_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙋‍♂️": { - "name": "man raising hand", - "slug": "man_raising_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙋‍♀️": { - "name": "woman raising hand", - "slug": "woman_raising_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧏": { - "name": "deaf person", - "slug": "deaf_person", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧏‍♂️": { - "name": "deaf man", - "slug": "deaf_man", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧏‍♀️": { - "name": "deaf woman", - "slug": "deaf_woman", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🙇": { - "name": "person bowing", - "slug": "person_bowing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🙇‍♂️": { - "name": "man bowing", - "slug": "man_bowing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🙇‍♀️": { - "name": "woman bowing", - "slug": "woman_bowing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤦": { - "name": "person facepalming", - "slug": "person_facepalming", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤦‍♂️": { - "name": "man facepalming", - "slug": "man_facepalming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤦‍♀️": { - "name": "woman facepalming", - "slug": "woman_facepalming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤷": { - "name": "person shrugging", - "slug": "person_shrugging", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤷‍♂️": { - "name": "man shrugging", - "slug": "man_shrugging", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤷‍♀️": { - "name": "woman shrugging", - "slug": "woman_shrugging", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍⚕️": { - "name": "health worker", - "slug": "health_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍⚕️": { - "name": "man health worker", - "slug": "man_health_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍⚕️": { - "name": "woman health worker", - "slug": "woman_health_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🎓": { - "name": "student", - "slug": "student", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🎓": { - "name": "man student", - "slug": "man_student", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🎓": { - "name": "woman student", - "slug": "woman_student", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🏫": { - "name": "teacher", - "slug": "teacher", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🏫": { - "name": "man teacher", - "slug": "man_teacher", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🏫": { - "name": "woman teacher", - "slug": "woman_teacher", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍⚖️": { - "name": "judge", - "slug": "judge", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍⚖️": { - "name": "man judge", - "slug": "man_judge", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍⚖️": { - "name": "woman judge", - "slug": "woman_judge", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🌾": { - "name": "farmer", - "slug": "farmer", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🌾": { - "name": "man farmer", - "slug": "man_farmer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🌾": { - "name": "woman farmer", - "slug": "woman_farmer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🍳": { - "name": "cook", - "slug": "cook", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🍳": { - "name": "man cook", - "slug": "man_cook", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🍳": { - "name": "woman cook", - "slug": "woman_cook", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🔧": { - "name": "mechanic", - "slug": "mechanic", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🔧": { - "name": "man mechanic", - "slug": "man_mechanic", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🔧": { - "name": "woman mechanic", - "slug": "woman_mechanic", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🏭": { - "name": "factory worker", - "slug": "factory_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🏭": { - "name": "man factory worker", - "slug": "man_factory_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🏭": { - "name": "woman factory worker", - "slug": "woman_factory_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍💼": { - "name": "office worker", - "slug": "office_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍💼": { - "name": "man office worker", - "slug": "man_office_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍💼": { - "name": "woman office worker", - "slug": "woman_office_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🔬": { - "name": "scientist", - "slug": "scientist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🔬": { - "name": "man scientist", - "slug": "man_scientist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🔬": { - "name": "woman scientist", - "slug": "woman_scientist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍💻": { - "name": "technologist", - "slug": "technologist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍💻": { - "name": "man technologist", - "slug": "man_technologist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍💻": { - "name": "woman technologist", - "slug": "woman_technologist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🎤": { - "name": "singer", - "slug": "singer", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🎤": { - "name": "man singer", - "slug": "man_singer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🎤": { - "name": "woman singer", - "slug": "woman_singer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🎨": { - "name": "artist", - "slug": "artist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🎨": { - "name": "man artist", - "slug": "man_artist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🎨": { - "name": "woman artist", - "slug": "woman_artist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍✈️": { - "name": "pilot", - "slug": "pilot", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍✈️": { - "name": "man pilot", - "slug": "man_pilot", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍✈️": { - "name": "woman pilot", - "slug": "woman_pilot", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🚀": { - "name": "astronaut", - "slug": "astronaut", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🚀": { - "name": "man astronaut", - "slug": "man_astronaut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🚀": { - "name": "woman astronaut", - "slug": "woman_astronaut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🚒": { - "name": "firefighter", - "slug": "firefighter", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "👨‍🚒": { - "name": "man firefighter", - "slug": "man_firefighter", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👩‍🚒": { - "name": "woman firefighter", - "slug": "woman_firefighter", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👮": { - "name": "police officer", - "slug": "police_officer", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👮‍♂️": { - "name": "man police officer", - "slug": "man_police_officer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👮‍♀️": { - "name": "woman police officer", - "slug": "woman_police_officer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🕵️": { - "name": "detective", - "slug": "detective", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "🕵️‍♂️": { - "name": "man detective", - "slug": "man_detective", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🕵️‍♀️": { - "name": "woman detective", - "slug": "woman_detective", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💂": { - "name": "guard", - "slug": "guard", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "💂‍♂️": { - "name": "man guard", - "slug": "man_guard", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💂‍♀️": { - "name": "woman guard", - "slug": "woman_guard", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🥷": { - "name": "ninja", - "slug": "ninja", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "👷": { - "name": "construction worker", - "slug": "construction_worker", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👷‍♂️": { - "name": "man construction worker", - "slug": "man_construction_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👷‍♀️": { - "name": "woman construction worker", - "slug": "woman_construction_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🫅": { - "name": "person with crown", - "slug": "person_with_crown", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🤴": { - "name": "prince", - "slug": "prince", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "👸": { - "name": "princess", - "slug": "princess", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👳": { - "name": "person wearing turban", - "slug": "person_wearing_turban", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👳‍♂️": { - "name": "man wearing turban", - "slug": "man_wearing_turban", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👳‍♀️": { - "name": "woman wearing turban", - "slug": "woman_wearing_turban", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👲": { - "name": "person with skullcap", - "slug": "person_with_skullcap", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🧕": { - "name": "woman with headscarf", - "slug": "woman_with_headscarf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🤵": { - "name": "person in tuxedo", - "slug": "person_in_tuxedo", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤵‍♂️": { - "name": "man in tuxedo", - "slug": "man_in_tuxedo", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "🤵‍♀️": { - "name": "woman in tuxedo", - "slug": "woman_in_tuxedo", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "👰": { - "name": "person with veil", - "slug": "person_with_veil", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "👰‍♂️": { - "name": "man with veil", - "slug": "man_with_veil", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "👰‍♀️": { - "name": "woman with veil", - "slug": "woman_with_veil", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "🤰": { - "name": "pregnant woman", - "slug": "pregnant_woman", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🫃": { - "name": "pregnant man", - "slug": "pregnant_man", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🫄": { - "name": "pregnant person", - "slug": "pregnant_person", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "🤱": { - "name": "breast-feeding", - "slug": "breast_feeding", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "👩‍🍼": { - "name": "woman feeding baby", - "slug": "woman_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "👨‍🍼": { - "name": "man feeding baby", - "slug": "man_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "🧑‍🍼": { - "name": "person feeding baby", - "slug": "person_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "👼": { - "name": "baby angel", - "slug": "baby_angel", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🎅": { - "name": "Santa Claus", - "slug": "santa_claus", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🤶": { - "name": "Mrs. Claus", - "slug": "mrs_claus", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🧑‍🎄": { - "name": "mx claus", - "slug": "mx_claus", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "🦸": { - "name": "superhero", - "slug": "superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🦸‍♂️": { - "name": "man superhero", - "slug": "man_superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🦸‍♀️": { - "name": "woman superhero", - "slug": "woman_superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🦹": { - "name": "supervillain", - "slug": "supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🦹‍♂️": { - "name": "man supervillain", - "slug": "man_supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🦹‍♀️": { - "name": "woman supervillain", - "slug": "woman_supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "🧙": { - "name": "mage", - "slug": "mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧙‍♂️": { - "name": "man mage", - "slug": "man_mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧙‍♀️": { - "name": "woman mage", - "slug": "woman_mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧚": { - "name": "fairy", - "slug": "fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧚‍♂️": { - "name": "man fairy", - "slug": "man_fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧚‍♀️": { - "name": "woman fairy", - "slug": "woman_fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧛": { - "name": "vampire", - "slug": "vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧛‍♂️": { - "name": "man vampire", - "slug": "man_vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧛‍♀️": { - "name": "woman vampire", - "slug": "woman_vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧜": { - "name": "merperson", - "slug": "merperson", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧜‍♂️": { - "name": "merman", - "slug": "merman", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧜‍♀️": { - "name": "mermaid", - "slug": "mermaid", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧝": { - "name": "elf", - "slug": "elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧝‍♂️": { - "name": "man elf", - "slug": "man_elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧝‍♀️": { - "name": "woman elf", - "slug": "woman_elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧞": { - "name": "genie", - "slug": "genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧞‍♂️": { - "name": "man genie", - "slug": "man_genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧞‍♀️": { - "name": "woman genie", - "slug": "woman_genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧟": { - "name": "zombie", - "slug": "zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧟‍♂️": { - "name": "man zombie", - "slug": "man_zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧟‍♀️": { - "name": "woman zombie", - "slug": "woman_zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧌": { - "name": "troll", - "slug": "troll", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "💆": { - "name": "person getting massage", - "slug": "person_getting_massage", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "💆‍♂️": { - "name": "man getting massage", - "slug": "man_getting_massage", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💆‍♀️": { - "name": "woman getting massage", - "slug": "woman_getting_massage", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💇": { - "name": "person getting haircut", - "slug": "person_getting_haircut", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "💇‍♂️": { - "name": "man getting haircut", - "slug": "man_getting_haircut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "💇‍♀️": { - "name": "woman getting haircut", - "slug": "woman_getting_haircut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚶": { - "name": "person walking", - "slug": "person_walking", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🚶‍♂️": { - "name": "man walking", - "slug": "man_walking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚶‍♀️": { - "name": "woman walking", - "slug": "woman_walking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚶‍➡️": { - "name": "person walking facing right", - "slug": "person_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🚶‍♀️‍➡️": { - "name": "woman walking facing right", - "slug": "woman_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🚶‍♂️‍➡️": { - "name": "man walking facing right", - "slug": "man_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🧍": { - "name": "person standing", - "slug": "person_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧍‍♂️": { - "name": "man standing", - "slug": "man_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧍‍♀️": { - "name": "woman standing", - "slug": "woman_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧎": { - "name": "person kneeling", - "slug": "person_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧎‍♂️": { - "name": "man kneeling", - "slug": "man_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧎‍♀️": { - "name": "woman kneeling", - "slug": "woman_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "🧎‍➡️": { - "name": "person kneeling facing right", - "slug": "person_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🧎‍♀️‍➡️": { - "name": "woman kneeling facing right", - "slug": "woman_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🧎‍♂️‍➡️": { - "name": "man kneeling facing right", - "slug": "man_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🧑‍🦯": { - "name": "person with white cane", - "slug": "person_with_white_cane", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "🧑‍🦯‍➡️": { - "name": "person with white cane facing right", - "slug": "person_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "👨‍🦯": { - "name": "man with white cane", - "slug": "man_with_white_cane", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👨‍🦯‍➡️": { - "name": "man with white cane facing right", - "slug": "man_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "👩‍🦯": { - "name": "woman with white cane", - "slug": "woman_with_white_cane", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👩‍🦯‍➡️": { - "name": "woman with white cane facing right", - "slug": "woman_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🧑‍🦼": { - "name": "person in motorized wheelchair", - "slug": "person_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "🧑‍🦼‍➡️": { - "name": "person in motorized wheelchair facing right", - "slug": "person_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "👨‍🦼": { - "name": "man in motorized wheelchair", - "slug": "man_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👨‍🦼‍➡️": { - "name": "man in motorized wheelchair facing right", - "slug": "man_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "👩‍🦼": { - "name": "woman in motorized wheelchair", - "slug": "woman_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👩‍🦼‍➡️": { - "name": "woman in motorized wheelchair facing right", - "slug": "woman_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🧑‍🦽": { - "name": "person in manual wheelchair", - "slug": "person_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "🧑‍🦽‍➡️": { - "name": "person in manual wheelchair facing right", - "slug": "person_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "👨‍🦽": { - "name": "man in manual wheelchair", - "slug": "man_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👨‍🦽‍➡️": { - "name": "man in manual wheelchair facing right", - "slug": "man_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "👩‍🦽": { - "name": "woman in manual wheelchair", - "slug": "woman_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👩‍🦽‍➡️": { - "name": "woman in manual wheelchair facing right", - "slug": "woman_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🏃": { - "name": "person running", - "slug": "person_running", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🏃‍♂️": { - "name": "man running", - "slug": "man_running", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏃‍♀️": { - "name": "woman running", - "slug": "woman_running", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏃‍➡️": { - "name": "person running facing right", - "slug": "person_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🏃‍♀️‍➡️": { - "name": "woman running facing right", - "slug": "woman_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "🏃‍♂️‍➡️": { - "name": "man running facing right", - "slug": "man_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "💃": { - "name": "woman dancing", - "slug": "woman_dancing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🕺": { - "name": "man dancing", - "slug": "man_dancing", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🕴️": { - "name": "person in suit levitating", - "slug": "person_in_suit_levitating", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "👯": { - "name": "people with bunny ears", - "slug": "people_with_bunny_ears", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👯‍♂️": { - "name": "men with bunny ears", - "slug": "men_with_bunny_ears", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👯‍♀️": { - "name": "women with bunny ears", - "slug": "women_with_bunny_ears", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "🧖": { - "name": "person in steamy room", - "slug": "person_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧖‍♂️": { - "name": "man in steamy room", - "slug": "man_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧖‍♀️": { - "name": "woman in steamy room", - "slug": "woman_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧗": { - "name": "person climbing", - "slug": "person_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧗‍♂️": { - "name": "man climbing", - "slug": "man_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧗‍♀️": { - "name": "woman climbing", - "slug": "woman_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🤺": { - "name": "person fencing", - "slug": "person_fencing", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🏇": { - "name": "horse racing", - "slug": "horse_racing", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "⛷️": { - "name": "skier", - "slug": "skier", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏂": { - "name": "snowboarder", - "slug": "snowboarder", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🏌️": { - "name": "person golfing", - "slug": "person_golfing", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏌️‍♂️": { - "name": "man golfing", - "slug": "man_golfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏌️‍♀️": { - "name": "woman golfing", - "slug": "woman_golfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏄": { - "name": "person surfing", - "slug": "person_surfing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🏄‍♂️": { - "name": "man surfing", - "slug": "man_surfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏄‍♀️": { - "name": "woman surfing", - "slug": "woman_surfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚣": { - "name": "person rowing boat", - "slug": "person_rowing_boat", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🚣‍♂️": { - "name": "man rowing boat", - "slug": "man_rowing_boat", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚣‍♀️": { - "name": "woman rowing boat", - "slug": "woman_rowing_boat", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏊": { - "name": "person swimming", - "slug": "person_swimming", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🏊‍♂️": { - "name": "man swimming", - "slug": "man_swimming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏊‍♀️": { - "name": "woman swimming", - "slug": "woman_swimming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "⛹️": { - "name": "person bouncing ball", - "slug": "person_bouncing_ball", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "⛹️‍♂️": { - "name": "man bouncing ball", - "slug": "man_bouncing_ball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "⛹️‍♀️": { - "name": "woman bouncing ball", - "slug": "woman_bouncing_ball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏋️": { - "name": "person lifting weights", - "slug": "person_lifting_weights", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "🏋️‍♂️": { - "name": "man lifting weights", - "slug": "man_lifting_weights", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🏋️‍♀️": { - "name": "woman lifting weights", - "slug": "woman_lifting_weights", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚴": { - "name": "person biking", - "slug": "person_biking", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🚴‍♂️": { - "name": "man biking", - "slug": "man_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚴‍♀️": { - "name": "woman biking", - "slug": "woman_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚵": { - "name": "person mountain biking", - "slug": "person_mountain_biking", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🚵‍♂️": { - "name": "man mountain biking", - "slug": "man_mountain_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🚵‍♀️": { - "name": "woman mountain biking", - "slug": "woman_mountain_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤸": { - "name": "person cartwheeling", - "slug": "person_cartwheeling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤸‍♂️": { - "name": "man cartwheeling", - "slug": "man_cartwheeling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤸‍♀️": { - "name": "woman cartwheeling", - "slug": "woman_cartwheeling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤼": { - "name": "people wrestling", - "slug": "people_wrestling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🤼‍♂️": { - "name": "men wrestling", - "slug": "men_wrestling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "🤼‍♀️": { - "name": "women wrestling", - "slug": "women_wrestling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "🤽": { - "name": "person playing water polo", - "slug": "person_playing_water_polo", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤽‍♂️": { - "name": "man playing water polo", - "slug": "man_playing_water_polo", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤽‍♀️": { - "name": "woman playing water polo", - "slug": "woman_playing_water_polo", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤾": { - "name": "person playing handball", - "slug": "person_playing_handball", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤾‍♂️": { - "name": "man playing handball", - "slug": "man_playing_handball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤾‍♀️": { - "name": "woman playing handball", - "slug": "woman_playing_handball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤹": { - "name": "person juggling", - "slug": "person_juggling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "🤹‍♂️": { - "name": "man juggling", - "slug": "man_juggling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🤹‍♀️": { - "name": "woman juggling", - "slug": "woman_juggling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧘": { - "name": "person in lotus position", - "slug": "person_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧘‍♂️": { - "name": "man in lotus position", - "slug": "man_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🧘‍♀️": { - "name": "woman in lotus position", - "slug": "woman_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "🛀": { - "name": "person taking bath", - "slug": "person_taking_bath", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "🛌": { - "name": "person in bed", - "slug": "person_in_bed", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "🧑‍🤝‍🧑": { - "name": "people holding hands", - "slug": "people_holding_hands", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👭": { - "name": "women holding hands", - "slug": "women_holding_hands", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👫": { - "name": "woman and man holding hands", - "slug": "woman_and_man_holding_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "👬": { - "name": "men holding hands", - "slug": "men_holding_hands", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "💏": { - "name": "kiss", - "slug": "kiss", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👩‍❤️‍💋‍👨": { - "name": "kiss woman, man", - "slug": "kiss_woman_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👨‍❤️‍💋‍👨": { - "name": "kiss man, man", - "slug": "kiss_man_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👩‍❤️‍💋‍👩": { - "name": "kiss woman, woman", - "slug": "kiss_woman_woman", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "💑": { - "name": "couple with heart", - "slug": "couple_with_heart", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👩‍❤️‍👨": { - "name": "couple with heart woman, man", - "slug": "couple_with_heart_woman_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👨‍❤️‍👨": { - "name": "couple with heart man, man", - "slug": "couple_with_heart_man_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👩‍❤️‍👩": { - "name": "couple with heart woman, woman", - "slug": "couple_with_heart_woman_woman", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "👨‍👩‍👦": { - "name": "family man, woman, boy", - "slug": "family_man_woman_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👩‍👧": { - "name": "family man, woman, girl", - "slug": "family_man_woman_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👩‍👧‍👦": { - "name": "family man, woman, girl, boy", - "slug": "family_man_woman_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👩‍👦‍👦": { - "name": "family man, woman, boy, boy", - "slug": "family_man_woman_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👩‍👧‍👧": { - "name": "family man, woman, girl, girl", - "slug": "family_man_woman_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👨‍👦": { - "name": "family man, man, boy", - "slug": "family_man_man_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👨‍👧": { - "name": "family man, man, girl", - "slug": "family_man_man_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👨‍👧‍👦": { - "name": "family man, man, girl, boy", - "slug": "family_man_man_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👨‍👦‍👦": { - "name": "family man, man, boy, boy", - "slug": "family_man_man_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👨‍👧‍👧": { - "name": "family man, man, girl, girl", - "slug": "family_man_man_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👩‍👩‍👦": { - "name": "family woman, woman, boy", - "slug": "family_woman_woman_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👩‍👩‍👧": { - "name": "family woman, woman, girl", - "slug": "family_woman_woman_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👩‍👩‍👧‍👦": { - "name": "family woman, woman, girl, boy", - "slug": "family_woman_woman_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👩‍👩‍👦‍👦": { - "name": "family woman, woman, boy, boy", - "slug": "family_woman_woman_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👩‍👩‍👧‍👧": { - "name": "family woman, woman, girl, girl", - "slug": "family_woman_woman_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "👨‍👦": { - "name": "family man, boy", - "slug": "family_man_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👨‍👦‍👦": { - "name": "family man, boy, boy", - "slug": "family_man_boy_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👨‍👧": { - "name": "family man, girl", - "slug": "family_man_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👨‍👧‍👦": { - "name": "family man, girl, boy", - "slug": "family_man_girl_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👨‍👧‍👧": { - "name": "family man, girl, girl", - "slug": "family_man_girl_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👩‍👦": { - "name": "family woman, boy", - "slug": "family_woman_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👩‍👦‍👦": { - "name": "family woman, boy, boy", - "slug": "family_woman_boy_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👩‍👧": { - "name": "family woman, girl", - "slug": "family_woman_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👩‍👧‍👦": { - "name": "family woman, girl, boy", - "slug": "family_woman_girl_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "👩‍👧‍👧": { - "name": "family woman, girl, girl", - "slug": "family_woman_girl_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "🗣️": { - "name": "speaking head", - "slug": "speaking_head", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "👤": { - "name": "bust in silhouette", - "slug": "bust_in_silhouette", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👥": { - "name": "busts in silhouette", - "slug": "busts_in_silhouette", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🫂": { - "name": "people hugging", - "slug": "people_hugging", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "👪": { - "name": "family", - "slug": "family", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧑‍🧑‍🧒": { - "name": "family adult, adult, child", - "slug": "family_adult_adult_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🧑‍🧑‍🧒‍🧒": { - "name": "family adult, adult, child, child", - "slug": "family_adult_adult_child_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🧑‍🧒": { - "name": "family adult, child", - "slug": "family_adult_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🧑‍🧒‍🧒": { - "name": "family adult, child, child", - "slug": "family_adult_child_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "👣": { - "name": "footprints", - "slug": "footprints", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐵": { - "name": "monkey face", - "slug": "monkey_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐒": { - "name": "monkey", - "slug": "monkey", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦍": { - "name": "gorilla", - "slug": "gorilla", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦧": { - "name": "orangutan", - "slug": "orangutan", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🐶": { - "name": "dog face", - "slug": "dog_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐕": { - "name": "dog", - "slug": "dog", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🦮": { - "name": "guide dog", - "slug": "guide_dog", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🐕‍🦺": { - "name": "service dog", - "slug": "service_dog", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🐩": { - "name": "poodle", - "slug": "poodle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐺": { - "name": "wolf", - "slug": "wolf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦊": { - "name": "fox", - "slug": "fox", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦝": { - "name": "raccoon", - "slug": "raccoon", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🐱": { - "name": "cat face", - "slug": "cat_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐈": { - "name": "cat", - "slug": "cat", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🐈‍⬛": { - "name": "black cat", - "slug": "black_cat", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🦁": { - "name": "lion", - "slug": "lion", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐯": { - "name": "tiger face", - "slug": "tiger_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐅": { - "name": "tiger", - "slug": "tiger", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐆": { - "name": "leopard", - "slug": "leopard", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐴": { - "name": "horse face", - "slug": "horse_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫎": { - "name": "moose", - "slug": "moose", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🫏": { - "name": "donkey", - "slug": "donkey", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🐎": { - "name": "horse", - "slug": "horse", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦄": { - "name": "unicorn", - "slug": "unicorn", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🦓": { - "name": "zebra", - "slug": "zebra", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🦌": { - "name": "deer", - "slug": "deer", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦬": { - "name": "bison", - "slug": "bison", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🐮": { - "name": "cow face", - "slug": "cow_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐂": { - "name": "ox", - "slug": "ox", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐃": { - "name": "water buffalo", - "slug": "water_buffalo", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐄": { - "name": "cow", - "slug": "cow", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐷": { - "name": "pig face", - "slug": "pig_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐖": { - "name": "pig", - "slug": "pig", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐗": { - "name": "boar", - "slug": "boar", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐽": { - "name": "pig nose", - "slug": "pig_nose", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐏": { - "name": "ram", - "slug": "ram", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐑": { - "name": "ewe", - "slug": "ewe", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐐": { - "name": "goat", - "slug": "goat", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐪": { - "name": "camel", - "slug": "camel", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐫": { - "name": "two-hump camel", - "slug": "two_hump_camel", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦙": { - "name": "llama", - "slug": "llama", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦒": { - "name": "giraffe", - "slug": "giraffe", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🐘": { - "name": "elephant", - "slug": "elephant", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦣": { - "name": "mammoth", - "slug": "mammoth", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🦏": { - "name": "rhinoceros", - "slug": "rhinoceros", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦛": { - "name": "hippopotamus", - "slug": "hippopotamus", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🐭": { - "name": "mouse face", - "slug": "mouse_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐁": { - "name": "mouse", - "slug": "mouse", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐀": { - "name": "rat", - "slug": "rat", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐹": { - "name": "hamster", - "slug": "hamster", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐰": { - "name": "rabbit face", - "slug": "rabbit_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐇": { - "name": "rabbit", - "slug": "rabbit", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐿️": { - "name": "chipmunk", - "slug": "chipmunk", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🦫": { - "name": "beaver", - "slug": "beaver", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🦔": { - "name": "hedgehog", - "slug": "hedgehog", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🦇": { - "name": "bat", - "slug": "bat", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🐻": { - "name": "bear", - "slug": "bear", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐻‍❄️": { - "name": "polar bear", - "slug": "polar_bear", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🐨": { - "name": "koala", - "slug": "koala", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐼": { - "name": "panda", - "slug": "panda", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦥": { - "name": "sloth", - "slug": "sloth", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦦": { - "name": "otter", - "slug": "otter", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦨": { - "name": "skunk", - "slug": "skunk", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦘": { - "name": "kangaroo", - "slug": "kangaroo", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦡": { - "name": "badger", - "slug": "badger", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🐾": { - "name": "paw prints", - "slug": "paw_prints", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦃": { - "name": "turkey", - "slug": "turkey", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐔": { - "name": "chicken", - "slug": "chicken", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐓": { - "name": "rooster", - "slug": "rooster", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐣": { - "name": "hatching chick", - "slug": "hatching_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐤": { - "name": "baby chick", - "slug": "baby_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐥": { - "name": "front-facing baby chick", - "slug": "front_facing_baby_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐦": { - "name": "bird", - "slug": "bird", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐧": { - "name": "penguin", - "slug": "penguin", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕊️": { - "name": "dove", - "slug": "dove", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🦅": { - "name": "eagle", - "slug": "eagle", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦆": { - "name": "duck", - "slug": "duck", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦢": { - "name": "swan", - "slug": "swan", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦉": { - "name": "owl", - "slug": "owl", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦤": { - "name": "dodo", - "slug": "dodo", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪶": { - "name": "feather", - "slug": "feather", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🦩": { - "name": "flamingo", - "slug": "flamingo", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦚": { - "name": "peacock", - "slug": "peacock", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦜": { - "name": "parrot", - "slug": "parrot", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪽": { - "name": "wing", - "slug": "wing", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🐦‍⬛": { - "name": "black bird", - "slug": "black_bird", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🪿": { - "name": "goose", - "slug": "goose", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🐦‍🔥": { - "name": "phoenix", - "slug": "phoenix", - "group": "Animals & Nature", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🐸": { - "name": "frog", - "slug": "frog", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐊": { - "name": "crocodile", - "slug": "crocodile", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐢": { - "name": "turtle", - "slug": "turtle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦎": { - "name": "lizard", - "slug": "lizard", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🐍": { - "name": "snake", - "slug": "snake", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐲": { - "name": "dragon face", - "slug": "dragon_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐉": { - "name": "dragon", - "slug": "dragon", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🦕": { - "name": "sauropod", - "slug": "sauropod", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🦖": { - "name": "T-Rex", - "slug": "t_rex", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🐳": { - "name": "spouting whale", - "slug": "spouting_whale", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐋": { - "name": "whale", - "slug": "whale", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🐬": { - "name": "dolphin", - "slug": "dolphin", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦭": { - "name": "seal", - "slug": "seal", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🐟": { - "name": "fish", - "slug": "fish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐠": { - "name": "tropical fish", - "slug": "tropical_fish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐡": { - "name": "blowfish", - "slug": "blowfish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦈": { - "name": "shark", - "slug": "shark", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🐙": { - "name": "octopus", - "slug": "octopus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐚": { - "name": "spiral shell", - "slug": "spiral_shell", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪸": { - "name": "coral", - "slug": "coral", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🪼": { - "name": "jellyfish", - "slug": "jellyfish", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🐌": { - "name": "snail", - "slug": "snail", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦋": { - "name": "butterfly", - "slug": "butterfly", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🐛": { - "name": "bug", - "slug": "bug", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐜": { - "name": "ant", - "slug": "ant", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🐝": { - "name": "honeybee", - "slug": "honeybee", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪲": { - "name": "beetle", - "slug": "beetle", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🐞": { - "name": "lady beetle", - "slug": "lady_beetle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🦗": { - "name": "cricket", - "slug": "cricket", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🪳": { - "name": "cockroach", - "slug": "cockroach", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🕷️": { - "name": "spider", - "slug": "spider", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕸️": { - "name": "spider web", - "slug": "spider_web", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🦂": { - "name": "scorpion", - "slug": "scorpion", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🦟": { - "name": "mosquito", - "slug": "mosquito", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪰": { - "name": "fly", - "slug": "fly", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪱": { - "name": "worm", - "slug": "worm", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🦠": { - "name": "microbe", - "slug": "microbe", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "💐": { - "name": "bouquet", - "slug": "bouquet", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌸": { - "name": "cherry blossom", - "slug": "cherry_blossom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💮": { - "name": "white flower", - "slug": "white_flower", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪷": { - "name": "lotus", - "slug": "lotus", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🏵️": { - "name": "rosette", - "slug": "rosette", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌹": { - "name": "rose", - "slug": "rose", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥀": { - "name": "wilted flower", - "slug": "wilted_flower", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🌺": { - "name": "hibiscus", - "slug": "hibiscus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌻": { - "name": "sunflower", - "slug": "sunflower", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌼": { - "name": "blossom", - "slug": "blossom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌷": { - "name": "tulip", - "slug": "tulip", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪻": { - "name": "hyacinth", - "slug": "hyacinth", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🌱": { - "name": "seedling", - "slug": "seedling", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪴": { - "name": "potted plant", - "slug": "potted_plant", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🌲": { - "name": "evergreen tree", - "slug": "evergreen_tree", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌳": { - "name": "deciduous tree", - "slug": "deciduous_tree", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌴": { - "name": "palm tree", - "slug": "palm_tree", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌵": { - "name": "cactus", - "slug": "cactus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌾": { - "name": "sheaf of rice", - "slug": "sheaf_of_rice", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌿": { - "name": "herb", - "slug": "herb", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☘️": { - "name": "shamrock", - "slug": "shamrock", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🍀": { - "name": "four leaf clover", - "slug": "four_leaf_clover", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍁": { - "name": "maple leaf", - "slug": "maple_leaf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍂": { - "name": "fallen leaf", - "slug": "fallen_leaf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍃": { - "name": "leaf fluttering in wind", - "slug": "leaf_fluttering_in_wind", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪹": { - "name": "empty nest", - "slug": "empty_nest", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🪺": { - "name": "nest with eggs", - "slug": "nest_with_eggs", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🍄": { - "name": "mushroom", - "slug": "mushroom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍇": { - "name": "grapes", - "slug": "grapes", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍈": { - "name": "melon", - "slug": "melon", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍉": { - "name": "watermelon", - "slug": "watermelon", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍊": { - "name": "tangerine", - "slug": "tangerine", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍋": { - "name": "lemon", - "slug": "lemon", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🍋‍🟩": { - "name": "lime", - "slug": "lime", - "group": "Food & Drink", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🍌": { - "name": "banana", - "slug": "banana", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍍": { - "name": "pineapple", - "slug": "pineapple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥭": { - "name": "mango", - "slug": "mango", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🍎": { - "name": "red apple", - "slug": "red_apple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍏": { - "name": "green apple", - "slug": "green_apple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍐": { - "name": "pear", - "slug": "pear", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🍑": { - "name": "peach", - "slug": "peach", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍒": { - "name": "cherries", - "slug": "cherries", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍓": { - "name": "strawberry", - "slug": "strawberry", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫐": { - "name": "blueberries", - "slug": "blueberries", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🥝": { - "name": "kiwi fruit", - "slug": "kiwi_fruit", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🍅": { - "name": "tomato", - "slug": "tomato", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫒": { - "name": "olive", - "slug": "olive", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🥥": { - "name": "coconut", - "slug": "coconut", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥑": { - "name": "avocado", - "slug": "avocado", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🍆": { - "name": "eggplant", - "slug": "eggplant", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥔": { - "name": "potato", - "slug": "potato", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥕": { - "name": "carrot", - "slug": "carrot", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🌽": { - "name": "ear of corn", - "slug": "ear_of_corn", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌶️": { - "name": "hot pepper", - "slug": "hot_pepper", - "group": "Food & Drink", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🫑": { - "name": "bell pepper", - "slug": "bell_pepper", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🥒": { - "name": "cucumber", - "slug": "cucumber", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥬": { - "name": "leafy green", - "slug": "leafy_green", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥦": { - "name": "broccoli", - "slug": "broccoli", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧄": { - "name": "garlic", - "slug": "garlic", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🧅": { - "name": "onion", - "slug": "onion", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🥜": { - "name": "peanuts", - "slug": "peanuts", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🫘": { - "name": "beans", - "slug": "beans", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🌰": { - "name": "chestnut", - "slug": "chestnut", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫚": { - "name": "ginger root", - "slug": "ginger_root", - "group": "Food & Drink", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🫛": { - "name": "pea pod", - "slug": "pea_pod", - "group": "Food & Drink", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🍄‍🟫": { - "name": "brown mushroom", - "slug": "brown_mushroom", - "group": "Food & Drink", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "🍞": { - "name": "bread", - "slug": "bread", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥐": { - "name": "croissant", - "slug": "croissant", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥖": { - "name": "baguette bread", - "slug": "baguette_bread", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🫓": { - "name": "flatbread", - "slug": "flatbread", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🥨": { - "name": "pretzel", - "slug": "pretzel", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥯": { - "name": "bagel", - "slug": "bagel", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥞": { - "name": "pancakes", - "slug": "pancakes", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🧇": { - "name": "waffle", - "slug": "waffle", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🧀": { - "name": "cheese wedge", - "slug": "cheese_wedge", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🍖": { - "name": "meat on bone", - "slug": "meat_on_bone", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍗": { - "name": "poultry leg", - "slug": "poultry_leg", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥩": { - "name": "cut of meat", - "slug": "cut_of_meat", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥓": { - "name": "bacon", - "slug": "bacon", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🍔": { - "name": "hamburger", - "slug": "hamburger", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍟": { - "name": "french fries", - "slug": "french_fries", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍕": { - "name": "pizza", - "slug": "pizza", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌭": { - "name": "hot dog", - "slug": "hot_dog", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥪": { - "name": "sandwich", - "slug": "sandwich", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🌮": { - "name": "taco", - "slug": "taco", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌯": { - "name": "burrito", - "slug": "burrito", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🫔": { - "name": "tamale", - "slug": "tamale", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🥙": { - "name": "stuffed flatbread", - "slug": "stuffed_flatbread", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🧆": { - "name": "falafel", - "slug": "falafel", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🥚": { - "name": "egg", - "slug": "egg", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🍳": { - "name": "cooking", - "slug": "cooking", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥘": { - "name": "shallow pan of food", - "slug": "shallow_pan_of_food", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🍲": { - "name": "pot of food", - "slug": "pot_of_food", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫕": { - "name": "fondue", - "slug": "fondue", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🥣": { - "name": "bowl with spoon", - "slug": "bowl_with_spoon", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥗": { - "name": "green salad", - "slug": "green_salad", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🍿": { - "name": "popcorn", - "slug": "popcorn", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🧈": { - "name": "butter", - "slug": "butter", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🧂": { - "name": "salt", - "slug": "salt", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥫": { - "name": "canned food", - "slug": "canned_food", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🍱": { - "name": "bento box", - "slug": "bento_box", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍘": { - "name": "rice cracker", - "slug": "rice_cracker", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍙": { - "name": "rice ball", - "slug": "rice_ball", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍚": { - "name": "cooked rice", - "slug": "cooked_rice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍛": { - "name": "curry rice", - "slug": "curry_rice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍜": { - "name": "steaming bowl", - "slug": "steaming_bowl", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍝": { - "name": "spaghetti", - "slug": "spaghetti", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍠": { - "name": "roasted sweet potato", - "slug": "roasted_sweet_potato", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍢": { - "name": "oden", - "slug": "oden", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍣": { - "name": "sushi", - "slug": "sushi", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍤": { - "name": "fried shrimp", - "slug": "fried_shrimp", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍥": { - "name": "fish cake with swirl", - "slug": "fish_cake_with_swirl", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥮": { - "name": "moon cake", - "slug": "moon_cake", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🍡": { - "name": "dango", - "slug": "dango", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥟": { - "name": "dumpling", - "slug": "dumpling", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥠": { - "name": "fortune cookie", - "slug": "fortune_cookie", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥡": { - "name": "takeout box", - "slug": "takeout_box", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🦀": { - "name": "crab", - "slug": "crab", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🦞": { - "name": "lobster", - "slug": "lobster", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦐": { - "name": "shrimp", - "slug": "shrimp", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦑": { - "name": "squid", - "slug": "squid", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦪": { - "name": "oyster", - "slug": "oyster", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🍦": { - "name": "soft ice cream", - "slug": "soft_ice_cream", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍧": { - "name": "shaved ice", - "slug": "shaved_ice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍨": { - "name": "ice cream", - "slug": "ice_cream", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍩": { - "name": "doughnut", - "slug": "doughnut", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍪": { - "name": "cookie", - "slug": "cookie", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎂": { - "name": "birthday cake", - "slug": "birthday_cake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍰": { - "name": "shortcake", - "slug": "shortcake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧁": { - "name": "cupcake", - "slug": "cupcake", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥧": { - "name": "pie", - "slug": "pie", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🍫": { - "name": "chocolate bar", - "slug": "chocolate_bar", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍬": { - "name": "candy", - "slug": "candy", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍭": { - "name": "lollipop", - "slug": "lollipop", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍮": { - "name": "custard", - "slug": "custard", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍯": { - "name": "honey pot", - "slug": "honey_pot", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍼": { - "name": "baby bottle", - "slug": "baby_bottle", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥛": { - "name": "glass of milk", - "slug": "glass_of_milk", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "☕": { - "name": "hot beverage", - "slug": "hot_beverage", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫖": { - "name": "teapot", - "slug": "teapot", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🍵": { - "name": "teacup without handle", - "slug": "teacup_without_handle", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍶": { - "name": "sake", - "slug": "sake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍾": { - "name": "bottle with popping cork", - "slug": "bottle_with_popping_cork", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🍷": { - "name": "wine glass", - "slug": "wine_glass", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍸": { - "name": "cocktail glass", - "slug": "cocktail_glass", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍹": { - "name": "tropical drink", - "slug": "tropical_drink", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍺": { - "name": "beer mug", - "slug": "beer_mug", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🍻": { - "name": "clinking beer mugs", - "slug": "clinking_beer_mugs", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥂": { - "name": "clinking glasses", - "slug": "clinking_glasses", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥃": { - "name": "tumbler glass", - "slug": "tumbler_glass", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🫗": { - "name": "pouring liquid", - "slug": "pouring_liquid", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🥤": { - "name": "cup with straw", - "slug": "cup_with_straw", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧋": { - "name": "bubble tea", - "slug": "bubble_tea", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🧃": { - "name": "beverage box", - "slug": "beverage_box", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🧉": { - "name": "mate", - "slug": "mate", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🧊": { - "name": "ice", - "slug": "ice", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🥢": { - "name": "chopsticks", - "slug": "chopsticks", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🍽️": { - "name": "fork and knife with plate", - "slug": "fork_and_knife_with_plate", - "group": "Food & Drink", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🍴": { - "name": "fork and knife", - "slug": "fork_and_knife", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥄": { - "name": "spoon", - "slug": "spoon", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🔪": { - "name": "kitchen knife", - "slug": "kitchen_knife", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🫙": { - "name": "jar", - "slug": "jar", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🏺": { - "name": "amphora", - "slug": "amphora", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌍": { - "name": "globe showing Europe-Africa", - "slug": "globe_showing_europe_africa", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌎": { - "name": "globe showing Americas", - "slug": "globe_showing_americas", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌏": { - "name": "globe showing Asia-Australia", - "slug": "globe_showing_asia_australia", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌐": { - "name": "globe with meridians", - "slug": "globe_with_meridians", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🗺️": { - "name": "world map", - "slug": "world_map", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🗾": { - "name": "map of Japan", - "slug": "map_of_japan", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧭": { - "name": "compass", - "slug": "compass", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🏔️": { - "name": "snow-capped mountain", - "slug": "snow_capped_mountain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⛰️": { - "name": "mountain", - "slug": "mountain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌋": { - "name": "volcano", - "slug": "volcano", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗻": { - "name": "mount fuji", - "slug": "mount_fuji", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏕️": { - "name": "camping", - "slug": "camping", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏖️": { - "name": "beach with umbrella", - "slug": "beach_with_umbrella", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏜️": { - "name": "desert", - "slug": "desert", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏝️": { - "name": "desert island", - "slug": "desert_island", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏞️": { - "name": "national park", - "slug": "national_park", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏟️": { - "name": "stadium", - "slug": "stadium", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏛️": { - "name": "classical building", - "slug": "classical_building", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏗️": { - "name": "building construction", - "slug": "building_construction", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🧱": { - "name": "brick", - "slug": "brick", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪨": { - "name": "rock", - "slug": "rock", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪵": { - "name": "wood", - "slug": "wood", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🛖": { - "name": "hut", - "slug": "hut", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🏘️": { - "name": "houses", - "slug": "houses", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏚️": { - "name": "derelict house", - "slug": "derelict_house", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏠": { - "name": "house", - "slug": "house", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏡": { - "name": "house with garden", - "slug": "house_with_garden", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏢": { - "name": "office building", - "slug": "office_building", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏣": { - "name": "Japanese post office", - "slug": "japanese_post_office", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏤": { - "name": "post office", - "slug": "post_office", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏥": { - "name": "hospital", - "slug": "hospital", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏦": { - "name": "bank", - "slug": "bank", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏨": { - "name": "hotel", - "slug": "hotel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏩": { - "name": "love hotel", - "slug": "love_hotel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏪": { - "name": "convenience store", - "slug": "convenience_store", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏫": { - "name": "school", - "slug": "school", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏬": { - "name": "department store", - "slug": "department_store", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏭": { - "name": "factory", - "slug": "factory", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏯": { - "name": "Japanese castle", - "slug": "japanese_castle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏰": { - "name": "castle", - "slug": "castle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💒": { - "name": "wedding", - "slug": "wedding", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗼": { - "name": "Tokyo tower", - "slug": "tokyo_tower", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗽": { - "name": "Statue of Liberty", - "slug": "statue_of_liberty", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛪": { - "name": "church", - "slug": "church", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕌": { - "name": "mosque", - "slug": "mosque", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛕": { - "name": "hindu temple", - "slug": "hindu_temple", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🕍": { - "name": "synagogue", - "slug": "synagogue", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⛩️": { - "name": "shinto shrine", - "slug": "shinto_shrine", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕋": { - "name": "kaaba", - "slug": "kaaba", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⛲": { - "name": "fountain", - "slug": "fountain", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛺": { - "name": "tent", - "slug": "tent", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌁": { - "name": "foggy", - "slug": "foggy", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌃": { - "name": "night with stars", - "slug": "night_with_stars", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏙️": { - "name": "cityscape", - "slug": "cityscape", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌄": { - "name": "sunrise over mountains", - "slug": "sunrise_over_mountains", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌅": { - "name": "sunrise", - "slug": "sunrise", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌆": { - "name": "cityscape at dusk", - "slug": "cityscape_at_dusk", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌇": { - "name": "sunset", - "slug": "sunset", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌉": { - "name": "bridge at night", - "slug": "bridge_at_night", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♨️": { - "name": "hot springs", - "slug": "hot_springs", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎠": { - "name": "carousel horse", - "slug": "carousel_horse", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛝": { - "name": "playground slide", - "slug": "playground_slide", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🎡": { - "name": "ferris wheel", - "slug": "ferris_wheel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎢": { - "name": "roller coaster", - "slug": "roller_coaster", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💈": { - "name": "barber pole", - "slug": "barber_pole", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎪": { - "name": "circus tent", - "slug": "circus_tent", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚂": { - "name": "locomotive", - "slug": "locomotive", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚃": { - "name": "railway car", - "slug": "railway_car", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚄": { - "name": "high-speed train", - "slug": "high_speed_train", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚅": { - "name": "bullet train", - "slug": "bullet_train", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚆": { - "name": "train", - "slug": "train", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚇": { - "name": "metro", - "slug": "metro", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚈": { - "name": "light rail", - "slug": "light_rail", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚉": { - "name": "station", - "slug": "station", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚊": { - "name": "tram", - "slug": "tram", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚝": { - "name": "monorail", - "slug": "monorail", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚞": { - "name": "mountain railway", - "slug": "mountain_railway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚋": { - "name": "tram car", - "slug": "tram_car", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚌": { - "name": "bus", - "slug": "bus", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚍": { - "name": "oncoming bus", - "slug": "oncoming_bus", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🚎": { - "name": "trolleybus", - "slug": "trolleybus", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚐": { - "name": "minibus", - "slug": "minibus", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚑": { - "name": "ambulance", - "slug": "ambulance", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚒": { - "name": "fire engine", - "slug": "fire_engine", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚓": { - "name": "police car", - "slug": "police_car", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚔": { - "name": "oncoming police car", - "slug": "oncoming_police_car", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🚕": { - "name": "taxi", - "slug": "taxi", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚖": { - "name": "oncoming taxi", - "slug": "oncoming_taxi", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚗": { - "name": "automobile", - "slug": "automobile", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚘": { - "name": "oncoming automobile", - "slug": "oncoming_automobile", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🚙": { - "name": "sport utility vehicle", - "slug": "sport_utility_vehicle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛻": { - "name": "pickup truck", - "slug": "pickup_truck", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🚚": { - "name": "delivery truck", - "slug": "delivery_truck", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚛": { - "name": "articulated lorry", - "slug": "articulated_lorry", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚜": { - "name": "tractor", - "slug": "tractor", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏎️": { - "name": "racing car", - "slug": "racing_car", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏍️": { - "name": "motorcycle", - "slug": "motorcycle", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🛵": { - "name": "motor scooter", - "slug": "motor_scooter", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🦽": { - "name": "manual wheelchair", - "slug": "manual_wheelchair", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🦼": { - "name": "motorized wheelchair", - "slug": "motorized_wheelchair", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🛺": { - "name": "auto rickshaw", - "slug": "auto_rickshaw", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🚲": { - "name": "bicycle", - "slug": "bicycle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛴": { - "name": "kick scooter", - "slug": "kick_scooter", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🛹": { - "name": "skateboard", - "slug": "skateboard", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🛼": { - "name": "roller skate", - "slug": "roller_skate", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🚏": { - "name": "bus stop", - "slug": "bus_stop", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛣️": { - "name": "motorway", - "slug": "motorway", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🛤️": { - "name": "railway track", - "slug": "railway_track", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🛢️": { - "name": "oil drum", - "slug": "oil_drum", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⛽": { - "name": "fuel pump", - "slug": "fuel_pump", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛞": { - "name": "wheel", - "slug": "wheel", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🚨": { - "name": "police car light", - "slug": "police_car_light", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚥": { - "name": "horizontal traffic light", - "slug": "horizontal_traffic_light", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚦": { - "name": "vertical traffic light", - "slug": "vertical_traffic_light", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛑": { - "name": "stop sign", - "slug": "stop_sign", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🚧": { - "name": "construction", - "slug": "construction", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚓": { - "name": "anchor", - "slug": "anchor", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛟": { - "name": "ring buoy", - "slug": "ring_buoy", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "⛵": { - "name": "sailboat", - "slug": "sailboat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛶": { - "name": "canoe", - "slug": "canoe", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🚤": { - "name": "speedboat", - "slug": "speedboat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛳️": { - "name": "passenger ship", - "slug": "passenger_ship", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⛴️": { - "name": "ferry", - "slug": "ferry", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🛥️": { - "name": "motor boat", - "slug": "motor_boat", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🚢": { - "name": "ship", - "slug": "ship", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✈️": { - "name": "airplane", - "slug": "airplane", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛩️": { - "name": "small airplane", - "slug": "small_airplane", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🛫": { - "name": "airplane departure", - "slug": "airplane_departure", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛬": { - "name": "airplane arrival", - "slug": "airplane_arrival", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🪂": { - "name": "parachute", - "slug": "parachute", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "💺": { - "name": "seat", - "slug": "seat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚁": { - "name": "helicopter", - "slug": "helicopter", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚟": { - "name": "suspension railway", - "slug": "suspension_railway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚠": { - "name": "mountain cableway", - "slug": "mountain_cableway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚡": { - "name": "aerial tramway", - "slug": "aerial_tramway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛰️": { - "name": "satellite", - "slug": "satellite", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🚀": { - "name": "rocket", - "slug": "rocket", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛸": { - "name": "flying saucer", - "slug": "flying_saucer", - "group": "Travel & Places", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🛎️": { - "name": "bellhop bell", - "slug": "bellhop_bell", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🧳": { - "name": "luggage", - "slug": "luggage", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "⌛": { - "name": "hourglass done", - "slug": "hourglass_done", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏳": { - "name": "hourglass not done", - "slug": "hourglass_not_done", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⌚": { - "name": "watch", - "slug": "watch", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏰": { - "name": "alarm clock", - "slug": "alarm_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏱️": { - "name": "stopwatch", - "slug": "stopwatch", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⏲️": { - "name": "timer clock", - "slug": "timer_clock", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🕰️": { - "name": "mantelpiece clock", - "slug": "mantelpiece_clock", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕛": { - "name": "twelve o’clock", - "slug": "twelve_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕧": { - "name": "twelve-thirty", - "slug": "twelve_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕐": { - "name": "one o’clock", - "slug": "one_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕜": { - "name": "one-thirty", - "slug": "one_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕑": { - "name": "two o’clock", - "slug": "two_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕝": { - "name": "two-thirty", - "slug": "two_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕒": { - "name": "three o’clock", - "slug": "three_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕞": { - "name": "three-thirty", - "slug": "three_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕓": { - "name": "four o’clock", - "slug": "four_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕟": { - "name": "four-thirty", - "slug": "four_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕔": { - "name": "five o’clock", - "slug": "five_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕠": { - "name": "five-thirty", - "slug": "five_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕕": { - "name": "six o’clock", - "slug": "six_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕡": { - "name": "six-thirty", - "slug": "six_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕖": { - "name": "seven o’clock", - "slug": "seven_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕢": { - "name": "seven-thirty", - "slug": "seven_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕗": { - "name": "eight o’clock", - "slug": "eight_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕣": { - "name": "eight-thirty", - "slug": "eight_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕘": { - "name": "nine o’clock", - "slug": "nine_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕤": { - "name": "nine-thirty", - "slug": "nine_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕙": { - "name": "ten o’clock", - "slug": "ten_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕥": { - "name": "ten-thirty", - "slug": "ten_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🕚": { - "name": "eleven o’clock", - "slug": "eleven_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕦": { - "name": "eleven-thirty", - "slug": "eleven_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌑": { - "name": "new moon", - "slug": "new_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌒": { - "name": "waxing crescent moon", - "slug": "waxing_crescent_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌓": { - "name": "first quarter moon", - "slug": "first_quarter_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌔": { - "name": "waxing gibbous moon", - "slug": "waxing_gibbous_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌕": { - "name": "full moon", - "slug": "full_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌖": { - "name": "waning gibbous moon", - "slug": "waning_gibbous_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌗": { - "name": "last quarter moon", - "slug": "last_quarter_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌘": { - "name": "waning crescent moon", - "slug": "waning_crescent_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌙": { - "name": "crescent moon", - "slug": "crescent_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌚": { - "name": "new moon face", - "slug": "new_moon_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌛": { - "name": "first quarter moon face", - "slug": "first_quarter_moon_face", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌜": { - "name": "last quarter moon face", - "slug": "last_quarter_moon_face", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌡️": { - "name": "thermometer", - "slug": "thermometer", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "☀️": { - "name": "sun", - "slug": "sun", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌝": { - "name": "full moon face", - "slug": "full_moon_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🌞": { - "name": "sun with face", - "slug": "sun_with_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🪐": { - "name": "ringed planet", - "slug": "ringed_planet", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "⭐": { - "name": "star", - "slug": "star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌟": { - "name": "glowing star", - "slug": "glowing_star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌠": { - "name": "shooting star", - "slug": "shooting_star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌌": { - "name": "milky way", - "slug": "milky_way", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☁️": { - "name": "cloud", - "slug": "cloud", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛅": { - "name": "sun behind cloud", - "slug": "sun_behind_cloud", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛈️": { - "name": "cloud with lightning and rain", - "slug": "cloud_with_lightning_and_rain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌤️": { - "name": "sun behind small cloud", - "slug": "sun_behind_small_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌥️": { - "name": "sun behind large cloud", - "slug": "sun_behind_large_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌦️": { - "name": "sun behind rain cloud", - "slug": "sun_behind_rain_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌧️": { - "name": "cloud with rain", - "slug": "cloud_with_rain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌨️": { - "name": "cloud with snow", - "slug": "cloud_with_snow", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌩️": { - "name": "cloud with lightning", - "slug": "cloud_with_lightning", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌪️": { - "name": "tornado", - "slug": "tornado", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌫️": { - "name": "fog", - "slug": "fog", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌬️": { - "name": "wind face", - "slug": "wind_face", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🌀": { - "name": "cyclone", - "slug": "cyclone", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌈": { - "name": "rainbow", - "slug": "rainbow", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌂": { - "name": "closed umbrella", - "slug": "closed_umbrella", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☂️": { - "name": "umbrella", - "slug": "umbrella", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "☔": { - "name": "umbrella with rain drops", - "slug": "umbrella_with_rain_drops", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛱️": { - "name": "umbrella on ground", - "slug": "umbrella_on_ground", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⚡": { - "name": "high voltage", - "slug": "high_voltage", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❄️": { - "name": "snowflake", - "slug": "snowflake", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☃️": { - "name": "snowman", - "slug": "snowman", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⛄": { - "name": "snowman without snow", - "slug": "snowman_without_snow", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☄️": { - "name": "comet", - "slug": "comet", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔥": { - "name": "fire", - "slug": "fire", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💧": { - "name": "droplet", - "slug": "droplet", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🌊": { - "name": "water wave", - "slug": "water_wave", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎃": { - "name": "jack-o-lantern", - "slug": "jack_o_lantern", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎄": { - "name": "Christmas tree", - "slug": "christmas_tree", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎆": { - "name": "fireworks", - "slug": "fireworks", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎇": { - "name": "sparkler", - "slug": "sparkler", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧨": { - "name": "firecracker", - "slug": "firecracker", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "✨": { - "name": "sparkles", - "slug": "sparkles", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎈": { - "name": "balloon", - "slug": "balloon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎉": { - "name": "party popper", - "slug": "party_popper", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎊": { - "name": "confetti ball", - "slug": "confetti_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎋": { - "name": "tanabata tree", - "slug": "tanabata_tree", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎍": { - "name": "pine decoration", - "slug": "pine_decoration", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎎": { - "name": "Japanese dolls", - "slug": "japanese_dolls", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎏": { - "name": "carp streamer", - "slug": "carp_streamer", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎐": { - "name": "wind chime", - "slug": "wind_chime", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎑": { - "name": "moon viewing ceremony", - "slug": "moon_viewing_ceremony", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧧": { - "name": "red envelope", - "slug": "red_envelope", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🎀": { - "name": "ribbon", - "slug": "ribbon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎁": { - "name": "wrapped gift", - "slug": "wrapped_gift", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎗️": { - "name": "reminder ribbon", - "slug": "reminder_ribbon", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎟️": { - "name": "admission tickets", - "slug": "admission_tickets", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎫": { - "name": "ticket", - "slug": "ticket", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎖️": { - "name": "military medal", - "slug": "military_medal", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏆": { - "name": "trophy", - "slug": "trophy", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏅": { - "name": "sports medal", - "slug": "sports_medal", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥇": { - "name": "1st place medal", - "slug": "1st_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥈": { - "name": "2nd place medal", - "slug": "2nd_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥉": { - "name": "3rd place medal", - "slug": "3rd_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "⚽": { - "name": "soccer ball", - "slug": "soccer_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚾": { - "name": "baseball", - "slug": "baseball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥎": { - "name": "softball", - "slug": "softball", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🏀": { - "name": "basketball", - "slug": "basketball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏐": { - "name": "volleyball", - "slug": "volleyball", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏈": { - "name": "american football", - "slug": "american_football", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏉": { - "name": "rugby football", - "slug": "rugby_football", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🎾": { - "name": "tennis", - "slug": "tennis", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥏": { - "name": "flying disc", - "slug": "flying_disc", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🎳": { - "name": "bowling", - "slug": "bowling", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏏": { - "name": "cricket game", - "slug": "cricket_game", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏑": { - "name": "field hockey", - "slug": "field_hockey", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏒": { - "name": "ice hockey", - "slug": "ice_hockey", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥍": { - "name": "lacrosse", - "slug": "lacrosse", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🏓": { - "name": "ping pong", - "slug": "ping_pong", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏸": { - "name": "badminton", - "slug": "badminton", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🥊": { - "name": "boxing glove", - "slug": "boxing_glove", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥋": { - "name": "martial arts uniform", - "slug": "martial_arts_uniform", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🥅": { - "name": "goal net", - "slug": "goal_net", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "⛳": { - "name": "flag in hole", - "slug": "flag_in_hole", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛸️": { - "name": "ice skate", - "slug": "ice_skate", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎣": { - "name": "fishing pole", - "slug": "fishing_pole", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🤿": { - "name": "diving mask", - "slug": "diving_mask", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🎽": { - "name": "running shirt", - "slug": "running_shirt", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎿": { - "name": "skis", - "slug": "skis", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛷": { - "name": "sled", - "slug": "sled", - "group": "Activities", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🥌": { - "name": "curling stone", - "slug": "curling_stone", - "group": "Activities", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🎯": { - "name": "bullseye", - "slug": "bullseye", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪀": { - "name": "yo-yo", - "slug": "yo_yo", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🪁": { - "name": "kite", - "slug": "kite", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🔫": { - "name": "water pistol", - "slug": "water_pistol", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎱": { - "name": "pool 8 ball", - "slug": "pool_8_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔮": { - "name": "crystal ball", - "slug": "crystal_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪄": { - "name": "magic wand", - "slug": "magic_wand", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🎮": { - "name": "video game", - "slug": "video_game", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕹️": { - "name": "joystick", - "slug": "joystick", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎰": { - "name": "slot machine", - "slug": "slot_machine", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎲": { - "name": "game die", - "slug": "game_die", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧩": { - "name": "puzzle piece", - "slug": "puzzle_piece", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧸": { - "name": "teddy bear", - "slug": "teddy_bear", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪅": { - "name": "piñata", - "slug": "pinata", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪩": { - "name": "mirror ball", - "slug": "mirror_ball", - "group": "Activities", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🪆": { - "name": "nesting dolls", - "slug": "nesting_dolls", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "♠️": { - "name": "spade suit", - "slug": "spade_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♥️": { - "name": "heart suit", - "slug": "heart_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♦️": { - "name": "diamond suit", - "slug": "diamond_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♣️": { - "name": "club suit", - "slug": "club_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♟️": { - "name": "chess pawn", - "slug": "chess_pawn", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🃏": { - "name": "joker", - "slug": "joker", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🀄": { - "name": "mahjong red dragon", - "slug": "mahjong_red_dragon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎴": { - "name": "flower playing cards", - "slug": "flower_playing_cards", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎭": { - "name": "performing arts", - "slug": "performing_arts", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🖼️": { - "name": "framed picture", - "slug": "framed_picture", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎨": { - "name": "artist palette", - "slug": "artist_palette", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧵": { - "name": "thread", - "slug": "thread", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪡": { - "name": "sewing needle", - "slug": "sewing_needle", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🧶": { - "name": "yarn", - "slug": "yarn", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪢": { - "name": "knot", - "slug": "knot", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "👓": { - "name": "glasses", - "slug": "glasses", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕶️": { - "name": "sunglasses", - "slug": "sunglasses", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🥽": { - "name": "goggles", - "slug": "goggles", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥼": { - "name": "lab coat", - "slug": "lab_coat", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🦺": { - "name": "safety vest", - "slug": "safety_vest", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "👔": { - "name": "necktie", - "slug": "necktie", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👕": { - "name": "t-shirt", - "slug": "t_shirt", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👖": { - "name": "jeans", - "slug": "jeans", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧣": { - "name": "scarf", - "slug": "scarf", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧤": { - "name": "gloves", - "slug": "gloves", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧥": { - "name": "coat", - "slug": "coat", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🧦": { - "name": "socks", - "slug": "socks", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "👗": { - "name": "dress", - "slug": "dress", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👘": { - "name": "kimono", - "slug": "kimono", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥻": { - "name": "sari", - "slug": "sari", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🩱": { - "name": "one-piece swimsuit", - "slug": "one_piece_swimsuit", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🩲": { - "name": "briefs", - "slug": "briefs", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🩳": { - "name": "shorts", - "slug": "shorts", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "👙": { - "name": "bikini", - "slug": "bikini", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👚": { - "name": "woman’s clothes", - "slug": "woman_s_clothes", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪭": { - "name": "folding hand fan", - "slug": "folding_hand_fan", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "👛": { - "name": "purse", - "slug": "purse", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👜": { - "name": "handbag", - "slug": "handbag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👝": { - "name": "clutch bag", - "slug": "clutch_bag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛍️": { - "name": "shopping bags", - "slug": "shopping_bags", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎒": { - "name": "backpack", - "slug": "backpack", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🩴": { - "name": "thong sandal", - "slug": "thong_sandal", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "👞": { - "name": "man’s shoe", - "slug": "man_s_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👟": { - "name": "running shoe", - "slug": "running_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🥾": { - "name": "hiking boot", - "slug": "hiking_boot", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🥿": { - "name": "flat shoe", - "slug": "flat_shoe", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "👠": { - "name": "high-heeled shoe", - "slug": "high_heeled_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👡": { - "name": "woman’s sandal", - "slug": "woman_s_sandal", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🩰": { - "name": "ballet shoes", - "slug": "ballet_shoes", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "👢": { - "name": "woman’s boot", - "slug": "woman_s_boot", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪮": { - "name": "hair pick", - "slug": "hair_pick", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "👑": { - "name": "crown", - "slug": "crown", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "👒": { - "name": "woman’s hat", - "slug": "woman_s_hat", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎩": { - "name": "top hat", - "slug": "top_hat", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎓": { - "name": "graduation cap", - "slug": "graduation_cap", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧢": { - "name": "billed cap", - "slug": "billed_cap", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🪖": { - "name": "military helmet", - "slug": "military_helmet", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "⛑️": { - "name": "rescue worker’s helmet", - "slug": "rescue_worker_s_helmet", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📿": { - "name": "prayer beads", - "slug": "prayer_beads", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💄": { - "name": "lipstick", - "slug": "lipstick", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💍": { - "name": "ring", - "slug": "ring", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💎": { - "name": "gem stone", - "slug": "gem_stone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔇": { - "name": "muted speaker", - "slug": "muted_speaker", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔈": { - "name": "speaker low volume", - "slug": "speaker_low_volume", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🔉": { - "name": "speaker medium volume", - "slug": "speaker_medium_volume", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔊": { - "name": "speaker high volume", - "slug": "speaker_high_volume", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📢": { - "name": "loudspeaker", - "slug": "loudspeaker", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📣": { - "name": "megaphone", - "slug": "megaphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📯": { - "name": "postal horn", - "slug": "postal_horn", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔔": { - "name": "bell", - "slug": "bell", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔕": { - "name": "bell with slash", - "slug": "bell_with_slash", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🎼": { - "name": "musical score", - "slug": "musical_score", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎵": { - "name": "musical note", - "slug": "musical_note", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎶": { - "name": "musical notes", - "slug": "musical_notes", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎙️": { - "name": "studio microphone", - "slug": "studio_microphone", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎚️": { - "name": "level slider", - "slug": "level_slider", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎛️": { - "name": "control knobs", - "slug": "control_knobs", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎤": { - "name": "microphone", - "slug": "microphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎧": { - "name": "headphone", - "slug": "headphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📻": { - "name": "radio", - "slug": "radio", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎷": { - "name": "saxophone", - "slug": "saxophone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪗": { - "name": "accordion", - "slug": "accordion", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🎸": { - "name": "guitar", - "slug": "guitar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎹": { - "name": "musical keyboard", - "slug": "musical_keyboard", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎺": { - "name": "trumpet", - "slug": "trumpet", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎻": { - "name": "violin", - "slug": "violin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪕": { - "name": "banjo", - "slug": "banjo", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🥁": { - "name": "drum", - "slug": "drum", - "group": "Objects", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🪘": { - "name": "long drum", - "slug": "long_drum", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪇": { - "name": "maracas", - "slug": "maracas", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "🪈": { - "name": "flute", - "slug": "flute", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "📱": { - "name": "mobile phone", - "slug": "mobile_phone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📲": { - "name": "mobile phone with arrow", - "slug": "mobile_phone_with_arrow", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☎️": { - "name": "telephone", - "slug": "telephone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📞": { - "name": "telephone receiver", - "slug": "telephone_receiver", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📟": { - "name": "pager", - "slug": "pager", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📠": { - "name": "fax machine", - "slug": "fax_machine", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔋": { - "name": "battery", - "slug": "battery", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪫": { - "name": "low battery", - "slug": "low_battery", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🔌": { - "name": "electric plug", - "slug": "electric_plug", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💻": { - "name": "laptop", - "slug": "laptop", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🖥️": { - "name": "desktop computer", - "slug": "desktop_computer", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🖨️": { - "name": "printer", - "slug": "printer", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⌨️": { - "name": "keyboard", - "slug": "keyboard", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🖱️": { - "name": "computer mouse", - "slug": "computer_mouse", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🖲️": { - "name": "trackball", - "slug": "trackball", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "💽": { - "name": "computer disk", - "slug": "computer_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💾": { - "name": "floppy disk", - "slug": "floppy_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💿": { - "name": "optical disk", - "slug": "optical_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📀": { - "name": "dvd", - "slug": "dvd", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧮": { - "name": "abacus", - "slug": "abacus", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🎥": { - "name": "movie camera", - "slug": "movie_camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎞️": { - "name": "film frames", - "slug": "film_frames", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📽️": { - "name": "film projector", - "slug": "film_projector", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🎬": { - "name": "clapper board", - "slug": "clapper_board", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📺": { - "name": "television", - "slug": "television", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📷": { - "name": "camera", - "slug": "camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📸": { - "name": "camera with flash", - "slug": "camera_with_flash", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "📹": { - "name": "video camera", - "slug": "video_camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📼": { - "name": "videocassette", - "slug": "videocassette", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔍": { - "name": "magnifying glass tilted left", - "slug": "magnifying_glass_tilted_left", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔎": { - "name": "magnifying glass tilted right", - "slug": "magnifying_glass_tilted_right", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🕯️": { - "name": "candle", - "slug": "candle", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "💡": { - "name": "light bulb", - "slug": "light_bulb", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔦": { - "name": "flashlight", - "slug": "flashlight", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏮": { - "name": "red paper lantern", - "slug": "red_paper_lantern", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪔": { - "name": "diya lamp", - "slug": "diya_lamp", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "📔": { - "name": "notebook with decorative cover", - "slug": "notebook_with_decorative_cover", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📕": { - "name": "closed book", - "slug": "closed_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📖": { - "name": "open book", - "slug": "open_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📗": { - "name": "green book", - "slug": "green_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📘": { - "name": "blue book", - "slug": "blue_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📙": { - "name": "orange book", - "slug": "orange_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📚": { - "name": "books", - "slug": "books", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📓": { - "name": "notebook", - "slug": "notebook", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📒": { - "name": "ledger", - "slug": "ledger", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📃": { - "name": "page with curl", - "slug": "page_with_curl", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📜": { - "name": "scroll", - "slug": "scroll", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📄": { - "name": "page facing up", - "slug": "page_facing_up", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📰": { - "name": "newspaper", - "slug": "newspaper", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗞️": { - "name": "rolled-up newspaper", - "slug": "rolled_up_newspaper", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📑": { - "name": "bookmark tabs", - "slug": "bookmark_tabs", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔖": { - "name": "bookmark", - "slug": "bookmark", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏷️": { - "name": "label", - "slug": "label", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "💰": { - "name": "money bag", - "slug": "money_bag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪙": { - "name": "coin", - "slug": "coin", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "💴": { - "name": "yen banknote", - "slug": "yen_banknote", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💵": { - "name": "dollar banknote", - "slug": "dollar_banknote", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💶": { - "name": "euro banknote", - "slug": "euro_banknote", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💷": { - "name": "pound banknote", - "slug": "pound_banknote", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💸": { - "name": "money with wings", - "slug": "money_with_wings", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💳": { - "name": "credit card", - "slug": "credit_card", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🧾": { - "name": "receipt", - "slug": "receipt", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "💹": { - "name": "chart increasing with yen", - "slug": "chart_increasing_with_yen", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✉️": { - "name": "envelope", - "slug": "envelope", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📧": { - "name": "e-mail", - "slug": "e_mail", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📨": { - "name": "incoming envelope", - "slug": "incoming_envelope", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📩": { - "name": "envelope with arrow", - "slug": "envelope_with_arrow", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📤": { - "name": "outbox tray", - "slug": "outbox_tray", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📥": { - "name": "inbox tray", - "slug": "inbox_tray", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📦": { - "name": "package", - "slug": "package", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📫": { - "name": "closed mailbox with raised flag", - "slug": "closed_mailbox_with_raised_flag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📪": { - "name": "closed mailbox with lowered flag", - "slug": "closed_mailbox_with_lowered_flag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📬": { - "name": "open mailbox with raised flag", - "slug": "open_mailbox_with_raised_flag", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📭": { - "name": "open mailbox with lowered flag", - "slug": "open_mailbox_with_lowered_flag", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📮": { - "name": "postbox", - "slug": "postbox", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗳️": { - "name": "ballot box with ballot", - "slug": "ballot_box_with_ballot", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "✏️": { - "name": "pencil", - "slug": "pencil", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✒️": { - "name": "black nib", - "slug": "black_nib", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🖋️": { - "name": "fountain pen", - "slug": "fountain_pen", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🖊️": { - "name": "pen", - "slug": "pen", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🖌️": { - "name": "paintbrush", - "slug": "paintbrush", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🖍️": { - "name": "crayon", - "slug": "crayon", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📝": { - "name": "memo", - "slug": "memo", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💼": { - "name": "briefcase", - "slug": "briefcase", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📁": { - "name": "file folder", - "slug": "file_folder", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📂": { - "name": "open file folder", - "slug": "open_file_folder", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗂️": { - "name": "card index dividers", - "slug": "card_index_dividers", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📅": { - "name": "calendar", - "slug": "calendar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📆": { - "name": "tear-off calendar", - "slug": "tear_off_calendar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗒️": { - "name": "spiral notepad", - "slug": "spiral_notepad", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🗓️": { - "name": "spiral calendar", - "slug": "spiral_calendar", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📇": { - "name": "card index", - "slug": "card_index", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📈": { - "name": "chart increasing", - "slug": "chart_increasing", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📉": { - "name": "chart decreasing", - "slug": "chart_decreasing", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📊": { - "name": "bar chart", - "slug": "bar_chart", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📋": { - "name": "clipboard", - "slug": "clipboard", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📌": { - "name": "pushpin", - "slug": "pushpin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📍": { - "name": "round pushpin", - "slug": "round_pushpin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📎": { - "name": "paperclip", - "slug": "paperclip", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🖇️": { - "name": "linked paperclips", - "slug": "linked_paperclips", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "📏": { - "name": "straight ruler", - "slug": "straight_ruler", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📐": { - "name": "triangular ruler", - "slug": "triangular_ruler", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✂️": { - "name": "scissors", - "slug": "scissors", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗃️": { - "name": "card file box", - "slug": "card_file_box", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🗄️": { - "name": "file cabinet", - "slug": "file_cabinet", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🗑️": { - "name": "wastebasket", - "slug": "wastebasket", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🔒": { - "name": "locked", - "slug": "locked", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔓": { - "name": "unlocked", - "slug": "unlocked", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔏": { - "name": "locked with pen", - "slug": "locked_with_pen", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔐": { - "name": "locked with key", - "slug": "locked_with_key", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔑": { - "name": "key", - "slug": "key", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🗝️": { - "name": "old key", - "slug": "old_key", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🔨": { - "name": "hammer", - "slug": "hammer", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪓": { - "name": "axe", - "slug": "axe", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "⛏️": { - "name": "pick", - "slug": "pick", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⚒️": { - "name": "hammer and pick", - "slug": "hammer_and_pick", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛠️": { - "name": "hammer and wrench", - "slug": "hammer_and_wrench", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🗡️": { - "name": "dagger", - "slug": "dagger", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⚔️": { - "name": "crossed swords", - "slug": "crossed_swords", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "💣": { - "name": "bomb", - "slug": "bomb", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪃": { - "name": "boomerang", - "slug": "boomerang", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🏹": { - "name": "bow and arrow", - "slug": "bow_and_arrow", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛡️": { - "name": "shield", - "slug": "shield", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🪚": { - "name": "carpentry saw", - "slug": "carpentry_saw", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🔧": { - "name": "wrench", - "slug": "wrench", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪛": { - "name": "screwdriver", - "slug": "screwdriver", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🔩": { - "name": "nut and bolt", - "slug": "nut_and_bolt", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚙️": { - "name": "gear", - "slug": "gear", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🗜️": { - "name": "clamp", - "slug": "clamp", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⚖️": { - "name": "balance scale", - "slug": "balance_scale", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🦯": { - "name": "white cane", - "slug": "white_cane", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🔗": { - "name": "link", - "slug": "link", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛓️‍💥": { - "name": "broken chain", - "slug": "broken_chain", - "group": "Objects", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "⛓️": { - "name": "chains", - "slug": "chains", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🪝": { - "name": "hook", - "slug": "hook", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🧰": { - "name": "toolbox", - "slug": "toolbox", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧲": { - "name": "magnet", - "slug": "magnet", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪜": { - "name": "ladder", - "slug": "ladder", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "⚗️": { - "name": "alembic", - "slug": "alembic", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🧪": { - "name": "test tube", - "slug": "test_tube", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧫": { - "name": "petri dish", - "slug": "petri_dish", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧬": { - "name": "dna", - "slug": "dna", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🔬": { - "name": "microscope", - "slug": "microscope", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔭": { - "name": "telescope", - "slug": "telescope", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "📡": { - "name": "satellite antenna", - "slug": "satellite_antenna", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💉": { - "name": "syringe", - "slug": "syringe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🩸": { - "name": "drop of blood", - "slug": "drop_of_blood", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "💊": { - "name": "pill", - "slug": "pill", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🩹": { - "name": "adhesive bandage", - "slug": "adhesive_bandage", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🩼": { - "name": "crutch", - "slug": "crutch", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🩺": { - "name": "stethoscope", - "slug": "stethoscope", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🩻": { - "name": "x-ray", - "slug": "x_ray", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🚪": { - "name": "door", - "slug": "door", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛗": { - "name": "elevator", - "slug": "elevator", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪞": { - "name": "mirror", - "slug": "mirror", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪟": { - "name": "window", - "slug": "window", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🛏️": { - "name": "bed", - "slug": "bed", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🛋️": { - "name": "couch and lamp", - "slug": "couch_and_lamp", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🪑": { - "name": "chair", - "slug": "chair", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🚽": { - "name": "toilet", - "slug": "toilet", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪠": { - "name": "plunger", - "slug": "plunger", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🚿": { - "name": "shower", - "slug": "shower", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛁": { - "name": "bathtub", - "slug": "bathtub", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🪤": { - "name": "mouse trap", - "slug": "mouse_trap", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪒": { - "name": "razor", - "slug": "razor", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🧴": { - "name": "lotion bottle", - "slug": "lotion_bottle", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧷": { - "name": "safety pin", - "slug": "safety_pin", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧹": { - "name": "broom", - "slug": "broom", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧺": { - "name": "basket", - "slug": "basket", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧻": { - "name": "roll of paper", - "slug": "roll_of_paper", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪣": { - "name": "bucket", - "slug": "bucket", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🧼": { - "name": "soap", - "slug": "soap", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🫧": { - "name": "bubbles", - "slug": "bubbles", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🪥": { - "name": "toothbrush", - "slug": "toothbrush", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🧽": { - "name": "sponge", - "slug": "sponge", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🧯": { - "name": "fire extinguisher", - "slug": "fire_extinguisher", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🛒": { - "name": "shopping cart", - "slug": "shopping_cart", - "group": "Objects", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "🚬": { - "name": "cigarette", - "slug": "cigarette", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚰️": { - "name": "coffin", - "slug": "coffin", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🪦": { - "name": "headstone", - "slug": "headstone", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "⚱️": { - "name": "funeral urn", - "slug": "funeral_urn", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🧿": { - "name": "nazar amulet", - "slug": "nazar_amulet", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🪬": { - "name": "hamsa", - "slug": "hamsa", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🗿": { - "name": "moai", - "slug": "moai", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪧": { - "name": "placard", - "slug": "placard", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🪪": { - "name": "identification card", - "slug": "identification_card", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "🏧": { - "name": "ATM sign", - "slug": "atm_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚮": { - "name": "litter in bin sign", - "slug": "litter_in_bin_sign", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚰": { - "name": "potable water", - "slug": "potable_water", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "♿": { - "name": "wheelchair symbol", - "slug": "wheelchair_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚹": { - "name": "men’s room", - "slug": "men_s_room", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚺": { - "name": "women’s room", - "slug": "women_s_room", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚻": { - "name": "restroom", - "slug": "restroom", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚼": { - "name": "baby symbol", - "slug": "baby_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚾": { - "name": "water closet", - "slug": "water_closet", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛂": { - "name": "passport control", - "slug": "passport_control", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛃": { - "name": "customs", - "slug": "customs", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛄": { - "name": "baggage claim", - "slug": "baggage_claim", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🛅": { - "name": "left luggage", - "slug": "left_luggage", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⚠️": { - "name": "warning", - "slug": "warning", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚸": { - "name": "children crossing", - "slug": "children_crossing", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⛔": { - "name": "no entry", - "slug": "no_entry", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚫": { - "name": "prohibited", - "slug": "prohibited", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚳": { - "name": "no bicycles", - "slug": "no_bicycles", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚭": { - "name": "no smoking", - "slug": "no_smoking", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚯": { - "name": "no littering", - "slug": "no_littering", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚱": { - "name": "non-potable water", - "slug": "non_potable_water", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🚷": { - "name": "no pedestrians", - "slug": "no_pedestrians", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "📵": { - "name": "no mobile phones", - "slug": "no_mobile_phones", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔞": { - "name": "no one under eighteen", - "slug": "no_one_under_eighteen", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☢️": { - "name": "radioactive", - "slug": "radioactive", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "☣️": { - "name": "biohazard", - "slug": "biohazard", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⬆️": { - "name": "up arrow", - "slug": "up_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↗️": { - "name": "up-right arrow", - "slug": "up_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "➡️": { - "name": "right arrow", - "slug": "right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↘️": { - "name": "down-right arrow", - "slug": "down_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⬇️": { - "name": "down arrow", - "slug": "down_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↙️": { - "name": "down-left arrow", - "slug": "down_left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⬅️": { - "name": "left arrow", - "slug": "left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↖️": { - "name": "up-left arrow", - "slug": "up_left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↕️": { - "name": "up-down arrow", - "slug": "up_down_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↔️": { - "name": "left-right arrow", - "slug": "left_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↩️": { - "name": "right arrow curving left", - "slug": "right_arrow_curving_left", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "↪️": { - "name": "left arrow curving right", - "slug": "left_arrow_curving_right", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⤴️": { - "name": "right arrow curving up", - "slug": "right_arrow_curving_up", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⤵️": { - "name": "right arrow curving down", - "slug": "right_arrow_curving_down", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔃": { - "name": "clockwise vertical arrows", - "slug": "clockwise_vertical_arrows", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔄": { - "name": "counterclockwise arrows button", - "slug": "counterclockwise_arrows_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔙": { - "name": "BACK arrow", - "slug": "back_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔚": { - "name": "END arrow", - "slug": "end_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔛": { - "name": "ON! arrow", - "slug": "on_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔜": { - "name": "SOON arrow", - "slug": "soon_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔝": { - "name": "TOP arrow", - "slug": "top_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛐": { - "name": "place of worship", - "slug": "place_of_worship", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "⚛️": { - "name": "atom symbol", - "slug": "atom_symbol", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🕉️": { - "name": "om", - "slug": "om", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "✡️": { - "name": "star of David", - "slug": "star_of_david", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "☸️": { - "name": "wheel of dharma", - "slug": "wheel_of_dharma", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "☯️": { - "name": "yin yang", - "slug": "yin_yang", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "✝️": { - "name": "latin cross", - "slug": "latin_cross", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "☦️": { - "name": "orthodox cross", - "slug": "orthodox_cross", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "☪️": { - "name": "star and crescent", - "slug": "star_and_crescent", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "☮️": { - "name": "peace symbol", - "slug": "peace_symbol", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🕎": { - "name": "menorah", - "slug": "menorah", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔯": { - "name": "dotted six-pointed star", - "slug": "dotted_six_pointed_star", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🪯": { - "name": "khanda", - "slug": "khanda", - "group": "Symbols", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "♈": { - "name": "Aries", - "slug": "aries", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♉": { - "name": "Taurus", - "slug": "taurus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♊": { - "name": "Gemini", - "slug": "gemini", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♋": { - "name": "Cancer", - "slug": "cancer", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♌": { - "name": "Leo", - "slug": "leo", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♍": { - "name": "Virgo", - "slug": "virgo", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♎": { - "name": "Libra", - "slug": "libra", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♏": { - "name": "Scorpio", - "slug": "scorpio", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♐": { - "name": "Sagittarius", - "slug": "sagittarius", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♑": { - "name": "Capricorn", - "slug": "capricorn", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♒": { - "name": "Aquarius", - "slug": "aquarius", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♓": { - "name": "Pisces", - "slug": "pisces", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⛎": { - "name": "Ophiuchus", - "slug": "ophiuchus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔀": { - "name": "shuffle tracks button", - "slug": "shuffle_tracks_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔁": { - "name": "repeat button", - "slug": "repeat_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔂": { - "name": "repeat single button", - "slug": "repeat_single_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "▶️": { - "name": "play button", - "slug": "play_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏩": { - "name": "fast-forward button", - "slug": "fast_forward_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏭️": { - "name": "next track button", - "slug": "next_track_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⏯️": { - "name": "play or pause button", - "slug": "play_or_pause_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "◀️": { - "name": "reverse button", - "slug": "reverse_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏪": { - "name": "fast reverse button", - "slug": "fast_reverse_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏮️": { - "name": "last track button", - "slug": "last_track_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🔼": { - "name": "upwards button", - "slug": "upwards_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏫": { - "name": "fast up button", - "slug": "fast_up_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔽": { - "name": "downwards button", - "slug": "downwards_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏬": { - "name": "fast down button", - "slug": "fast_down_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⏸️": { - "name": "pause button", - "slug": "pause_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⏹️": { - "name": "stop button", - "slug": "stop_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⏺️": { - "name": "record button", - "slug": "record_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "⏏️": { - "name": "eject button", - "slug": "eject_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🎦": { - "name": "cinema", - "slug": "cinema", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔅": { - "name": "dim button", - "slug": "dim_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔆": { - "name": "bright button", - "slug": "bright_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "📶": { - "name": "antenna bars", - "slug": "antenna_bars", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🛜": { - "name": "wireless", - "slug": "wireless", - "group": "Symbols", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "📳": { - "name": "vibration mode", - "slug": "vibration_mode", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📴": { - "name": "mobile phone off", - "slug": "mobile_phone_off", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "♀️": { - "name": "female sign", - "slug": "female_sign", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "♂️": { - "name": "male sign", - "slug": "male_sign", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "⚧️": { - "name": "transgender symbol", - "slug": "transgender_symbol", - "group": "Symbols", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "✖️": { - "name": "multiply", - "slug": "multiply", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "➕": { - "name": "plus", - "slug": "plus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "➖": { - "name": "minus", - "slug": "minus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "➗": { - "name": "divide", - "slug": "divide", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🟰": { - "name": "heavy equals sign", - "slug": "heavy_equals_sign", - "group": "Symbols", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "♾️": { - "name": "infinity", - "slug": "infinity", - "group": "Symbols", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "‼️": { - "name": "double exclamation mark", - "slug": "double_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⁉️": { - "name": "exclamation question mark", - "slug": "exclamation_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❓": { - "name": "red question mark", - "slug": "red_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❔": { - "name": "white question mark", - "slug": "white_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❕": { - "name": "white exclamation mark", - "slug": "white_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❗": { - "name": "red exclamation mark", - "slug": "red_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "〰️": { - "name": "wavy dash", - "slug": "wavy_dash", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💱": { - "name": "currency exchange", - "slug": "currency_exchange", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💲": { - "name": "heavy dollar sign", - "slug": "heavy_dollar_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚕️": { - "name": "medical symbol", - "slug": "medical_symbol", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "♻️": { - "name": "recycling symbol", - "slug": "recycling_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚜️": { - "name": "fleur-de-lis", - "slug": "fleur_de_lis", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🔱": { - "name": "trident emblem", - "slug": "trident_emblem", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "📛": { - "name": "name badge", - "slug": "name_badge", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔰": { - "name": "Japanese symbol for beginner", - "slug": "japanese_symbol_for_beginner", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⭕": { - "name": "hollow red circle", - "slug": "hollow_red_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✅": { - "name": "check mark button", - "slug": "check_mark_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "☑️": { - "name": "check box with check", - "slug": "check_box_with_check", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✔️": { - "name": "check mark", - "slug": "check_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❌": { - "name": "cross mark", - "slug": "cross_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❎": { - "name": "cross mark button", - "slug": "cross_mark_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "➰": { - "name": "curly loop", - "slug": "curly_loop", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "➿": { - "name": "double curly loop", - "slug": "double_curly_loop", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "〽️": { - "name": "part alternation mark", - "slug": "part_alternation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✳️": { - "name": "eight-spoked asterisk", - "slug": "eight_spoked_asterisk", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "✴️": { - "name": "eight-pointed star", - "slug": "eight_pointed_star", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "❇️": { - "name": "sparkle", - "slug": "sparkle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "©️": { - "name": "copyright", - "slug": "copyright", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "®️": { - "name": "registered", - "slug": "registered", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "™️": { - "name": "trade mark", - "slug": "trade_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "#️⃣": { - "name": "keycap #", - "slug": "keycap_number_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "*️⃣": { - "name": "keycap *", - "slug": "keycap_asterisk", - "group": "Symbols", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "0️⃣": { - "name": "keycap 0", - "slug": "keycap_0", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "1️⃣": { - "name": "keycap 1", - "slug": "keycap_1", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "2️⃣": { - "name": "keycap 2", - "slug": "keycap_2", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "3️⃣": { - "name": "keycap 3", - "slug": "keycap_3", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "4️⃣": { - "name": "keycap 4", - "slug": "keycap_4", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "5️⃣": { - "name": "keycap 5", - "slug": "keycap_5", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "6️⃣": { - "name": "keycap 6", - "slug": "keycap_6", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "7️⃣": { - "name": "keycap 7", - "slug": "keycap_7", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "8️⃣": { - "name": "keycap 8", - "slug": "keycap_8", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "9️⃣": { - "name": "keycap 9", - "slug": "keycap_9", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔟": { - "name": "keycap 10", - "slug": "keycap_10", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔠": { - "name": "input latin uppercase", - "slug": "input_latin_uppercase", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔡": { - "name": "input latin lowercase", - "slug": "input_latin_lowercase", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔢": { - "name": "input numbers", - "slug": "input_numbers", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔣": { - "name": "input symbols", - "slug": "input_symbols", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔤": { - "name": "input latin letters", - "slug": "input_latin_letters", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🅰️": { - "name": "A button (blood type)", - "slug": "a_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆎": { - "name": "AB button (blood type)", - "slug": "ab_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🅱️": { - "name": "B button (blood type)", - "slug": "b_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆑": { - "name": "CL button", - "slug": "cl_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆒": { - "name": "COOL button", - "slug": "cool_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆓": { - "name": "FREE button", - "slug": "free_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ℹ️": { - "name": "information", - "slug": "information", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆔": { - "name": "ID button", - "slug": "id_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "Ⓜ️": { - "name": "circled M", - "slug": "circled_m", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆕": { - "name": "NEW button", - "slug": "new_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆖": { - "name": "NG button", - "slug": "ng_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🅾️": { - "name": "O button (blood type)", - "slug": "o_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆗": { - "name": "OK button", - "slug": "ok_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🅿️": { - "name": "P button", - "slug": "p_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆘": { - "name": "SOS button", - "slug": "sos_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆙": { - "name": "UP! button", - "slug": "up_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🆚": { - "name": "VS button", - "slug": "vs_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈁": { - "name": "Japanese “here” button", - "slug": "japanese_here_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈂️": { - "name": "Japanese “service charge” button", - "slug": "japanese_service_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈷️": { - "name": "Japanese “monthly amount” button", - "slug": "japanese_monthly_amount_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈶": { - "name": "Japanese “not free of charge” button", - "slug": "japanese_not_free_of_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈯": { - "name": "Japanese “reserved” button", - "slug": "japanese_reserved_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🉐": { - "name": "Japanese “bargain” button", - "slug": "japanese_bargain_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈹": { - "name": "Japanese “discount” button", - "slug": "japanese_discount_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈚": { - "name": "Japanese “free of charge” button", - "slug": "japanese_free_of_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈲": { - "name": "Japanese “prohibited” button", - "slug": "japanese_prohibited_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🉑": { - "name": "Japanese “acceptable” button", - "slug": "japanese_acceptable_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈸": { - "name": "Japanese “application” button", - "slug": "japanese_application_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈴": { - "name": "Japanese “passing grade” button", - "slug": "japanese_passing_grade_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈳": { - "name": "Japanese “vacancy” button", - "slug": "japanese_vacancy_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "㊗️": { - "name": "Japanese “congratulations” button", - "slug": "japanese_congratulations_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "㊙️": { - "name": "Japanese “secret” button", - "slug": "japanese_secret_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈺": { - "name": "Japanese “open for business” button", - "slug": "japanese_open_for_business_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🈵": { - "name": "Japanese “no vacancy” button", - "slug": "japanese_no_vacancy_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔴": { - "name": "red circle", - "slug": "red_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🟠": { - "name": "orange circle", - "slug": "orange_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟡": { - "name": "yellow circle", - "slug": "yellow_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟢": { - "name": "green circle", - "slug": "green_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🔵": { - "name": "blue circle", - "slug": "blue_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🟣": { - "name": "purple circle", - "slug": "purple_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟤": { - "name": "brown circle", - "slug": "brown_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "⚫": { - "name": "black circle", - "slug": "black_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⚪": { - "name": "white circle", - "slug": "white_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🟥": { - "name": "red square", - "slug": "red_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟧": { - "name": "orange square", - "slug": "orange_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟨": { - "name": "yellow square", - "slug": "yellow_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟩": { - "name": "green square", - "slug": "green_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟦": { - "name": "blue square", - "slug": "blue_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟪": { - "name": "purple square", - "slug": "purple_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "🟫": { - "name": "brown square", - "slug": "brown_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "⬛": { - "name": "black large square", - "slug": "black_large_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "⬜": { - "name": "white large square", - "slug": "white_large_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "◼️": { - "name": "black medium square", - "slug": "black_medium_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "◻️": { - "name": "white medium square", - "slug": "white_medium_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "◾": { - "name": "black medium-small square", - "slug": "black_medium_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "◽": { - "name": "white medium-small square", - "slug": "white_medium_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "▪️": { - "name": "black small square", - "slug": "black_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "▫️": { - "name": "white small square", - "slug": "white_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔶": { - "name": "large orange diamond", - "slug": "large_orange_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔷": { - "name": "large blue diamond", - "slug": "large_blue_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔸": { - "name": "small orange diamond", - "slug": "small_orange_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔹": { - "name": "small blue diamond", - "slug": "small_blue_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔺": { - "name": "red triangle pointed up", - "slug": "red_triangle_pointed_up", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔻": { - "name": "red triangle pointed down", - "slug": "red_triangle_pointed_down", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "💠": { - "name": "diamond with a dot", - "slug": "diamond_with_a_dot", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔘": { - "name": "radio button", - "slug": "radio_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔳": { - "name": "white square button", - "slug": "white_square_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🔲": { - "name": "black square button", - "slug": "black_square_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏁": { - "name": "chequered flag", - "slug": "chequered_flag", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🚩": { - "name": "triangular flag", - "slug": "triangular_flag", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🎌": { - "name": "crossed flags", - "slug": "crossed_flags", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🏴": { - "name": "black flag", - "slug": "black_flag", - "group": "Flags", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "🏳️": { - "name": "white flag", - "slug": "white_flag", - "group": "Flags", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "🏳️‍🌈": { - "name": "rainbow flag", - "slug": "rainbow_flag", - "group": "Flags", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "🏳️‍⚧️": { - "name": "transgender flag", - "slug": "transgender_flag", - "group": "Flags", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "🏴‍☠️": { - "name": "pirate flag", - "slug": "pirate_flag", - "group": "Flags", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "🇦🇨": { - "name": "flag Ascension Island", - "slug": "flag_ascension_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇩": { - "name": "flag Andorra", - "slug": "flag_andorra", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇪": { - "name": "flag United Arab Emirates", - "slug": "flag_united_arab_emirates", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇫": { - "name": "flag Afghanistan", - "slug": "flag_afghanistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇬": { - "name": "flag Antigua & Barbuda", - "slug": "flag_antigua_barbuda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇮": { - "name": "flag Anguilla", - "slug": "flag_anguilla", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇱": { - "name": "flag Albania", - "slug": "flag_albania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇲": { - "name": "flag Armenia", - "slug": "flag_armenia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇴": { - "name": "flag Angola", - "slug": "flag_angola", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇶": { - "name": "flag Antarctica", - "slug": "flag_antarctica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇷": { - "name": "flag Argentina", - "slug": "flag_argentina", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇸": { - "name": "flag American Samoa", - "slug": "flag_american_samoa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇹": { - "name": "flag Austria", - "slug": "flag_austria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇺": { - "name": "flag Australia", - "slug": "flag_australia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇼": { - "name": "flag Aruba", - "slug": "flag_aruba", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇽": { - "name": "flag Åland Islands", - "slug": "flag_aland_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇦🇿": { - "name": "flag Azerbaijan", - "slug": "flag_azerbaijan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇦": { - "name": "flag Bosnia & Herzegovina", - "slug": "flag_bosnia_herzegovina", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇧": { - "name": "flag Barbados", - "slug": "flag_barbados", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇩": { - "name": "flag Bangladesh", - "slug": "flag_bangladesh", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇪": { - "name": "flag Belgium", - "slug": "flag_belgium", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇫": { - "name": "flag Burkina Faso", - "slug": "flag_burkina_faso", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇬": { - "name": "flag Bulgaria", - "slug": "flag_bulgaria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇭": { - "name": "flag Bahrain", - "slug": "flag_bahrain", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇮": { - "name": "flag Burundi", - "slug": "flag_burundi", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇯": { - "name": "flag Benin", - "slug": "flag_benin", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇱": { - "name": "flag St. Barthélemy", - "slug": "flag_st_barthelemy", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇲": { - "name": "flag Bermuda", - "slug": "flag_bermuda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇳": { - "name": "flag Brunei", - "slug": "flag_brunei", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇴": { - "name": "flag Bolivia", - "slug": "flag_bolivia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇶": { - "name": "flag Caribbean Netherlands", - "slug": "flag_caribbean_netherlands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇷": { - "name": "flag Brazil", - "slug": "flag_brazil", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇸": { - "name": "flag Bahamas", - "slug": "flag_bahamas", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇹": { - "name": "flag Bhutan", - "slug": "flag_bhutan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇻": { - "name": "flag Bouvet Island", - "slug": "flag_bouvet_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇼": { - "name": "flag Botswana", - "slug": "flag_botswana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇾": { - "name": "flag Belarus", - "slug": "flag_belarus", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇧🇿": { - "name": "flag Belize", - "slug": "flag_belize", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇦": { - "name": "flag Canada", - "slug": "flag_canada", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇨": { - "name": "flag Cocos (Keeling) Islands", - "slug": "flag_cocos_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇩": { - "name": "flag Congo - Kinshasa", - "slug": "flag_congo_kinshasa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇫": { - "name": "flag Central African Republic", - "slug": "flag_central_african_republic", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇬": { - "name": "flag Congo - Brazzaville", - "slug": "flag_congo_brazzaville", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇭": { - "name": "flag Switzerland", - "slug": "flag_switzerland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇮": { - "name": "flag Côte d’Ivoire", - "slug": "flag_cote_d_ivoire", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇰": { - "name": "flag Cook Islands", - "slug": "flag_cook_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇱": { - "name": "flag Chile", - "slug": "flag_chile", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇲": { - "name": "flag Cameroon", - "slug": "flag_cameroon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇳": { - "name": "flag China", - "slug": "flag_china", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇨🇴": { - "name": "flag Colombia", - "slug": "flag_colombia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇵": { - "name": "flag Clipperton Island", - "slug": "flag_clipperton_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇷": { - "name": "flag Costa Rica", - "slug": "flag_costa_rica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇺": { - "name": "flag Cuba", - "slug": "flag_cuba", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇻": { - "name": "flag Cape Verde", - "slug": "flag_cape_verde", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇼": { - "name": "flag Curaçao", - "slug": "flag_curacao", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇽": { - "name": "flag Christmas Island", - "slug": "flag_christmas_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇾": { - "name": "flag Cyprus", - "slug": "flag_cyprus", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇨🇿": { - "name": "flag Czechia", - "slug": "flag_czechia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇩🇪": { - "name": "flag Germany", - "slug": "flag_germany", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇩🇬": { - "name": "flag Diego Garcia", - "slug": "flag_diego_garcia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇩🇯": { - "name": "flag Djibouti", - "slug": "flag_djibouti", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇩🇰": { - "name": "flag Denmark", - "slug": "flag_denmark", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇩🇲": { - "name": "flag Dominica", - "slug": "flag_dominica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇩🇴": { - "name": "flag Dominican Republic", - "slug": "flag_dominican_republic", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇩🇿": { - "name": "flag Algeria", - "slug": "flag_algeria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇦": { - "name": "flag Ceuta & Melilla", - "slug": "flag_ceuta_melilla", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇨": { - "name": "flag Ecuador", - "slug": "flag_ecuador", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇪": { - "name": "flag Estonia", - "slug": "flag_estonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇬": { - "name": "flag Egypt", - "slug": "flag_egypt", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇭": { - "name": "flag Western Sahara", - "slug": "flag_western_sahara", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇷": { - "name": "flag Eritrea", - "slug": "flag_eritrea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇸": { - "name": "flag Spain", - "slug": "flag_spain", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇪🇹": { - "name": "flag Ethiopia", - "slug": "flag_ethiopia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇪🇺": { - "name": "flag European Union", - "slug": "flag_european_union", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇫🇮": { - "name": "flag Finland", - "slug": "flag_finland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇫🇯": { - "name": "flag Fiji", - "slug": "flag_fiji", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇫🇰": { - "name": "flag Falkland Islands", - "slug": "flag_falkland_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇫🇲": { - "name": "flag Micronesia", - "slug": "flag_micronesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇫🇴": { - "name": "flag Faroe Islands", - "slug": "flag_faroe_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇫🇷": { - "name": "flag France", - "slug": "flag_france", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇬🇦": { - "name": "flag Gabon", - "slug": "flag_gabon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇧": { - "name": "flag United Kingdom", - "slug": "flag_united_kingdom", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇬🇩": { - "name": "flag Grenada", - "slug": "flag_grenada", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇪": { - "name": "flag Georgia", - "slug": "flag_georgia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇫": { - "name": "flag French Guiana", - "slug": "flag_french_guiana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇬": { - "name": "flag Guernsey", - "slug": "flag_guernsey", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇭": { - "name": "flag Ghana", - "slug": "flag_ghana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇮": { - "name": "flag Gibraltar", - "slug": "flag_gibraltar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇱": { - "name": "flag Greenland", - "slug": "flag_greenland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇲": { - "name": "flag Gambia", - "slug": "flag_gambia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇳": { - "name": "flag Guinea", - "slug": "flag_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇵": { - "name": "flag Guadeloupe", - "slug": "flag_guadeloupe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇶": { - "name": "flag Equatorial Guinea", - "slug": "flag_equatorial_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇷": { - "name": "flag Greece", - "slug": "flag_greece", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇸": { - "name": "flag South Georgia & South Sandwich Islands", - "slug": "flag_south_georgia_south_sandwich_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇹": { - "name": "flag Guatemala", - "slug": "flag_guatemala", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇺": { - "name": "flag Guam", - "slug": "flag_guam", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇼": { - "name": "flag Guinea-Bissau", - "slug": "flag_guinea_bissau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇬🇾": { - "name": "flag Guyana", - "slug": "flag_guyana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇭🇰": { - "name": "flag Hong Kong SAR China", - "slug": "flag_hong_kong_sar_china", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇭🇲": { - "name": "flag Heard & McDonald Islands", - "slug": "flag_heard_mcdonald_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇭🇳": { - "name": "flag Honduras", - "slug": "flag_honduras", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇭🇷": { - "name": "flag Croatia", - "slug": "flag_croatia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇭🇹": { - "name": "flag Haiti", - "slug": "flag_haiti", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇭🇺": { - "name": "flag Hungary", - "slug": "flag_hungary", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇨": { - "name": "flag Canary Islands", - "slug": "flag_canary_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇩": { - "name": "flag Indonesia", - "slug": "flag_indonesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇪": { - "name": "flag Ireland", - "slug": "flag_ireland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇱": { - "name": "flag Israel", - "slug": "flag_israel", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇲": { - "name": "flag Isle of Man", - "slug": "flag_isle_of_man", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇳": { - "name": "flag India", - "slug": "flag_india", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇴": { - "name": "flag British Indian Ocean Territory", - "slug": "flag_british_indian_ocean_territory", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇶": { - "name": "flag Iraq", - "slug": "flag_iraq", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇷": { - "name": "flag Iran", - "slug": "flag_iran", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇸": { - "name": "flag Iceland", - "slug": "flag_iceland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇮🇹": { - "name": "flag Italy", - "slug": "flag_italy", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇯🇪": { - "name": "flag Jersey", - "slug": "flag_jersey", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇯🇲": { - "name": "flag Jamaica", - "slug": "flag_jamaica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇯🇴": { - "name": "flag Jordan", - "slug": "flag_jordan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇯🇵": { - "name": "flag Japan", - "slug": "flag_japan", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇰🇪": { - "name": "flag Kenya", - "slug": "flag_kenya", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇬": { - "name": "flag Kyrgyzstan", - "slug": "flag_kyrgyzstan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇭": { - "name": "flag Cambodia", - "slug": "flag_cambodia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇮": { - "name": "flag Kiribati", - "slug": "flag_kiribati", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇲": { - "name": "flag Comoros", - "slug": "flag_comoros", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇳": { - "name": "flag St. Kitts & Nevis", - "slug": "flag_st_kitts_nevis", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇵": { - "name": "flag North Korea", - "slug": "flag_north_korea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇷": { - "name": "flag South Korea", - "slug": "flag_south_korea", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇰🇼": { - "name": "flag Kuwait", - "slug": "flag_kuwait", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇾": { - "name": "flag Cayman Islands", - "slug": "flag_cayman_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇰🇿": { - "name": "flag Kazakhstan", - "slug": "flag_kazakhstan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇦": { - "name": "flag Laos", - "slug": "flag_laos", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇧": { - "name": "flag Lebanon", - "slug": "flag_lebanon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇨": { - "name": "flag St. Lucia", - "slug": "flag_st_lucia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇮": { - "name": "flag Liechtenstein", - "slug": "flag_liechtenstein", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇰": { - "name": "flag Sri Lanka", - "slug": "flag_sri_lanka", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇷": { - "name": "flag Liberia", - "slug": "flag_liberia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇸": { - "name": "flag Lesotho", - "slug": "flag_lesotho", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇹": { - "name": "flag Lithuania", - "slug": "flag_lithuania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇺": { - "name": "flag Luxembourg", - "slug": "flag_luxembourg", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇻": { - "name": "flag Latvia", - "slug": "flag_latvia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇱🇾": { - "name": "flag Libya", - "slug": "flag_libya", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇦": { - "name": "flag Morocco", - "slug": "flag_morocco", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇨": { - "name": "flag Monaco", - "slug": "flag_monaco", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇩": { - "name": "flag Moldova", - "slug": "flag_moldova", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇪": { - "name": "flag Montenegro", - "slug": "flag_montenegro", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇫": { - "name": "flag St. Martin", - "slug": "flag_st_martin", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇬": { - "name": "flag Madagascar", - "slug": "flag_madagascar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇭": { - "name": "flag Marshall Islands", - "slug": "flag_marshall_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇰": { - "name": "flag North Macedonia", - "slug": "flag_north_macedonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇱": { - "name": "flag Mali", - "slug": "flag_mali", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇲": { - "name": "flag Myanmar (Burma)", - "slug": "flag_myanmar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇳": { - "name": "flag Mongolia", - "slug": "flag_mongolia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇴": { - "name": "flag Macao SAR China", - "slug": "flag_macao_sar_china", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇵": { - "name": "flag Northern Mariana Islands", - "slug": "flag_northern_mariana_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇶": { - "name": "flag Martinique", - "slug": "flag_martinique", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇷": { - "name": "flag Mauritania", - "slug": "flag_mauritania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇸": { - "name": "flag Montserrat", - "slug": "flag_montserrat", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇹": { - "name": "flag Malta", - "slug": "flag_malta", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇺": { - "name": "flag Mauritius", - "slug": "flag_mauritius", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇻": { - "name": "flag Maldives", - "slug": "flag_maldives", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇼": { - "name": "flag Malawi", - "slug": "flag_malawi", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇽": { - "name": "flag Mexico", - "slug": "flag_mexico", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇾": { - "name": "flag Malaysia", - "slug": "flag_malaysia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇲🇿": { - "name": "flag Mozambique", - "slug": "flag_mozambique", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇦": { - "name": "flag Namibia", - "slug": "flag_namibia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇨": { - "name": "flag New Caledonia", - "slug": "flag_new_caledonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇪": { - "name": "flag Niger", - "slug": "flag_niger", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇫": { - "name": "flag Norfolk Island", - "slug": "flag_norfolk_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇬": { - "name": "flag Nigeria", - "slug": "flag_nigeria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇮": { - "name": "flag Nicaragua", - "slug": "flag_nicaragua", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇱": { - "name": "flag Netherlands", - "slug": "flag_netherlands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇴": { - "name": "flag Norway", - "slug": "flag_norway", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇵": { - "name": "flag Nepal", - "slug": "flag_nepal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇷": { - "name": "flag Nauru", - "slug": "flag_nauru", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇺": { - "name": "flag Niue", - "slug": "flag_niue", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇳🇿": { - "name": "flag New Zealand", - "slug": "flag_new_zealand", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇴🇲": { - "name": "flag Oman", - "slug": "flag_oman", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇦": { - "name": "flag Panama", - "slug": "flag_panama", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇪": { - "name": "flag Peru", - "slug": "flag_peru", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇫": { - "name": "flag French Polynesia", - "slug": "flag_french_polynesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇬": { - "name": "flag Papua New Guinea", - "slug": "flag_papua_new_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇭": { - "name": "flag Philippines", - "slug": "flag_philippines", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇰": { - "name": "flag Pakistan", - "slug": "flag_pakistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇱": { - "name": "flag Poland", - "slug": "flag_poland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇲": { - "name": "flag St. Pierre & Miquelon", - "slug": "flag_st_pierre_miquelon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇳": { - "name": "flag Pitcairn Islands", - "slug": "flag_pitcairn_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇷": { - "name": "flag Puerto Rico", - "slug": "flag_puerto_rico", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇸": { - "name": "flag Palestinian Territories", - "slug": "flag_palestinian_territories", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇹": { - "name": "flag Portugal", - "slug": "flag_portugal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇼": { - "name": "flag Palau", - "slug": "flag_palau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇵🇾": { - "name": "flag Paraguay", - "slug": "flag_paraguay", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇶🇦": { - "name": "flag Qatar", - "slug": "flag_qatar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇷🇪": { - "name": "flag Réunion", - "slug": "flag_reunion", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇷🇴": { - "name": "flag Romania", - "slug": "flag_romania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇷🇸": { - "name": "flag Serbia", - "slug": "flag_serbia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇷🇺": { - "name": "flag Russia", - "slug": "flag_russia", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇷🇼": { - "name": "flag Rwanda", - "slug": "flag_rwanda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇦": { - "name": "flag Saudi Arabia", - "slug": "flag_saudi_arabia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇧": { - "name": "flag Solomon Islands", - "slug": "flag_solomon_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇨": { - "name": "flag Seychelles", - "slug": "flag_seychelles", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇩": { - "name": "flag Sudan", - "slug": "flag_sudan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇪": { - "name": "flag Sweden", - "slug": "flag_sweden", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇬": { - "name": "flag Singapore", - "slug": "flag_singapore", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇭": { - "name": "flag St. Helena", - "slug": "flag_st_helena", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇮": { - "name": "flag Slovenia", - "slug": "flag_slovenia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇯": { - "name": "flag Svalbard & Jan Mayen", - "slug": "flag_svalbard_jan_mayen", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇰": { - "name": "flag Slovakia", - "slug": "flag_slovakia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇱": { - "name": "flag Sierra Leone", - "slug": "flag_sierra_leone", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇲": { - "name": "flag San Marino", - "slug": "flag_san_marino", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇳": { - "name": "flag Senegal", - "slug": "flag_senegal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇴": { - "name": "flag Somalia", - "slug": "flag_somalia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇷": { - "name": "flag Suriname", - "slug": "flag_suriname", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇸": { - "name": "flag South Sudan", - "slug": "flag_south_sudan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇹": { - "name": "flag São Tomé & Príncipe", - "slug": "flag_sao_tome_principe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇻": { - "name": "flag El Salvador", - "slug": "flag_el_salvador", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇽": { - "name": "flag Sint Maarten", - "slug": "flag_sint_maarten", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇾": { - "name": "flag Syria", - "slug": "flag_syria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇸🇿": { - "name": "flag Eswatini", - "slug": "flag_eswatini", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇦": { - "name": "flag Tristan da Cunha", - "slug": "flag_tristan_da_cunha", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇨": { - "name": "flag Turks & Caicos Islands", - "slug": "flag_turks_caicos_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇩": { - "name": "flag Chad", - "slug": "flag_chad", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇫": { - "name": "flag French Southern Territories", - "slug": "flag_french_southern_territories", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇬": { - "name": "flag Togo", - "slug": "flag_togo", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇭": { - "name": "flag Thailand", - "slug": "flag_thailand", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇯": { - "name": "flag Tajikistan", - "slug": "flag_tajikistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇰": { - "name": "flag Tokelau", - "slug": "flag_tokelau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇱": { - "name": "flag Timor-Leste", - "slug": "flag_timor_leste", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇲": { - "name": "flag Turkmenistan", - "slug": "flag_turkmenistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇳": { - "name": "flag Tunisia", - "slug": "flag_tunisia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇴": { - "name": "flag Tonga", - "slug": "flag_tonga", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇷": { - "name": "flag Türkiye", - "slug": "flag_turkiye", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇹": { - "name": "flag Trinidad & Tobago", - "slug": "flag_trinidad_tobago", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇻": { - "name": "flag Tuvalu", - "slug": "flag_tuvalu", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇼": { - "name": "flag Taiwan", - "slug": "flag_taiwan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇹🇿": { - "name": "flag Tanzania", - "slug": "flag_tanzania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇺🇦": { - "name": "flag Ukraine", - "slug": "flag_ukraine", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇺🇬": { - "name": "flag Uganda", - "slug": "flag_uganda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇺🇲": { - "name": "flag U.S. Outlying Islands", - "slug": "flag_u_s_outlying_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇺🇳": { - "name": "flag United Nations", - "slug": "flag_united_nations", - "group": "Flags", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "🇺🇸": { - "name": "flag United States", - "slug": "flag_united_states", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "🇺🇾": { - "name": "flag Uruguay", - "slug": "flag_uruguay", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇺🇿": { - "name": "flag Uzbekistan", - "slug": "flag_uzbekistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇦": { - "name": "flag Vatican City", - "slug": "flag_vatican_city", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇨": { - "name": "flag St. Vincent & Grenadines", - "slug": "flag_st_vincent_grenadines", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇪": { - "name": "flag Venezuela", - "slug": "flag_venezuela", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇬": { - "name": "flag British Virgin Islands", - "slug": "flag_british_virgin_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇮": { - "name": "flag U.S. Virgin Islands", - "slug": "flag_u_s_virgin_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇳": { - "name": "flag Vietnam", - "slug": "flag_vietnam", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇻🇺": { - "name": "flag Vanuatu", - "slug": "flag_vanuatu", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇼🇫": { - "name": "flag Wallis & Futuna", - "slug": "flag_wallis_futuna", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇼🇸": { - "name": "flag Samoa", - "slug": "flag_samoa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇽🇰": { - "name": "flag Kosovo", - "slug": "flag_kosovo", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇾🇪": { - "name": "flag Yemen", - "slug": "flag_yemen", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇾🇹": { - "name": "flag Mayotte", - "slug": "flag_mayotte", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇿🇦": { - "name": "flag South Africa", - "slug": "flag_south_africa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇿🇲": { - "name": "flag Zambia", - "slug": "flag_zambia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🇿🇼": { - "name": "flag Zimbabwe", - "slug": "flag_zimbabwe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "🏴󠁧󠁢󠁥󠁮󠁧󠁿": { - "name": "flag England", - "slug": "flag_england", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🏴󠁧󠁢󠁳󠁣󠁴󠁿": { - "name": "flag Scotland", - "slug": "flag_scotland", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "🏴󠁧󠁢󠁷󠁬󠁳󠁿": { - "name": "flag Wales", - "slug": "flag_wales", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - } -} diff --git a/config/assets/icons/applets/brightness.svg b/config/assets/icons/applets/brightness.svg deleted file mode 100755 index bcc61ef6..00000000 --- a/config/assets/icons/applets/brightness.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/config/assets/icons/mic/microphone-0.svg b/config/assets/icons/mic/microphone-0.svg deleted file mode 100755 index ef1f97a4..00000000 --- a/config/assets/icons/mic/microphone-0.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/config/assets/icons/misc/control.svg b/config/assets/icons/misc/control.svg deleted file mode 100644 index ed4e9d72..00000000 --- a/config/assets/icons/misc/control.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/config/assets/icons/misc/imac.svg b/config/assets/icons/misc/imac.svg deleted file mode 100644 index 47719db9..00000000 --- a/config/assets/icons/misc/imac.svg +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/config/assets/icons/misc/logo.svg b/config/assets/icons/misc/logo.svg deleted file mode 100644 index 9fa578dc..00000000 --- a/config/assets/icons/misc/logo.svg +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - diff --git a/config/assets/launcher.json b/config/assets/launcher.json deleted file mode 100644 index 9d411e0d..00000000 --- a/config/assets/launcher.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "launcher_config": { - "em": { - "icon": "emoji-people-symbolic", - "description": "Emoji - Search and copy emojis" - }, - "clip": { - "icon": "app.getclipboard.Clipboard", - "description": "Clipboard - Search and manage clipboard history" - }, - "=": { - "examples": [ - "= 10*5", - "= 2^8", - "= sin(30)" - ], - "icon": "calculator", - "description": "Quick Math - Fast mathematical expressions" - }, - "app": { - "icon": "apps", - "description": "Applications - Launch installed applications" - }, - "bin": { - "icon": "terminal", - "description": "Bins - Search and run executable binaries" - }, - "power": { - "icon": "shutdown", - "description": "Power - System power management and session control" - }, - "caffeine": { - "examples": [ - "caffeine 30m", - "caffeine 1h", - "caffeine 2h", - "caffeine on" - ], - "icon": "caffeine", - "description": "Caffeine - Prevent system from going idle" - }, - "sc": { - "icon": "cs-screen", - "description": "Screencapture - Record screen and audio" - }, - "wall": { - "icon": "daily-wallpaper", - "description": "Wallpapers - Set wallpapers, and tons of features" - }, - "?": { - "icon": "search-symbolic", - "description": "Quick Web Search - Fast web search with question mark", - "examples": [ - "? cats", - "? google cats", - "? youtube music" - ] - }, - "remind": { - "icon": "alarm-timer", - "description": "Reminders - Set time-based reminders with notifications" - }, - "otp": { - "icon": "auth-otp-symbolic", - "description": "Manage TOTP codes and 2FA authentication" - }, - "pass": { - "icon": "nextcloud-password-client", - "description": "Password Manager - Search and manage passwords" - }, - "bm": { - "icon": "bookmark-add-symbolic", - "description": "Bookmarks - Search and manage bookmarks" - }, - "script": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "bash": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "sh": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "tmux": { - "icon": "terminal", - "description": "Tmux Manager - Create, attach, rename, and kill tmux sessions" - } - }, - "settings": { - "max_examples_shown": 2, - "default_icon": "apps", - "fallback_example_template": "{trigger} ", - "config_version": "1.0" - } -} diff --git a/config/data.py b/config/data.py deleted file mode 100644 index e4aa977f..00000000 --- a/config/data.py +++ /dev/null @@ -1,118 +0,0 @@ -import json -import os - -import gi -from fabric.utils.helpers import get_relative_path -from gi.repository import Gdk, GLib - -gi.require_version("Gtk", "3.0") - -APP_NAME = "modus" -APP_NAME_CAP = "Modus" - - -def parse_timeout_string(timeout_str): - """ - Parse timeout string in format like '5s', '10m', '30s' etc. - Returns timeout in milliseconds. - """ - if not timeout_str or not isinstance(timeout_str, str): - return 5000 - - timeout_str = timeout_str.strip().lower() - - if timeout_str.endswith("s"): - try: - seconds = int(timeout_str[:-1]) - return seconds * 1000 - except ValueError: - return 5000 - elif timeout_str.endswith("m"): - try: - minutes = int(timeout_str[:-1]) - return minutes * 60 * 1000 - except ValueError: - return 5000 - else: - try: - seconds = int(timeout_str) - return seconds * 1000 - except ValueError: - return 5000 - - -CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" - -USERNAME = os.getlogin() -HOSTNAME = os.uname().nodename -HOME_DIR = os.path.expanduser("~") - -CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}") - -screen = Gdk.Screen.get_default() -CURRENT_WIDTH = screen.get_width() -CURRENT_HEIGHT = screen.get_height() - - -WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/") -CONFIG_FILE = get_relative_path("../config/assets/config.json") -MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") - - -def load_config(): - """Load the configuration from config.json""" - config = {} - - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r") as f: - config = json.load(f) - except Exception as e: - print(f"Error loading config: {e}") - - return config - - -if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, "r") as f: - config = json.load(f) - wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) - WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config) - DOCK_POSITION = config.get("dock_position", "Bottom") - TERMINAL_COMMAND = config.get("terminal_command", "kitty -e") - DOCK_ENABLED = config.get("dock_enabled", True) - DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) - DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) - DOCK_ICON_SIZE = config.get("dock_icon_size", 60) - WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 10) - HIDE_SPECIAL_WORKSPACE = config.get("hide_special_workspace", True) - DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get( - "dock_hide_special_workspace_apps", True - ) - - NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s") - NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) - NOTIFICATION_IGNORED_APPS_HISTORY = config.get( - "notification_ignored_apps_history", ["Hyprshot"] - ) - NOTIFICATION_LIMITED_APPS_HISTORY = config.get( - "notification_limited_apps_history", ["Spotify"] - ) - -else: - WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT - DOCK_POSITION = "Bottom" - DOCK_ENABLED = True - DOCK_ALWAYS_OCCLUDED = False - DOCK_AUTO_HIDE = True - TERMINAL_COMMAND = "kitty -e" - DOCK_THEME = "Pills" - DOCK_ICON_SIZE = 60 - WINDOW_SWITCHER_ITEMS_PER_ROW = 10 - HIDE_SPECIAL_WORKSPACE = True - DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True - - NOTIFICATION_TIMEOUT_STR = "5s" - NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) - NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"] - NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"] diff --git a/config/hypr/colors.lua b/config/hypr/colors.lua new file mode 100644 index 00000000..6dbf68f3 --- /dev/null +++ b/config/hypr/colors.lua @@ -0,0 +1,187 @@ +return { + + background = "rgb(121318)", + + blue = "rgb(b7c4ff)", + + blue_container = "rgb(364479)", + + blue_source = "rgb(0000ff)", + + blue_value = "rgb(0000ff)", + + cyan = "rgb(82d3e2)", + + cyan_container = "rgb(004e59)", + + cyan_source = "rgb(00ffff)", + + cyan_value = "rgb(00ffff)", + + error = "rgb(ffb4ab)", + + error_container = "rgb(93000a)", + + green = "rgb(95d5a7)", + + green_container = "rgb(10512f)", + + green_source = "rgb(00ff00)", + + green_value = "rgb(00ff00)", + + inverse_on_surface = "rgb(2f3036)", + + inverse_primary = "rgb(465d91)", + + inverse_surface = "rgb(e2e2e9)", + + magenta = "rgb(e4b7f3)", + + magenta_container = "rgb(5c386b)", + + magenta_source = "rgb(ff00ff)", + + magenta_value = "rgb(ff00ff)", + + on_background = "rgb(e2e2e9)", + + on_blue = "rgb(1e2d61)", + + on_blue_container = "rgb(dde1ff)", + + on_cyan = "rgb(00363e)", + + on_cyan_container = "rgb(9eefff)", + + on_error = "rgb(690005)", + + on_error_container = "rgb(ffdad6)", + + on_green = "rgb(00391d)", + + on_green_container = "rgb(b0f1c2)", + + on_magenta = "rgb(442253)", + + on_magenta_container = "rgb(f7d8ff)", + + on_primary = "rgb(142f60)", + + on_primary_container = "rgb(d9e2ff)", + + on_primary_fixed = "rgb(001944)", + + on_primary_fixed_variant = "rgb(2d4578)", + + on_red = "rgb(561d26)", + + on_red_container = "rgb(ffdadc)", + + on_secondary = "rgb(293042)", + + on_secondary_container = "rgb(dbe2f9)", + + on_secondary_fixed = "rgb(141b2c)", + + on_secondary_fixed_variant = "rgb(404659)", + + on_surface = "rgb(e2e2e9)", + + on_surface_variant = "rgb(c5c6d0)", + + on_tertiary = "rgb(412742)", + + on_tertiary_container = "rgb(fdd7fb)", + + on_tertiary_fixed = "rgb(2a132c)", + + on_tertiary_fixed_variant = "rgb(593e5a)", + + on_white = "rgb(00363d)", + + on_white_container = "rgb(9eeffd)", + + on_yellow = "rgb(263500)", + + on_yellow_container = "rgb(d4ec9d)", + + outline = "rgb(8f9099)", + + outline_variant = "rgb(44464f)", + + primary = "rgb(afc6ff)", + + primary_container = "rgb(2d4578)", + + primary_fixed = "rgb(d9e2ff)", + + primary_fixed_dim = "rgb(afc6ff)", + + red = "rgb(ffb2b9)", + + red_container = "rgb(72333b)", + + red_source = "rgb(ff0000)", + + red_value = "rgb(ff0000)", + + scrim = "rgb(000000)", + + secondary = "rgb(bfc6dc)", + + secondary_container = "rgb(404659)", + + secondary_fixed = "rgb(dbe2f9)", + + secondary_fixed_dim = "rgb(bfc6dc)", + + shadow = "rgb(000000)", + + source_color = "rgb(1a1c22)", + + surface = "rgb(121318)", + + surface_bright = "rgb(38393e)", + + surface_container = "rgb(1e1f25)", + + surface_container_high = "rgb(282a2f)", + + surface_container_highest = "rgb(33353a)", + + surface_container_low = "rgb(1a1b20)", + + surface_container_lowest = "rgb(0c0e13)", + + surface_dim = "rgb(121318)", + + surface_tint = "rgb(afc6ff)", + + surface_variant = "rgb(44464f)", + + tertiary = "rgb(dfbbde)", + + tertiary_container = "rgb(593e5a)", + + tertiary_fixed = "rgb(fdd7fb)", + + tertiary_fixed_dim = "rgb(dfbbde)", + + white = "rgb(82d3e0)", + + white_container = "rgb(004f58)", + + white_source = "rgb(ffffff)", + + white_value = "rgb(ffffff)", + + yellow = "rgb(b8cf84)", + + yellow_container = "rgb(3b4d0f)", + + yellow_source = "rgb(ffff00)", + + yellow_value = "rgb(ffff00)", + +} diff --git a/config/hypr/hypridle.conf b/config/hypr/hypridle.conf new file mode 100644 index 00000000..5fe2afd9 --- /dev/null +++ b/config/hypr/hypridle.conf @@ -0,0 +1,27 @@ +general { + lock_cmd = pidof lock || uv run --directory $HOME/.config/Modus python3 $HOME/.config/Modus/src/lock.py + 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. +} + +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/config/hypr/modus.lua b/config/hypr/modus.lua index 0e1f14fb..0388226e 100644 --- a/config/hypr/modus.lua +++ b/config/hypr/modus.lua @@ -1,5 +1,9 @@ -local modus = "$HOME/.config/Modus" +-- local modus = os.getenv("HOME") .. "/.config/Modus" +local modus = os.getenv("HOME") .. "/Projects/dev/Modus" +local colors = dofile(modus .. "/config/hypr/colors.lua") +local fabricSend = "fabric-cli exec modus" +-- startup hl.on("hyprland.start", function() local cmds = { "uwsm app -- awww-daemon", @@ -14,62 +18,118 @@ hl.on("hyprland.start", function() end end) --- Layer Rules for Blurs and Animations -local layer_rules = { - { - match = { namespace = "modus-notifications" }, - blur = true, - xray = 0, - blur_popups = true, - ignore_alpha = 0, - no_anim = true, - }, - { match = { namespace = "lockscreen" }, animation = "popin" }, - { - match = { namespace = "modus-launcher" }, - blur = true, - xray = 0, - blur_popups = true, - ignore_alpha = 0, - animation = "popin", - }, - { - match = { namespace = "fabric" }, - blur = true, - ignore_alpha = 0, - xray = 0, - blur_popups = true, - }, - { - match = { namespace = "modus" }, - blur = true, - xray = 0, - blur_popups = true, - ignore_alpha = 0, - }, - { match = { namespace = "notification-center" }, animation = "slide right" }, -} - -for _, rule in ipairs(layer_rules) do - hl.layer_rule(rule) -end - -local fabricSend = "fabric-cli exec modus1" - -- Reload Modus -hl.bind("SUPER + ALT + B", hl.dsp.exec_cmd("killall modus; cd " .. modus .. " && uwsm app -- uv run start")) +hl.bind("ALT + SHIFT + R", hl.dsp.exec_cmd("killall modus; cd " .. modus .. " && uwsm app -- uv run start")) --- Fabric Launcher Binds +-- Fabric Binds for key, method in pairs({ ["SUPER + SHIFT + Y"] = "app.set_css()", -- Reload CSS ["ALT + TAB"] = "switcher.show_switcher()", -- Application Switcher - ["SUPER + SPACE"] = "launcher.show_launcher()", -- App Launcher - ["SUPER + V"] = "launcher.show_launcher('clip')", -- Clipboard History - ["SUPER + W"] = "launcher.show_launcher('wall')", -- Wallpapers - ["ALT + SHIFT + W"] = "launcher.show_launcher('wall random', external=True)", -- Random Wallpaper - ["SUPER + Period"] = "launcher.show_launcher('em')", -- Emoji Picker - ["SUPER + ESCAPE"] = "launcher.show_launcher('power')", -- Power Menu - ["SUPER + SHIFT + M"] = "launcher.show_launcher('caffeine on', external=True)", -- Toggle Caffeine + ["SUPER + Z"] = "screencapture.toggle()", -- ScreenCapture + ["SUPER + S"] = "screencapture.toggle(ss='region')", -- Screenshot Region + ["ALT + SPACE"] = "switch_keyboard_layout()", -- KB_Layout Switcher }) do - hl.bind(key, hl.dsp.exec_cmd(fabricSend .. " '" .. method .. "'")) + hl.bind(key, hl.dsp.exec_cmd(fabricSend .. ' "' .. method .. '"')) end + +-- Spotlight Binds (spawns the process; it exits when closed to free memory. +-- A second press while open forwards a toggle to the running instance.) +local spotlightCmd = "cd " .. modus .. " && uv run python start.py spotlight" +for key, args in pairs({ + ["SUPER + D"] = "", -- Spotlight + ["SUPER + E"] = "em", -- Emoji + ["SUPER + V"] = "clip", -- Clipboard + ["SUPER + W"] = "wall", -- Wallpaper + ["ALT + SHIFT + W"] = "--external wr", -- Random Wallpaper +}) do + local cmd = spotlightCmd + if args ~= "" then + cmd = cmd .. " " .. args + end + hl.bind(key, hl.dsp.exec_cmd(cmd)) +end + +-- Layer Rules +hl.layer_rule({ + match = { + namespace = "^(lock|modus-.*)$", + }, + blur = true, + no_anim = true, + ignore_alpha = 0, + blur_popups = true, +}) + +hl.config({ + general = { + col = { + active_border = colors.primary, + inactive_border = colors.surface, + }, + gaps_in = 2, + gaps_out = 4, + border_size = 2, + layout = "scrolling", + }, + decoration = { + blur = { + enabled = true, + size = 10, + contrast = 0.89, + brightness = 1, + vibrancy = 0.5, + vibrancy_darkness = 0.5, + passes = 3, + ignore_opacity = true, + new_optimizations = true, + xray = true, + }, + active_opacity = 0.9, + inactive_opacity = 0.9, + rounding = 14, + rounding_power = 2.5, + shadow = { + enabled = true, + range = 20, + offset = { 0, 2 }, + render_power = 10, + color = "rgba(0, 0, 0, 0.25)", + }, + dim_inactive = true, + dim_strength = 0.05, + dim_special = 0.2, + }, + animations = { + enabled = true, + }, +}) + +hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } }) +hl.curve("easeInOutCubic", { type = "bezier", points = { { 0.65, 0.05 }, { 0.36, 1 } } }) +hl.curve("linear", { type = "bezier", points = { { 0, 0 }, { 1, 1 } } }) +hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1 } } }) +hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1 } } }) +hl.curve("move", { type = "bezier", points = { { 0.36, 0.13 }, { 0.1, 1.36 } } }) +hl.curve("new", { type = "bezier", points = { { 0.36, 0.13 }, { 0.1, 1.26 } } }) +hl.curve("workspace", { type = "bezier", points = { { 0.42, 0 }, { 0.58, 1 } } }) +hl.curve("resize", { type = "bezier", points = { { 0.46, 0 }, { 0.58, 1.16 } } }) + +hl.curve("easy", { type = "spring", mass = 1, stiffness = 71.2633, dampening = 15.8273644 }) + +hl.animation({ leaf = "global", enabled = true, speed = 1, bezier = "default" }) +hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" }) +hl.animation({ leaf = "windows", enabled = true, speed = 3.5, bezier = "new" }) +hl.animation({ leaf = "windowsIn", enabled = true, speed = 3.5, bezier = "move", style = "popin 87%" }) +hl.animation({ leaf = "windowsOut", enabled = true, speed = 3.5, bezier = "move", style = "popin 87%" }) +hl.animation({ leaf = "fadeIn", enabled = true, speed = 3.5, bezier = "default" }) +hl.animation({ leaf = "fadeOut", enabled = true, speed = 3.5, bezier = "default" }) +hl.animation({ leaf = "fade", enabled = true, speed = 3.03, bezier = "quick" }) +hl.animation({ leaf = "layers", enabled = true, speed = 3.81, bezier = "easeOutQuint" }) +hl.animation({ leaf = "layersIn", enabled = true, speed = 4, bezier = "easeOutQuint", style = "fade" }) +hl.animation({ leaf = "layersOut", enabled = true, speed = 1.5, bezier = "linear", style = "fade" }) +hl.animation({ leaf = "fadeLayersIn", enabled = true, speed = 1.79, bezier = "almostLinear" }) +hl.animation({ leaf = "fadeLayersOut", enabled = true, speed = 1.39, bezier = "almostLinear" }) +hl.animation({ leaf = "workspaces", enabled = true, speed = 2, bezier = "workspace", style = "" }) +hl.animation({ leaf = "workspacesIn", enabled = true, speed = 2, bezier = "workspace", style = "" }) +hl.animation({ leaf = "workspacesOut", enabled = true, speed = 2, bezier = "workspace", style = "" }) +hl.animation({ leaf = "zoomFactor", enabled = true, speed = 7, bezier = "quick" }) diff --git a/config/matugen/templates/macos.css b/config/matugen/templates/modus.css similarity index 100% rename from config/matugen/templates/macos.css rename to config/matugen/templates/modus.css diff --git a/config/mods.toml b/config/mods.toml new file mode 100644 index 00000000..ebff9cdc --- /dev/null +++ b/config/mods.toml @@ -0,0 +1,27 @@ +[Mods.color-picker] +icon = "misc/color-picker.svg" +icon-size = 18 +order = 0 +on-left = "hyprpicker -a -n -f hex && sleep 0.1 && c=$(wl-paste)" +on-middle = "hyprpicker -a -n -f rgb && sleep 0.1 && c=$(wl-paste)" +on-right = "hyprpicker -n -f hsv | wl-copy -n && sleep 0.1 && c=$(wl-paste)" + +[Mods.google-lens] +icon = "google-lens.svg" +icon-size = 16 +order = 1 +on-clicked = "python3 -m utils.google_lens_cli" + + +# [Mods.terminal] +# icon = "terminal.svg" +# icon-size = 18 +# +# [[Mods.terminal.options]] +# label = "Terminal" +# on-clicked = "kitty" +# +# [[Mods.terminal.options]] +# label = "Terminal but scary" +# on-clicked = "ghostty" +# divider = true diff --git a/debug_memory.py b/debug_memory.py deleted file mode 100644 index 2e175e74..00000000 --- a/debug_memory.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Real-time memory monitor for debugging expanded player memory leaks. -This module provides functions to track memory usage in real-time. -""" - -import psutil -import os -import gc -import threading -import time -from loguru import logger - - -class MemoryMonitor: - """Real-time memory monitoring for debugging memory leaks.""" - - def __init__(self): - self.process = psutil.Process(os.getpid()) - self.baseline_memory = None - self.last_memory = None - self.monitoring = False - self.monitor_thread = None - - def get_memory_usage(self): - """Get current memory usage in MB.""" - return self.process.memory_info().rss / 1024 / 1024 - - def get_memory_details(self): - """Get detailed memory information.""" - memory_info = self.process.memory_info() - memory_percent = self.process.memory_percent() - - return { - "rss_mb": memory_info.rss / 1024 / 1024, - "vms_mb": memory_info.vms / 1024 / 1024, - "percent": memory_percent, - "num_threads": self.process.num_threads(), - } - - def set_baseline(self, label="Baseline"): - """Set the baseline memory usage.""" - self.baseline_memory = self.get_memory_usage() - logger.info(f"🎯 {label} memory: {self.baseline_memory:.1f} MB") - return self.baseline_memory - - def log_memory_change(self, label="Memory Check", force_gc=True): - """Log current memory usage and change from baseline.""" - if force_gc: - gc.collect() - - current_memory = self.get_memory_usage() - details = self.get_memory_details() - - if self.baseline_memory: - delta = current_memory - self.baseline_memory - logger.info( - f"📊 {label}: {current_memory:.1f} MB (Δ: {delta:+.1f} MB) | Threads: { - details['num_threads'] - }" - ) - else: - logger.info( - f"📊 {label}: {current_memory:.1f} MB | Threads: { - details['num_threads'] - }" - ) - - self.last_memory = current_memory - return current_memory - - def log_memory_spike(self, threshold_mb=10): - """Log if there's a significant memory increase.""" - if self.last_memory: - current = self.get_memory_usage() - increase = current - self.last_memory - if increase > threshold_mb: - logger.warning( - f"🚨 MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)" - ) - return True - return False - - def start_continuous_monitoring(self, interval_seconds=2): - """Start continuous background monitoring.""" - if self.monitoring: - return - - self.monitoring = True - - def monitor_loop(): - while self.monitoring: - try: - self.log_memory_change("Continuous Monitor", force_gc=False) - time.sleep(interval_seconds) - except Exception as e: - logger.error(f"Memory monitor error: {e}") - break - - self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True) - self.monitor_thread.start() - logger.info( - f"🔍 Started continuous memory monitoring (every {interval_seconds}s)" - ) - - def stop_continuous_monitoring(self): - """Stop continuous monitoring.""" - if self.monitoring: - self.monitoring = False - logger.info("🛑 Stopped continuous memory monitoring") - - -# Global memory monitor instance -memory_monitor = MemoryMonitor() - -# Convenience functions for easy use - - -def set_memory_baseline(label="Baseline"): - """Set memory baseline.""" - return memory_monitor.set_baseline(label) - - -def log_memory(label="Memory Check"): - """Log current memory usage.""" - return memory_monitor.log_memory_change(label) - - -def start_memory_monitoring(): - """Start continuous memory monitoring.""" - memory_monitor.start_continuous_monitoring() - - -def stop_memory_monitoring(): - """Stop continuous memory monitoring.""" - memory_monitor.stop_continuous_monitoring() - - -def check_memory_spike(): - """Check for memory spike.""" - return memory_monitor.log_memory_spike() - - -# Test function - - -def test_memory_monitor(): - """Test the memory monitor.""" - print("Testing Memory Monitor...") - monitor = MemoryMonitor() - - monitor.set_baseline("Test Start") - - # Simulate some memory usage - big_list = [i for i in range(100000)] - monitor.log_memory_change("After creating big list") - - del big_list - monitor.log_memory_change("After deleting big list") - - print("Memory monitor test complete!") - - -if __name__ == "__main__": - test_memory_monitor() diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..b04e2c73 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,99 @@ +# Architecture Overview + +Modus is built with Python and the [Fabric](https://github.com/Fabric-Development/fabric/) +framework (GTK/Wayland wrapper), plus custom C extensions for high-performance +Wayland interactions. + +## 1. Core Application Loop + +Entry point is `start.py`, delegates to `src/main.py`. `main.py` instantiates +`fabric.Application`, which manages lifecycle of floating Wayland windows: + +- **Panel**: Top bar (`src/window/panel/`) +- **Dock**: macOS-style bottom dock (`src/window/dock/`) +- **Control Center**: Right-side quick settings (`src/window/controlcenter/`) +- **App Switcher**: Alt-Tab window switcher (`src/window/switcher/`) +- **Lock Screen**: Session lock (`src/window/lock.py`) +- **Spotlight Search**: Super+D search overlay (`src/window/spotlight/`) + +## 2. Spotlight Search Engine + +The spotlight (`src/window/spotlight/`) is a plugin-powered search overlay. +It has three layers: + +### Layer 1: UI (`main.py`) + +A GTK window with an entry field and scrollable result list. Renders results +from plugins. Manages keyboard navigation, viewport sizing, and result +selection. Uses a 200ms debounce on the entry field to avoid searching on +every keystroke. + +### Layer 2: Search Pipeline (`core/search.py`) + +On each keystroke: + +1. Routes the query — if it starts with a keyword (e.g., `gg ` for Google), + the query goes exclusively to that plugin +2. Otherwise, dispatches to all `searchable` plugins in parallel via daemon + threads +3. Collects results via `GLib.idle_add` (main thread callback) +4. Merges results sorted by plugin priority then result score +5. Cancels previous in-flight searches with `threading.Event` tokens + +### Layer 3: Plugin System (`core/loader.py`, `core/manager.py`, `core/registry.py`) + +- **Loader**: Scans `config/plugins/` for `.py` files and package directories. + Handles per-plugin virtualenvs via `uv` for dependency isolation. Supports + deep reload (re-import from disk without restart). +- **Manager**: Orchestrates lifecycle — instantiate, enable/disable, reload, + release memory. Persists disabled plugin list to `plugins.json`. +- **Registry**: Stores plugin metadata keyed by id. Maintains keyword-to-plugin + map for fast routing. + +### Data flow + +``` +User types → debounce (200ms) → route query + ├─ keyword match → single plugin search → render + └─ global search → dispatch to threads → collect → merge → render + +User selects result → action callback → (copy, launch, etc.) +User presses Escape → cancel searches → release plugin memory → hide +``` + +## 3. High-Performance App Switcher (AppCapture) + +The App Switcher (Alt+Tab) needs live video previews of Wayland windows. Pure +Python DBus polling is too slow, so Modus uses a C-extension at +`src/window/switcher/app-capture/`. + +- Uses `hyprland-toplevel-export-v1` Wayland protocol to get dmabuf/shm buffers + from Hyprland +- Compiled to `libappcapture.so`, exposed to Python via GObject Introspection +- Framerate throttled by Python using `invoke_repeater`, controlled by + `switcher_live_preview_delay_ms` in `config.toml` + +## 4. Global Menu (DBus) + +macOS-style menu bar in the top panel. + +- Apps export menus over `com.canonical.dbusmenu` DBus +- Custom C shim (`src/window/globalmenu/libmenu_button_shim.c`) forces GTK apps to + export menus even if they weren't designed to +- Python daemon (`src/window/globalmenu/service.py`) listens for exported menus and + renders them as Fabric widgets in the panel + +## 5. State Management & Services + +Services in `src/services/` monitor system state asynchronously and emit +signals: + +| Service | Source | What it tracks | +|---------|--------|---------------| +| `battery.py` | UPower over DBus | Battery percentage, charging state | +| `bluetooth.py` | BlueZ over DBus | Bluetooth devices | +| `brightness.py` | `brightnessctl` / `ddcutil` | Screen brightness | +| `network.py` | NetworkManager over DBus | WiFi, ethernet state | + +Uses signals (`connect("changed", update_ui)`) for reactive UI — virtually 0% +CPU while idle. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..90d07310 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,86 @@ +# Configuration Guide + +Modus is highly configurable using TOML files located in the `config/` directory. + +## File Overview + +| File | Purpose | +|------|---------| +| `config.toml` | Main application configuration (modules, features, toggles). | +| `mods.toml` | Custom panel button definitions (menus, shell commands). | +| `dock.toml` | Pinned applications for the dock. | + +--- + +## `config.toml` (Main Settings) + +The `config.toml` file controls which widgets and services are enabled. Most changes here require restarting Modus (`uv run start`). + +**Key Settings:** +- `wallpapers_dir`: Path to the directory where your wallpapers are stored. +- `dock_enabled`: Toggle the dock widget (`true`/`false`). +- `dock_auto_hide`: Whether the dock hides automatically when windows overlap it. +- `notification_timeout`: Duration before a notification automatically dismisses (e.g., `"5s"`). +- `notification_ignored_apps`: Array of app names whose notifications should be suppressed. +- `weather_location`: Set your city for the weather widget (e.g., `"Patan, Nepal"`). + +**App Switcher & Screencapture:** +- `window_switcher`: Enables the custom Alt-Tab window switcher. +- `switcher_live_preview`: Enables real-time Wayland video previews of running applications. +- `switcher_live_preview_delay_ms`: Framerate delay for previews (e.g., `200` = ~5fps). Lower is smoother but uses more CPU. +- `osd`: Enables on-screen displays for volume and brightness changes. + +**Spotlight Search Keybinds** (defined in `config/hypr/modus.lua`): + +| Key | Action | +|-----|--------| +| `Super + D` | Open spotlight search (default) | +| `Super + E` | Open emoji picker | +| `Super + V` | Open clipboard history | +| `Super + W` | Open wallpaper browser | +| `Alt + Shift + W` | Set random wallpaper | + +These can be customized in `config/hypr/modus.lua`. The spotlight `.toggle()` +method accepts optional arguments to pre-fill keywords: `toggle('em')` opens +the emoji picker directly. + +--- + +## `mods.toml` (Custom Panel Buttons) + +You can define custom buttons for the top panel that run shell commands or show dropdown menus. **Changes to this file reload instantly!** + +```toml +[Mods.terminal] +icon = "terminal.svg" +icon-size = 16 +order = 1 +on-clicked = "kitty" + +[Mods.color-picker] +icon = "misc/color-picker.svg" +icon-size = 22 +order = 0 +on-left = "hyprpicker -a -n -f hex | wl-copy" +on-right = "hyprpicker -n -f hsv | wl-copy" +``` + +**Properties:** +- `icon`: The name of an SVG icon placed in `src/assets/icons/`. +- `icon-size`: Defaults to 16. +- `order`: Determines left-to-right position (lower numbers appear first). +- `on-clicked`, `on-left`, `on-right`, `on-middle`: Shell commands executed via `sh -c`. Supports complex piping `|` and `&&`. + +--- + +## `dock.toml` (Pinned Apps) + +Controls which applications are permanently pinned to the dock. +```toml +pinned = [ + "firefox", + "kitty", + "org.gnome.Nautilus" +] +``` +Ensure you use the exact `.desktop` file name or application ID. diff --git a/docs/faqs_tips.md b/docs/faqs_tips.md new file mode 100644 index 00000000..005b962a --- /dev/null +++ b/docs/faqs_tips.md @@ -0,0 +1,103 @@ +# FAQs & Tips + +## Frequently Asked Questions + +### 1. The Global Menu isn't showing up for some apps? + +Modus uses a custom `libmenu_button_shim.so` with `appmenu-gtk-module` to +export GTK menus over DBus. +- Ensure `appmenu-gtk-module` is installed +- Ensure the shim was compiled correctly during installation +- Non-GTK apps (Electron, Qt) may need flags like + `export QT_QPA_PLATFORMTHEME=appmenu-qt5` + +### 2. High CPU usage when App Switcher is open? + +The App Switcher uses a custom C-backend to capture live window previews. +- 4K windows with many open can spike CPU +- **Fix**: Increase `switcher_live_preview_delay_ms` in `config.toml` (e.g., `200` + for ~5 FPS) or set `switcher_live_preview = false` to disable previews entirely + +### 3. Dock covering fullscreen games/videos? + +Set `dock_auto_hide = true` in `config.toml`. The dock hides automatically +when a window is maximized or fullscreen. + +### 4. Volume/Brightness OSD not showing? + +Set `osd = true` in `config.toml`. Requires `brightnessctl` for brightness and +Pipewire/Wireplumber for audio. + +### 5. Weather widget shows wrong location? + +Update `weather_location` in `config.toml` (e.g., `"London, UK"` or +`"Patan, Nepal"`). + +### 6. Spotlight plugin isn't showing up? + +- Check the file is in `config/plugins/` (not `examples/plugins/`) +- Check `config/plugins/` is NOT in `.gitignore` for your own repos +- Run `journalctl --user -f -t modus` and look for `[PluginLoader]` errors +- Make sure the file has `PLUGIN = YourClassName` at the bottom +- If using dependencies, ensure `uv` is installed +- After editing, run `fabric-cli exec modus1 'deep_reload your_plugin_id'` + +### 7. Plugin has dependencies but they aren't installed? + +- Check `uv` is installed (`which uv`) +- Check `requirements.txt` is next to your `.py` file or inside your package dir +- The venv lives at `config/plugins/.venvs//` +- Delete that directory to force re-install on next load + +--- + +## Pro Tips + +- **Live Reload for Mods**: `config/mods.toml` is monitored for changes. Edits + apply instantly — no restart needed + +- **Pinning Apps**: Use exact `.desktop` filename in `dock.toml` (e.g., + `"org.gnome.Nautilus"`, not `"Files"`) + +- **Custom Scripts**: `on-clicked` in `mods.toml` can point to bash scripts in + your home directory + +- **Theme Updates**: Modus uses Matugen tied to your wallpaper. Set a new + wallpaper and the shell recolors automatically + +- **Spotlight Keyboard Shortcuts** (from `config/hypr/modus.lua`): + + | Key | Action | + |-----|--------| + | `Super + D` | Open spotlight search | + | `Super + E` | Open emoji picker | + | `Super + V` | Open clipboard history | + | `Super + W` | Open wallpaper browser | + +- **Deep Reload a Plugin**: After editing `config/plugins/hello.py`: + + ```bash + fabric-cli exec modus1 'deep_reload hello' + ``` + + No Modus restart required. Works with all plugins. + +- **Test a Plugin from Terminal**: + + ```bash + fabric-cli exec modus1 'wth london' + ``` + + Routes to the weather plugin's `search()` and prints results to the Modus log. + +- **Check Plugin Load Log**: + + ```bash + journalctl --user -f -t modus + ``` + + Filters to Modus messages only. Shows plugin discovery, load errors, and + search crashes. + +- **Wayland Screencapture**: Toggle from panel or bind in Hyprland: + `bind = SUPER, S, exec, fabric-cli exec modus 'screencapture.toggle()'` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..c27d39e5 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,62 @@ +# Installation Guide + +Modus is a hackable shell for Hyprland built with Fabric. There are two primary ways to install it on Arch Linux. + +## Automated Installation (Recommended) + +> [!IMPORTANT] +> You need a working installation of Hyprland and knowledge of how it works. Ensure you are running this as a regular user (not root). + +1. **Clone the Repository:** + ```bash + git clone https://github.com/S4NKALP/Modus ~/.config/Modus + cd ~/.config/Modus + ``` + +2. **Run the Installer Script:** + ```bash + ./install.sh + ``` + The interactive installer will check requirements, install missing dependencies via `paru`/`yay`, build the necessary C-extensions (like `libappcapture.so` and `libmenu_button_shim.so`), and configure your Hyprland environment automatically. + +## Manual Installation + +If you prefer to install dependencies and compile the extensions manually: + +1. **Install Dependencies:** + ```bash + paru -S uv fabric-cli-git cliphist gnome-bluetooth-3.0 slurp ffmpeg hypridle hyprsunset hyprpicker hyprshot grim libnotify matugen-bin playerctl gtk-session-lock awww apple-fonts swappy wl-clipboard webp-pixbuf-loader wf-recorder acpi brightnessctl power-profiles-daemon uwsm cinnamon-desktop ddcutil at-spi2-core gcc make pkgconf appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 meson ninja wayland-protocols --needed + ``` + +2. **Clone and Sync:** + ```bash + git clone https://github.com/S4NKALP/Modus ~/.config/Modus + cd ~/.config/Modus + uv sync + ``` + +3. **Compile the App Switcher C-Backend:** + ```bash + cd src/window/switcher/app-capture + meson setup builddir + meson compile -C builddir + cd ../../../.. + ``` + +4. **Compile the Global Menu Shim:** + ```bash + cd src/window/globalmenu + gcc -shared -fPIC -O2 -o libmenu_button_shim.so libmenu_button_shim.c $(pkg-config --cflags --libs gtk+-3.0) -ldl + cd ../../.. + ``` + +5. **Start Modus:** + ```bash + uv run start + ``` + +> [!TIP] +> ### Post-Installation Recommendations +> - Install the recommended icon theme: [MacTahoe-icon-theme](https://github.com/vinceliuice/MacTahoe-icon-theme) +> - Install the recommended GTK theme: [MacTahoe-gtk-theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) +> - Edit your Hyprland configuration file to source `~/.config/Modus/config/hypr/modus.conf` for the best compatibility. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..bd6f0f15 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,533 @@ +# Plugin Development Guide + +Modus spotlight supports third-party plugins. Drop a Python file in `config/plugins/` and it gets auto-discovered on next startup. + +## Quick Start + +Copy the hello example to your plugins directory: + +```bash +cp -r examples/plugins/hello.py config/plugins/hello.py +``` + +Open spotlight and type `hi` or `hello`. The plugin shows a greeting result. + +## How It Works + +## How It Works + +1. On startup, Modus scans `config/plugins/` for `.py` files and package directories. +2. Each file is imported. The `PLUGIN` module-level variable is extracted (must be a `SpotlightPlugin` subclass). +3. Plugins are instantiated, initialized, and registered. +4. User types query -> spotlight routes to matching plugin (by keyword or global search) -> `search()` is called -> results are rendered. + +## Plugin Class Attributes + +| Attribute | Type | Required | Description | +|-----------------|------------|----------|-------------| +| `id` | `str` | Yes | Unique identifier (e.g., `"weather"`). | +| `name` | `str` | Yes | Human-readable name shown in UI. | +| `icon` | `str` | No | GTK icon name for the header. | +| `keywords` | `list[str]`| No | Trigger prefixes (e.g., `["wall", "wr"]`). User types `wall ` to activate. | +| `searchable` | `bool` | No | If `True`, plugin appears in global search (no prefix needed). Default `False`. | +| `priority` | `int` | No | Lower = searched first in global results. Default `100`. | +| `refresh_interval` | `int` | No | Auto-refresh timer in ms (0 = disabled). Fires `on_timer_tick()`. | + +## Methods + +### Required + +```python +def search(self, query: str, token: Any) -> list[SearchResult]: + """Return results matching query. + + token: Cancellation token. Check token.is_cancelled before expensive work. + Must return list of SearchResult. Never create GTK widgets. + """ +``` + +### Optional + +| Method | When Called | Use Case | +|--------|-------------|----------| +| `initialize()` | After instantiation | Load data, start services | +| `cleanup()` | On unload/disable | Free resources | +| `reload()` | On refresh | Default: cleanup + initialize | +| `on_activate(update_fn)` | Keyword prefix matched | Set up state for keyword mode | +| `on_deactivate()` | Keyword prefix cleared | Tear down keyword state | +| `on_submit(query)` | Enter pressed in keyword mode | Handle final input | +| `detect(text)` | Global search, each keystroke | Return `True` to exclusively handle input | +| `handle_external(command, args)` | External command (e.g., keybind) | Handle non-search invocations | +| `release_memory()` | Spotlight hides | Clear heavy cached data | + +## SearchResult Fields + +```python +SearchResult( + id="unique_id", # Unique within your plugin + title="Display Title", # Main text + subtitle="Description", # Secondary text + icon_name="", # GTK icon name (optional) + score=80.0, # 0-100, higher = better match + render_type="default", # Controls rendering (see below) + action=lambda: ..., # Called when user selects this result + plugin_name="", # Auto-filled by spotlight + plugin_id="", # Auto-filled by spotlight + metadata={}, # Arbitrary data for renderers +) +``` + +### Render Types + +| `render_type` | Widget Style | Use Case | +|-------------------|-----------------------------|-----------------------| +| `"default"` | Icon + title + subtitle | Generic results | +| `"app"` | Desktop app icon + name | Application spotlight | +| `"calc"` | Expression + result | Calculator | +| `"emoji"` | Emoji char + name + keywords | Emoji picker | +| `"power"` | Icon + name + description | Power menu | +| `"wallpaper"` | Thumbnail image | Wallpaper picker | +| `"clipboard_text"`| Text preview | Clipboard entries | +| `"clipboard_image"` | Image preview | Clipboard images | + +Use `"default"` unless you need a specific layout. Custom render types can be added to `main.py`'s `_render_result()`. + +## Keyword Plugins vs Global Search + +**Keyword-only** (`searchable = False`): +- User types `gg ` -> routes exclusively to your plugin +- `search()` receives the text after the prefix (e.g., `"linux news"`) +- Good for: YouTube search, Google search, wallpaper browser + +**Global search** (`searchable = True`): +- Plugin appears in results alongside apps, calculator, emoji +- `search()` receives the full query +- Good for: Calculator, emoji, power menu + +**Both** (`searchable = True` + `keywords`): +- Activates via keyword prefix AND appears in global results +- Good for: Wallpapers, clipboard, weather + +## Cancellation + +The `token` parameter has an `is_cancelled` flag. Check it before expensive operations: + +```python +def search(self, query: str, token: Any) -> list[SearchResult]: + results = [] + for item in self._large_dataset: + if token.is_cancelled: + return [] # Abort immediately + if self._matches(item, query): + results.append(item) + return results +``` + +The spotlight cancels previous searches when the user types new input. Without cancellation checks, your plugin wastes CPU on stale queries. + +## Examples + +### Hello World (single-file, no deps) + +File: `config/plugins/hello.py` + +```python +from typing import Any + +from window.spotlight.api import SpotlightPlugin, SearchResult + + +class HelloPlugin(SpotlightPlugin): + id = "hello" + name = "Hello" + icon = "face-smile-symbolic" + keywords = ["hi", "hello"] + searchable = True + priority = 100 + + def search(self, query: str, token: Any) -> list[SearchResult]: + if not query.strip(): + return [] + return [ + SearchResult( + id="hello_world", + title="Hello, World!", + subtitle="A friendly greeting", + icon_name="face-smile-symbolic", + score=100.0, + render_type="default", + action=lambda: print("Hello!"), + ) + ] + +PLUGIN = HelloPlugin +``` + +Test: `fabric-cli exec modus1 'deep_reload hello'` + +### Google / YouTube / Link Opener (keyword-only, shell-safe) + +File: `config/plugins/query.py` (built-in, but you can write your own) + +```python +import urllib.parse +from typing import Any + +from utils.functions import spawn_detached +from window.spotlight.api import SpotlightPlugin, SearchResult + + +class GooglePlugin(SpotlightPlugin): + id = "google" + name = "Google" + icon = "system-search-symbolic" + keywords = ["gg"] + searchable = False + + def search(self, query: str, token: Any) -> list[SearchResult]: + return [] + + def on_submit(self, query: str) -> None: + q = query.strip() + if q: + url = f"https://www.google.com/search?q={urllib.parse.quote(q)}" + spawn_detached(["xdg-open", url]) # no shell injection risk + + +PLUGIN = GooglePlugin +``` + +Usage: type `gg linux news` then Enter. Opens browser. + +### Weather Plugin (package plugin, third-party deps, I/O cancellation) + +Full example on disk at `config/plugins/weather/`. Demonstrates: +- Package plugin structure +- Auto-installed dependencies via `requirements.txt` + `uv` +- I/O with cancellation checks +- Result caching + +```python +"""Weather plugin for Modus spotlight. + +Demonstrates: + - Package plugin structure + - Third-party deps via requirements.txt (uv venv auto-install) + - Keyword activation + global search + - Deep reload during development + - Cancellation checks for I/O + - External command support +""" + +import os +from typing import Any + +from window.spotlight.api import SpotlightPlugin, SearchResult + +try: + import requests +except ImportError: + requests = None + + +class WeatherPlugin(SpotlightPlugin): + id = "weather" + name = "Weather" + icon = "weather-clear-symbolic" + keywords = ["wth", "weather"] + searchable = True + priority = 75 + + def __init__(self, context): + super().__init__(context) + self._cache: dict | None = None + + def initialize(self) -> None: + pass + + def cleanup(self) -> None: + self._cache = None + + def search(self, query: str, token: Any) -> list[SearchResult]: + q = query.strip().lower() + + # Strip keyword prefix ("wth london" -> "london") + for prefix in ("wth ", "weather "): + if q.startswith(prefix): + q = q[len(prefix):].strip() + break + if q in ("wth", "weather"): + q = "" + + if not q: + return [SearchResult(...hint...)] + + data = self._fetch_weather(q, token) + if token.is_cancelled or not data: + return [] + + temp = data.get("main", {}).get("temp", "?") + desc = data.get("weather", [{}])[0].get("description", "?") + city = data.get("name", q) + + return [ + SearchResult( + id=f"weather_{q}", + title=f"{city}: {temp}°C", + subtitle=desc.capitalize(), + icon_name="weather-clear-symbolic", + score=100.0, + render_type="default", + ) + ] + + def _fetch_weather(self, city: str, token: Any) -> dict | None: + """Fetch weather from OpenWeatherMap with caching.""" + if self._cache: + return self._cache + + api_key = os.environ.get("OPENWEATHER_API_KEY") + if not api_key or requests is None: + return None + + resp = requests.get( + "https://api.openweathermap.org/data/2.5/weather", + params={"q": city, "appid": api_key, "units": "metric"}, + timeout=10, + ) + if token.is_cancelled: + return None + if resp.status_code == 200: + self._cache = resp.json() + return self._cache + return None + + def handle_external(self, command: str, args: str) -> None: + print(f"External: {command} {args}") + + +PLUGIN = WeatherPlugin +``` + +File layout: + +``` +examples/plugins/weather/ + __init__.py # plugin code + requirements.txt # requests>=2.28 +``` + +Copy to plugins dir and try: +```bash +cp -r examples/plugins/weather config/plugins/weather +fabric-cli exec modus1 'deep_reload weather' +``` + +Now type `wth london` in spotlight. + +On first load, `PluginLoader` creates `config/plugins/.venvs/weather/` via `uv venv`, +installs `requests` via `uv pip install`, then imports your plugin. + +Usage: type `wth london` or just search `london` in global mode. + +Set env var in your Hyprland config: +``` +env = OPENWEATHER_API_KEY, your_key_here +``` + +### Color Picker (global search, clipboard action) + +```python +from typing import Any + +from utils.functions import run_command +from window.spotlight.api import SpotlightPlugin, SearchResult + + +class ColorPlugin(SpotlightPlugin): + id = "colors" + name = "Colors" + icon = "color-select-symbolic" + keywords = [] + searchable = True + priority = 80 + + COLORS = [ + ("Red", "#FF0000"), + ("Green", "#00FF00"), + ("Blue", "#0000FF"), + ] + + def search(self, query: str, token: Any) -> list[SearchResult]: + q = query.lower().strip() + if not q: + return [] + results = [] + for name, hex_code in self.COLORS: + if q in name.lower(): + results.append( + SearchResult( + id=f"color_{name.lower()}", + title=name, + subtitle=hex_code, + icon_name="color-select-symbolic", + score=90.0, + render_type="default", + action=lambda h=hex_code: self._copy_color(h), + ) + ) + return results + + def _copy_color(self, hex_code: str) -> None: + run_command(["wl-copy"], input=hex_code.encode(), text=False) + + +PLUGIN = ColorPlugin +``` + +## Package Plugins + +For complex plugins, use a directory with `plugin.py` or `__init__.py`: + +``` +config/plugins/ + myplugin/ + __init__.py # or plugin.py — must export PLUGIN = MyPluginClass + utils.py + data.json + requirements.txt # optional third-party deps +``` + +## Third-Party Dependencies + +If your plugin needs external Python packages, add `requirements.txt` next to your plugin file or in your package directory. Modus auto-installs them into an isolated virtualenv using `uv`. + +### Single-file plugin with dependencies + +``` +config/plugins/ + weather.py + weather/requirements.txt # companion directory +``` + +or + +``` +config/plugins/ + weather.py + requirements.txt # next to .py file +``` + +### Package plugin with dependencies + +``` +config/plugins/ + myplugin/ + __init__.py + utils.py + requirements.txt +``` + +### What happens at load time + +1. Modus checks for `requirements.txt` next to your `.py` file or in your package directory. +2. If found, it creates a venv at `config/plugins/.venvs//` using `uv venv`. +3. It runs `uv pip install -r requirements.txt --python /bin/python`. +4. The venv's `site-packages` is added to `sys.path` before your plugin is imported. +5. If `requirements.txt` changes later, deps are re-installed automatically. +6. **Requirement**: `uv` must be installed (`pip install uv` or `cargo install uv`). + +Only one `requirements.txt` per plugin is consulted. The venv persists across restarts. + +## Development Workflow + +### Edit → Reload → Test loop + +The fastest way to develop a plugin: + +1. Edit your plugin file +2. Run `fabric-cli exec modus1 'deep_reload your_plugin_id'` +3. Open spotlight and test + +No Modus restart needed. + +Example session: + +```bash +# Copy the weather example +cp -r examples/plugins/weather config/plugins/weather + +# --- First iteration --- +vim config/plugins/weather/__init__.py +fabric-cli exec modus1 'deep_reload weather' + +# --- Watch logs --- +journalctl -f --user -t modus + +# --- Second iteration --- +vim config/plugins/weather/__init__.py +fabric-cli exec modus1 'deep_reload weather' + +# --- Test output from terminal --- +fabric-cli exec modus1 'wth london' +``` + +### Deep reload under the hood + +``` +fabric-cli exec modus1 'deep_reload weather' +``` + +1. `Spotlight.handle_external("deep_reload", "weather")` catches the built-in command +2. `PluginManager.deep_reload("weather")` runs: + - `entry.instance.cleanup()` — free old resources + - `PluginLoader.unload_module("modus_plugin_user_weather")` — removes stale module from `sys.modules` + - `PluginLoader._load_package_plugin(path, "user")` — re-imports from disk + - `entry.plugin_class = fresh_class` — swaps the class reference + - `entry.instance = fresh_class(context)` — new instance + - `entry.instance.initialize()` — fresh setup + +### Check from terminal without UI + +```bash +fabric-cli exec modus1 'your_plugin_id some_query' +``` + +This routes to the plugin via keyword match or direct id lookup. + +### Errors & logging + +If a plugin fails to load or crashes during search, check the Modus log: + +```bash +journalctl --user -f -t modus +``` + +On reload failure, the old instance stays active. The log will show what went wrong. + +## External Commands (Keybinds) + +Plugins can be invoked from Hyprland keybinds via `handle_external`: + +```python +def handle_external(self, command: str, args: str) -> None: + if command == "myplugin": + self._do_stuff(args) +``` + +Hyprland config: +```lua +["SUPER + M"] = "fabric-cli exec modus1 'myplugin some_argument'" +``` + +## Tips + +- **Cache heavy data** in `initialize()`, not in `search()`. The search method runs on every keystroke. +- **Use `fuzzy_score`** from `utils.functions` for fuzzy matching against user input. +- **Keep `search()` fast**. If you do I/O, check `token.is_cancelled` frequently between operations. +- **Use `metadata`** to pass data to renderers. The spotlight owns all GTK widget creation — your plugin never touches widgets. +- **Use `run_command` from `utils.functions`** for subprocess calls — handles timeouts and errors. Never use shell strings (`exec_shell_command(f"...")`) with user input — that's a shell injection risk. +- **Use `spawn_detached` from `utils.functions`** to launch background processes without blocking. +- **Use `thread()` from `utils.functions`** for background work from `initialize()` or `handle_external()`. +- **Set `OPENWEATHER_API_KEY`** in your Hyprland env for the weather example. +- **Test from terminal**: `fabric-cli exec modus1 'deep_reload your_plugin_id'` diff --git a/docs/styling.md b/docs/styling.md new file mode 100644 index 00000000..5251f1aa --- /dev/null +++ b/docs/styling.md @@ -0,0 +1,23 @@ +# Styling Guide + +Modus handles styling dynamically using **Matugen**. This allows the shell to automatically match its color palette with your current wallpaper. + +## How it works + +1. When a new wallpaper is set (or the shell starts), Matugen generates a color palette. +2. Modus uses a Matugen template located at `config/matugen/templates/modus.css`. +3. Matugen processes the template and outputs the final stylesheet to `src/shared/styles/colors.css`. +4. Modus automatically hot-reloads the new CSS, instantly applying the colors to the panel, dock, widgets, and menus. + +## Customizing the Look + +If you want to manually tweak borders, padding, font sizes, or add custom CSS classes, you should modify the files in `src/shared/styles/`. + +### The CSS Hierarchy + +- `src/shared/styles/main.css`: The primary entry point. It imports all other CSS components. +- `src/shared/styles/colors.css`: **DO NOT EDIT THIS MANUALLY.** This is automatically generated by Matugen. If you want to change how colors are generated, edit the template at `config/matugen/templates/modus.css`. +- `src/shared/styles/components/`: Contains isolated CSS for specific widgets (e.g. `dock.css`, `notification.css`, `panel.css`). + +> [!TIP] +> If you create a custom widget or add a new component, you can add a `.css` file inside the `components` directory and then `@import` it in `main.css`. Modus will apply it the next time it restarts! diff --git a/examples/plugins/hello.py b/examples/plugins/hello.py new file mode 100644 index 00000000..3998e8d5 --- /dev/null +++ b/examples/plugins/hello.py @@ -0,0 +1,46 @@ +"""Example: simplest possible single-file plugin. + +Usage: + cp examples/plugins/hello.py config/plugins/hello.py + + Then open spotlight and type "hi" or "hello". + Or deep-reload from terminal: + fabric-cli exec modus1 'deep_reload hello' +""" + +from typing import Any + +from window.spotlight.api import SearchResult, SpotlightPlugin + + +class HelloPlugin(SpotlightPlugin): + id = "hello" + name = "Hello" + icon = "face-smile-symbolic" + keywords = ["hi", "hello"] + searchable = True + priority = 100 + + def initialize(self) -> None: + pass + + def cleanup(self) -> None: + pass + + def search(self, query: str, token: Any) -> list[SearchResult]: + if not query.strip(): + return [] + return [ + SearchResult( + id="hello_world", + title="Hello, World!", + subtitle="A friendly greeting", + icon_name="face-smile-symbolic", + score=100.0, + render_type="default", + action=lambda: print("Hello!"), + ) + ] + + +PLUGIN = HelloPlugin diff --git a/examples/plugins/weather/__init__.py b/examples/plugins/weather/__init__.py new file mode 100644 index 00000000..ca2e72a9 --- /dev/null +++ b/examples/plugins/weather/__init__.py @@ -0,0 +1,128 @@ +"""Example: package plugin with third-party dependencies. + +Demonstrates: + - Package plugin structure + - Third-party deps via requirements.txt (uv venv auto-install) + - Keyword activation + global search + - Deep reload during development + - Cancellation checks for I/O + - External command support + +Usage: + cp -r examples/plugins/weather config/plugins/weather + + Set your API key: + env = OPENWEATHER_API_KEY, your_key_here + + Open spotlight and type "wth london" or just "london". + After editing, deep-reload: + fabric-cli exec modus1 'deep_reload weather' +""" + +import os +from typing import Any + +from window.spotlight.api import SearchResult, SpotlightPlugin + +try: + import requests +except ImportError: + requests = None # type: ignore[assignment] + + +class WeatherPlugin(SpotlightPlugin): + id = "weather" + name = "Weather" + icon = "weather-clear-symbolic" + keywords = ["wth", "weather"] + searchable = True + priority = 75 + + def __init__(self, context): + super().__init__(context) + self._cache: dict | None = None + + def initialize(self) -> None: + pass + + def cleanup(self) -> None: + self._cache = None + + def release_memory(self) -> None: + self._cache = None + + def search(self, query: str, token: Any) -> list[SearchResult]: + q = query.strip().lower() + + for prefix in ("wth ", "weather "): + if q.startswith(prefix): + q = q[len(prefix) :].strip() + break + if q in ("wth", "weather"): + q = "" + + if not q: + return [ + SearchResult( + id="weather_hint", + title="Weather", + subtitle="Type 'wth ' to get current weather", + icon_name="weather-clear-symbolic", + score=100.0, + render_type="default", + ) + ] + + data = self._fetch_weather(q, token) + if token.is_cancelled or not data: + return [] + + temp = data.get("main", {}).get("temp", "?") + desc = data.get("weather", [{}])[0].get("description", "?") + city = data.get("name", q) + + return [ + SearchResult( + id=f"weather_{q}", + title=f"{city}: {temp}°C", + subtitle=desc.capitalize(), + icon_name="weather-clear-symbolic", + score=100.0, + render_type="default", + ) + ] + + def _fetch_weather(self, city: str, token: Any) -> dict | None: + if self._cache: + return self._cache + + api_key = os.environ.get("OPENWEATHER_API_KEY") + if not api_key or requests is None: + return None + + try: + resp = requests.get( + "https://api.openweathermap.org/data/2.5/weather", + params={"q": city, "appid": api_key, "units": "metric"}, + timeout=10, + ) + if token.is_cancelled: + return None + if resp.status_code == 200: + self._cache = resp.json() + return self._cache + except Exception: + pass + return None + + def handle_external(self, command: str, args: str) -> None: + print(f"[Weather] External: {command} {args}") + + def on_activate(self, update_fn=None) -> None: + print("[Weather] Keyword mode activated") + + def on_deactivate(self) -> None: + print("[Weather] Keyword mode deactivated") + + +PLUGIN = WeatherPlugin diff --git a/examples/plugins/weather/requirements.txt b/examples/plugins/weather/requirements.txt new file mode 100644 index 00000000..b18d5134 --- /dev/null +++ b/examples/plugins/weather/requirements.txt @@ -0,0 +1 @@ +requests>=2.28 diff --git a/install.sh b/install.sh index dc66055a..5b45c556 100755 --- a/install.sh +++ b/install.sh @@ -17,43 +17,33 @@ set -e set -u set -o pipefail +AUTO_YES=false +for arg in "$@"; do + case "$arg" in + -y|--yes) AUTO_YES=true ;; + esac +done + REPO_URL="https://github.com/S4NKALP/Modus.git" INSTALL_DIR="$HOME/.config/Modus" PACKAGES=( - python-fabric-git + uv fabric-cli-git - glace-git cliphist gnome-bluetooth-3.0 - gobject-introspection slurp ffmpeg hypridle hyprsunset hyprpicker - imagemagick + hyprshot + grim libnotify matugen-bin playerctl - python-gobject - python-pillow - python-setproctitle - python-toml - python-requests - python-numpy - python-pywayland - python-pyxdg - python-ijson - python-watchdog - python-pyotp - pyzbar - python-psutil - python-pydbus - python-thefuzz - python-pam gtk-session-lock - swww + awww apple-fonts swappy wl-clipboard @@ -64,6 +54,17 @@ PACKAGES=( power-profiles-daemon uwsm cinnamon-desktop + ddcutil + at-spi2-core + gcc + make + pkgconf + appmenu-gtk-module + libdbusmenu-gtk3 + libdbusmenu-qt5 + meson + ninja + wayland-protocols ) # Colors and formatting @@ -88,7 +89,7 @@ INFO="ℹ" WARN="⚠" # Progress tracking -TOTAL_STEPS=7 +TOTAL_STEPS=12 CURRENT_STEP=0 # Function for progress indicator @@ -202,7 +203,11 @@ progress "Package information" info "Total packages to install: ${BOLD}${#PACKAGES[@]}${RESET}" echo "" -read -rp " ${YELLOW}${INFO}${RESET} View full package list? (y/N): " view_packages +if [ "$AUTO_YES" = false ]; then + read -rp " ${YELLOW}${INFO}${RESET} View full package list? (y/N): " view_packages +else + view_packages="n" +fi if [[ "$view_packages" =~ ^[Yy]$ ]]; then echo "" printf " ${DIM}• %s${RESET}\n" "${PACKAGES[@]}" @@ -210,7 +215,11 @@ if [[ "$view_packages" =~ ^[Yy]$ ]]; then fi # Confirmation -read -rp " ${BOLD}Proceed with installation? (y/N):${RESET} " confirm +if [ "$AUTO_YES" = false ]; then + read -rp " ${BOLD}Proceed with installation? (y/N):${RESET} " confirm +else + confirm="y" +fi if [[ ! "$confirm" =~ ^[Yy]$ ]]; then warn "Installation cancelled by user" exit 0 @@ -229,7 +238,7 @@ else step "Installing yay-bin..." tmpdir=$(mktemp -d) ( - git clone --quiet --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin" 2>&1 | grep -v "Cloning into" || true + git clone --quiet --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin" 2>/dev/null || true cd "$tmpdir/yay-bin" makepkg -si --noconfirm >/dev/null 2>&1 ) & @@ -248,11 +257,11 @@ progress "Setting up Modus repository" if [ -d "$INSTALL_DIR" ]; then step "Updating existing repository..." - git -C "$INSTALL_DIR" pull --quiet 2>&1 | grep -v "Already up to date" || true + git -C "$INSTALL_DIR" pull --quiet 2>/dev/null || true success "Repository updated" else step "Cloning repository..." - git clone --quiet "$REPO_URL" "$INSTALL_DIR" 2>&1 | grep -v "Cloning into" || true + git clone --quiet "$REPO_URL" "$INSTALL_DIR" 2>/dev/null || true success "Repository cloned" fi info "Location: ${INSTALL_DIR}" @@ -302,42 +311,142 @@ else success "All packages are up-to-date" fi -# Configuration -# progress "Running configuration" -# if [ -f "$INSTALL_DIR/config/config.py" ]; then -# step "Initializing Modus configuration..." -# if python "$INSTALL_DIR/config/config.py" 2>/dev/null; then -# success "Configuration completed" -# else -# warn "Configuration step failed or was skipped" -# fi -# else -# info "No configuration file found, skipping" -# fi - -# Hyprland configuration +progress "Building Global Menu shim" + +step "Compiling libmenu_button_shim.so..." +SHIM_SRC="$INSTALL_DIR/src/window/globalmenu/libmenu_button_shim.c" +SHIM_OUT="$INSTALL_DIR/src/window/globalmenu/libmenu_button_shim.so" +if [ -f "$SHIM_SRC" ]; then + if gcc -shared -fPIC -O2 -o "$SHIM_OUT" "$SHIM_SRC" $(pkg-config --cflags --libs gtk+-3.0) -ldl 2>/dev/null; then + success "libmenu_button_shim.so built successfully" + else + warn "Failed to compile libmenu_button_shim.so - global menu may not work" + fi +else + warn "libmenu_button_shim.c not found - skipping shim build" +fi + +progress "Building App Capture module" + +step "Compiling libappcapture.so..." +if [ -d "$INSTALL_DIR/src/window/switcher/app-capture" ]; then + if ( + cd "$INSTALL_DIR/src/window/switcher/app-capture" + meson setup builddir --wipe >/dev/null 2>&1 || meson setup builddir >/dev/null 2>&1 + meson compile -C builddir >/dev/null 2>&1 + ); then + success "libappcapture.so built successfully" + else + warn "Failed to compile App Capture module" + fi +else + warn "App Capture module directory not found" +fi + progress "Configuring Hyprland" -HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf" -MODUS_CONF_LINE="source= ~/.config/Modus/config/hypr/modus.conf" +HYPR_CONFIG="$HOME/.config/hypr/hyprland.lua" +MODUS_MODULE_LINE="dofile(\"$INSTALL_DIR/config/hypr/modus.lua\")" if [ -f "$HYPR_CONFIG" ]; then - step "Checking Hyprland configuration..." - if grep -qF "$MODUS_CONF_LINE" "$HYPR_CONFIG"; then - success "Modus configuration already sourced" + step "Checking Hyprland Lua configuration..." + + if grep -qF "$MODUS_MODULE_LINE" "$HYPR_CONFIG"; then + success "Modus module already loaded" else - step "Adding Modus configuration to Hyprland..." - echo "" >> "$HYPR_CONFIG" - echo "# Modus configuration" >> "$HYPR_CONFIG" - echo "$MODUS_CONF_LINE" >> "$HYPR_CONFIG" - success "Modus configuration added to Hyprland" + step "Adding Modus module to Hyprland Lua config..." + + { + echo "" + echo "-- Modus configuration" + echo "$MODUS_MODULE_LINE" + } >> "$HYPR_CONFIG" + + success "Modus configuration added" fi else - warn "Hyprland config not found at $HYPR_CONFIG" - info "You may need to manually add: $MODUS_CONF_LINE" + warn "Hyprland Lua config not found at $HYPR_CONFIG" + info "You may need to manually add: $MODUS_MODULE_LINE" +fi + +step "Symlinking hypridle.conf..." +HYPR_DIR="$HOME/.config/hypr" +HYPRIDLE_CONF="$INSTALL_DIR/config/hypr/hypridle.conf" +HYPRIDLE_TARGET="$HYPR_DIR/hypridle.conf" +mkdir -p "$HYPR_DIR" +if [ -f "$HYPRIDLE_CONF" ]; then + if [ -L "$HYPRIDLE_TARGET" ]; then + rm "$HYPRIDLE_TARGET" + fi + ln -sf "$HYPRIDLE_CONF" "$HYPRIDLE_TARGET" + success "hypridle.conf symlinked" +else + warn "hypridle.conf not found at $HYPRIDLE_CONF" +fi + +progress "Configuring global environment" + +APP_CONF_DIR="$HOME/.config/environment.d" +APP_CONF_FILE="$APP_CONF_DIR/appmenu.conf" + +step "Writing environment.d config..." +mkdir -p "$APP_CONF_DIR" +cat > "$APP_CONF_FILE" << 'EOF' +GTK_MODULES=appmenu-gtk-module +UBUNTU_MENUPROXY=1 +EOF +success "Environment config written to $APP_CONF_FILE" + +PAM_FILE="$HOME/.pam_environment" +step "Updating pam_environment..." +for entry in "GTK_MODULES DEFAULT=appmenu-gtk-module" "UBUNTU_MENUPROXY DEFAULT=1"; do + var_name="${entry%% *}" + if ! grep -q "^${var_name} " "$PAM_FILE" 2>/dev/null; then + echo "$entry" >> "$PAM_FILE" + fi +done +success ".pam_environment updated" + +progress "Configuring Matugen" +MATUGEN_CONFIG="$HOME/.config/matugen/config.toml" + +MODUS_BLOCK='[templates.modus] +input_path = "~/.config/Modus/config/matugen/templates/modus.css" +output_path = "~/.config/Modus/src/shared/styles/colors.css" +post_hook = "fabric-cli exec modus '\''app.set_css()'\'' &"' + +HYPR_BLOCK='[templates.hyprland] +input_path = "~/.config/Modus/config/matugen/templates/hyprland-colors.lua" +output_path = "~/.config/Modus/config/matugen/colors.lua"' + +if [ -f "$MATUGEN_CONFIG" ]; then + step "Checking matugen config..." + + # ---- Modus1 ---- + if grep -qF "[templates.modus]" "$MATUGEN_CONFIG"; then + success "modus1 template already exists" + else + step "Adding modus1 template..." + echo "" >> "$MATUGEN_CONFIG" + echo "$MODUS_BLOCK" >> "$MATUGEN_CONFIG" + success "modus template added" + fi + + # ---- Hyprland ---- + if grep -qF "[templates.hyprland]" "$MATUGEN_CONFIG"; then + success "hyprland template already exists" + else + step "Adding hyprland template..." + echo "" >> "$MATUGEN_CONFIG" + echo "$HYPR_BLOCK" >> "$MATUGEN_CONFIG" + success "hyprland template added" + fi + +else + warn "matugen config not found at $MATUGEN_CONFIG" + info "Create it first or install matugen" fi -# Launch Modus progress "Launching Modus" step "Stopping existing instances..." @@ -349,10 +458,10 @@ else fi step "Starting Modus..." -if uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & then +if uwsm app -- uv run --project "$INSTALL_DIR" start >/dev/null 2>&1 & then disown - sleep 1 - if pgrep -f "python.*main.py" >/dev/null; then + sleep 2 + if pgrep -x "modus" >/dev/null; then success "Modus is now running" else warn "Modus may not have started correctly" @@ -366,7 +475,7 @@ fi echo "" echo -e "${GREEN}${BOLD}╔════════════════════════════════════════╗${RESET}" echo -e "${GREEN}${BOLD}║ ║${RESET}" -echo -e "${GREEN}${BOLD}║ Installation completed! 🎉 ║${RESET}" +echo -e "${GREEN}${BOLD}║ Installation completed! ║${RESET}" echo -e "${GREEN}${BOLD}║ ║${RESET}" echo -e "${GREEN}${BOLD}╚════════════════════════════════════════╝${RESET}" echo "" diff --git a/main.css b/main.css deleted file mode 100644 index 738870f4..00000000 --- a/main.css +++ /dev/null @@ -1,34 +0,0 @@ -@import url("./styles/colors.css"); -@import url("./styles/panel.css"); -@import url("./styles/dock.css"); -@import url("./styles/switcher.css"); -@import url("./styles/launcher.css"); -@import url("./styles/osd.css"); -@import url("./styles/controlcenter.css"); -@import url("./styles/notification.css"); -@import url("./styles/dropdown.css"); -@import url("./styles/about.css"); -@import url("./styles/notification-center.css"); -@import url("./styles/player.css"); -@import url("./styles/widgets.css"); -@import url("./styles/tray.css"); -@import url("./styles/battery-widget.css"); -@import url("./styles/lock.css"); -@import url("./styles/todo.css"); - -* { - all: unset; - color: var(--foreground); - font-size: unset; - font-family: "SF Pro Rounded"; -} - -#corner { - background-color: var(--shadow); - border-radius: 0; -} - -#corner-container { - min-width: 20px; - min-height: 20px; -} diff --git a/main.py b/main.py deleted file mode 100644 index f0db31a9..00000000 --- a/main.py +++ /dev/null @@ -1,67 +0,0 @@ -import setproctitle -from fabric import Application -from fabric.utils import get_relative_path, monitor_file -from loguru import logger - -from config.data import APP_NAME -from modules.dock import Dock -from modules.launcher.main import Launcher -from modules.notification.notification import ModusNoti -from modules.osd import OSD -from modules.panel.main import Panel -from modules.switcher import ApplicationSwitcher -from modules.widget import Deskwidgets - -# from modules.corners import Corners - -for log in [ - "fabric", - "services", - "utils", - # "modules", -]: - logger.disable(log) - - -if __name__ == "__main__": - setproctitle.setproctitle(APP_NAME) - - # Load configuration - from config.data import load_config - - # About().toggle(None) - config = load_config() - - panel = Panel() - # corners = Corners() - dock = Dock() - modusnoti = ModusNoti() - switcher = ApplicationSwitcher() - launcher = Launcher() - panel.launcher = launcher - osd = OSD() - - widgets = Deskwidgets() - # Set corners visibility based on config - # corners_visible = config.get("corners_visible", True) - # corners.set_visible(corners_visible) - - # Monitor CSS files for changes - css_file = monitor_file(get_relative_path("styles")) - _ = css_file.connect("changed", lambda *_: set_css()) - - # Make sure corners is added to the app - app = Application( - f"{APP_NAME}", panel, dock, switcher, launcher, modusnoti, osd, widgets - ) - - def set_css(): - app.set_stylesheet_from_file( - get_relative_path("main.css"), - ) - - app.set_css = set_css - - app.set_css() - - app.run() diff --git a/modules/controlcenter/bluetooth.py b/modules/controlcenter/bluetooth.py deleted file mode 100644 index 08346aa6..00000000 --- a/modules/controlcenter/bluetooth.py +++ /dev/null @@ -1,613 +0,0 @@ -import subprocess - -import gi -from fabric.bluetooth import BluetoothClient, BluetoothDevice -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.revealer import Revealer -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib, Gtk -from loguru import logger - -from services.battery import Battery - -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") - - -def set_bluetooth_enabled_with_fallback(client, enabled: bool): - try: - # Try fabric bluetooth first - client.set_enabled(enabled) - except Exception as e: - logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") - # Fallback to rfkill to unblock/block bluetooth - if enabled: - command = ["rfkill", "unblock", "bluetooth"] - else: - command = ["rfkill", "block", "bluetooth"] - - try: - # Execute the rfkill command - subprocess.run( - command, capture_output=True, text=True, timeout=10, check=True - ) - except Exception as subprocess_error: - logger.error(f"rfkill fallback failed with exception: {subprocess_error}") - - -class BluetoothDeviceSlot(CenterBox): - def __init__(self, device: BluetoothDevice, **kwargs): - super().__init__(h_expand=True, name="device-button", **kwargs) - self.device = device - self.device.connect("changed", self.on_changed) - self.device.connect( - "notify::closed", lambda *_: self.device.closed and self.destroy() - ) - - self.styles = [ - "connected" if self.device.connected else "", - "paired" if self.device.paired else "", - ] - - self.dimage = Image( - icon_name=device.icon_name + "-symbolic", # type: ignore - size=5, - name="device-icon", - style_classes=" ".join(self.styles), - ) - - self.device_button = Button( - on_clicked=lambda *_: self.toggle_connecting(), - child=Box( - orientation="h", - h_expand=True, - children=[self.dimage, Label(label=device.name)], - ), # type: ignore - ) - self.start_children = [self.device_button] - - # Add battery info if available - if hasattr(device, "battery_percentage") and device.battery_percentage > 0: - battery_box = Box(orientation="h", spacing=4) - - # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", - ) - - # Create battery percentage label - battery_label = Label( - label=f"{device.battery_percentage:.0f}%", name="battery-label" - ) - - battery_box.children = [battery_icon, battery_label] - self.end_children = [battery_box] - - self.device_button.connect("enter-notify-event", self.on_button_enter) - self.device_button.connect("leave-notify-event", self.on_button_leave) - self.device.emit("changed") # to update display status - - def on_button_enter(self, widget, event): - self.add_style_class("button-hovered") - - def on_button_leave(self, widget, event): - self.remove_style_class("button-hovered") - - def toggle_connecting(self): - self.device.emit("changed") - self.device.set_connecting(not self.device.connected) - - def on_changed(self, *_): - try: - # Update connection and pairing status - new_styles = [ - "connected" if self.device.connected else "", - "paired" if self.device.paired else "", - ] - - self.styles = new_styles - self.dimage.set_property("style-classes", " ".join(self.styles)) - except Exception: - return - - # Update battery info if available - if ( - hasattr(self.device, "battery_percentage") - and self.device.battery_percentage > 0 - ): - if not self.end_children: # Add battery display if not already present - battery_box = Box(orientation="h", spacing=4) - - # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", - ) - - # Create battery percentage label - battery_label = Label( - label=f"{self.device.battery_percentage:.0f}%", name="battery-label" - ) - - battery_box.children = [battery_icon, battery_label] - self.end_children = [battery_box] - else: # Update existing battery display - battery_box = self.end_children[0] - if hasattr(battery_box, "children") and len(battery_box.children) >= 2: - battery_icon = battery_box.children[0] - battery_label = battery_box.children[1] - - # Update battery icon - battery_icon.set_from_file( - get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ) - ) - - # Update battery percentage - battery_label.set_label(f"{self.device.battery_percentage:.0f}%") - elif self.end_children: # Remove battery display if no longer available - self.end_children = [] - - return - - -class BluetoothConnections(Box): - def __init__( - self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs - ): - super().__init__( - spacing=8, - orientation="vertical", - name="bluetooth-connections", - **kwargs, - ) - - self.parent = parent - self.show_hidden_devices = show_hidden_devices - self.is_scanning = False # Track scanning state - self.refresh_timer = None # Timer for periodic device refresh - self._update_in_progress = False # Prevent concurrent updates - self._destroyed = False # Track if widget is destroyed - - self.client = BluetoothClient(on_device_added=self.on_device_added) - - # Create pull-to-refresh indicator - self.refresh_indicator = Label( - name="bluetooth-refresh-indicator", - label="↓ Pull to scan for devices", - h_align="center", - visible=False, - style="color: #fff; font-size: 12px; padding: 5px;", - ) - - # Create title with optional back button - title_children = [] - if show_back_button: - title_children.append( - Button( - image=Image(icon_name="back", size=10), - on_clicked=lambda *_: self.parent.close_bluetooth(), - ) - ) - title_children.append(Label("Bluetooth", name="bluetooth-title")) - - self.title = Box( - orientation="h", - children=title_children, - ) - - self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") - - # Safely set initial state - self.toggle_button.set_active(self.client.enabled) - self.toggle_button.connect( - "notify::active", - lambda *_: set_bluetooth_enabled_with_fallback( - self.client, self.toggle_button.get_active() - ), - ) - - # Connect client signals - self.client.connect( - "notify::enabled", - lambda *_: self.toggle_button.set_active(self.client.enabled), - ) - self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) - - # Connect to device changes - self.client.connect("device-added", self.update_devices) - self.client.connect("device-removed", self.update_devices) - - # Connect to additional signals for better real-time monitoring - self.client.connect("changed", self.on_client_changed) - - # Create Devices section - self.paired_devices_label = Label( - label="Devices", h_align="start", name="networks-title" - ) - self.paired_devices = Box( - spacing=4, orientation="vertical", name="known-networks" - ) - - # Create "No devices available" message - self.no_devices_label = Label( - label="No devices available", - h_align="center", - name="no-networks-label", - visible=False, - ) - - # Create Other Devices section with clickable title - self.other_devices_button = Button( - child=Label("Other Devices", h_align="start"), - name="wifi-other-button", - on_clicked=self.toggle_other_devices, - ) - self.other_devices = Box(spacing=4, orientation="vertical") - - # Create scrolled window for other devices - self.other_devices_scrolled = ScrolledWindow( - min_content_size=(303, 150), - child=self.other_devices, - overlay_scroll=True, - ) - - # Add pull-to-refresh functionality to scrolled window - self.setup_pull_to_refresh() - - # Create revealer for Other Devices section - self.other_devices_revealer = Revealer( - child=self.other_devices_scrolled, - transition_type="slide-down", - transition_duration=100, - child_revealed=False, - ) - - # Create More Settings button (same style as Other Devices button) - self.more_settings_button = Button( - child=Label("More Settings", h_align="start"), - name="wifi-other-button", - on_clicked=self.open_bluetooth_settings, - ) - - self.children = [ - CenterBox( - start_children=self.title, - end_children=self.toggle_button, - name="bluetooth-widget-top", - ), - self.refresh_indicator, - Separator(orientation="h", name="separator"), - self.paired_devices_label, - self.paired_devices, - self.no_devices_label, - Separator(orientation="h", name="separator"), - self.other_devices_button, - self.other_devices_revealer, - Separator(orientation="h", name="separator"), - self.more_settings_button, - ] - - # Connect cleanup on destroy - self.connect("destroy", self.on_destroy) - - self.client.notify("scanning") - self.client.notify("enabled") - - # Initial device update - self.update_devices() - - # Start periodic device monitoring for real-time updates - self.start_device_monitoring() - - def toggle_other_devices(self, *_): - """Toggle the visibility of other devices section""" - current_state = self.other_devices_revealer.child_revealed - self.other_devices_revealer.child_revealed = not current_state - - # Handle scanning based on section visibility - if self.client: - if self.other_devices_revealer.child_revealed: - # Start scanning when revealing other devices section - if not self.client.scanning: - self.client.toggle_scan() - # Also force an immediate device refresh to catch any missed connections - self.force_device_refresh() - else: - # Stop scanning when hiding other devices section - if self.client.scanning: - self.client.toggle_scan() - - def open_bluetooth_settings(self, *_): - """Open Blueman bluetooth manager""" - try: - subprocess.Popen(["blueman-manager"], start_new_session=True) - if self.parent and hasattr(self.parent, "hide_controlcenter"): - self.parent.hide_controlcenter() - except FileNotFoundError: - pass - except Exception: - pass - - def update_scan_label(self): - """Update scanning state appearance""" - if self.client.scanning: - # Show scanning feedback in refresh indicator - self.refresh_indicator.set_label("Scanning for devices...") - self.refresh_indicator.set_visible(True) - self.refresh_indicator.add_style_class("scanning") - else: - # Hide scanning feedback - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("scanning") - - def update_devices(self, *_): - """Update the list of available devices""" - # Prevent concurrent updates and check if destroyed - if self._update_in_progress or self._destroyed or not self.client: - return - - self._update_in_progress = True - - try: - # Store current device addresses to detect changes - current_paired_addresses = { - child.device.address - for child in self.paired_devices.get_children() - if hasattr(child, "device") - } - current_other_addresses = { - child.device.address - for child in self.other_devices.get_children() - if hasattr(child, "device") - } - - # Get current devices safely - devices = self.client.devices - paired_devices = [] - other_devices = [] - new_paired_addresses = set() - new_other_addresses = set() - - for device in devices: - try: - if device.name and device.name != "Unknown": - # Categorize devices: paired devices go to "Devices (Paired)" - # All others go to "Other Devices" - if device.paired: - paired_devices.append(device) - new_paired_addresses.add(device.address) - else: - other_devices.append(device) - new_other_addresses.add(device.address) - except Exception: - continue - - # Check if we need to update (devices added/removed) - paired_changed = current_paired_addresses != new_paired_addresses - other_changed = current_other_addresses != new_other_addresses - - # Only rebuild if something actually changed - if paired_changed or other_changed: - # Clear existing devices safely - for child in list(self.paired_devices.get_children()): - if not self._destroyed: - child.destroy() - for child in list(self.other_devices.get_children()): - if not self._destroyed: - child.destroy() - - # Add paired devices - for device in paired_devices: - if not self._destroyed: - device_slot = BluetoothDeviceSlot(device) - self.paired_devices.add(device_slot) - - # Add other devices - for device in other_devices: - if not self._destroyed: - device_slot = BluetoothDeviceSlot(device) - self.other_devices.add(device_slot) - - # Show/hide sections based on available devices - if not self._destroyed: - has_paired_devices = len(paired_devices) > 0 - has_other_devices = len(other_devices) > 0 - has_any_devices = has_paired_devices or has_other_devices - - # Show paired devices section only if there are paired devices - self.paired_devices_label.set_visible(has_paired_devices) - self.paired_devices.set_visible(has_paired_devices) - - # Show "No devices available" message if no devices at all - self.no_devices_label.set_visible(not has_any_devices) - - # Always show the other devices button, regardless of available devices - self.other_devices_button.set_visible(True) # Always visible - - except Exception: - pass - finally: - self._update_in_progress = False - - def start_device_monitoring(self): - """Start periodic monitoring for device changes""" - # Monitor for device changes every 5 seconds (less aggressive) - # This helps catch devices that connect from external sources - self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) - - def stop_device_monitoring(self): - """Stop periodic monitoring""" - if self.refresh_timer: - GLib.source_remove(self.refresh_timer) - self.refresh_timer = None - - def periodic_device_refresh(self): - """Periodically refresh device list to catch external connections""" - # Skip if update in progress, destroyed, or client not available - if ( - self._update_in_progress - or self._destroyed - or not self.client - or not self.client.enabled - ): - return True # Continue monitoring - - try: - # Simple check - just trigger update_devices which has its own safety checks - # Don't force signal emissions as that can cause race conditions - self.update_devices() - - except Exception: - pass - - return True # Continue monitoring - - def force_device_refresh(self): - """Force an immediate refresh of the device list""" - if self._update_in_progress or self._destroyed: - return - - try: - # Simply trigger update_devices which has its own safety checks - # Avoid forcing signal emissions to prevent race conditions - self.update_devices() - except Exception: - pass - - def on_client_changed(self, *_): - """Handle when the bluetooth client state changes""" - # Update devices when client state changes - self.update_devices() - - def on_destroy(self, widget): - """Cleanup when widget is destroyed""" - # Mark as destroyed to prevent further updates - self._destroyed = True - # Stop monitoring - self.stop_device_monitoring() - # Make sure other devices revealer is collapsed when closing - try: - self.other_devices_revealer.child_revealed = False - except: - pass # Widget might already be destroyed - - def close_bluetooth(self): - """Called when Bluetooth panel is being closed""" - # Collapse the other devices section when closing - self.other_devices_revealer.child_revealed = False - - def setup_pull_to_refresh(self): - """Setup pull-to-refresh gesture for the scrolled window""" - # Get the scrolled window's vertical adjustment - self.vadjustment = self.other_devices_scrolled.get_vadjustment() - - # Track gesture state - self.pull_start_y = 0 - self.is_pulling = False - self.pull_threshold = 50 # pixels to trigger refresh - - # Connect to scroll events - self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) - self.other_devices_scrolled.connect("button-press-event", self.on_button_press) - self.other_devices_scrolled.connect( - "button-release-event", self.on_button_release - ) - self.other_devices_scrolled.connect( - "motion-notify-event", self.on_motion_notify - ) - - # Enable events - self.other_devices_scrolled.set_events( - Gdk.EventMask.SCROLL_MASK - | Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.POINTER_MOTION_MASK - ) - - def on_scroll_event(self, widget, event): - """Handle scroll events for pull-to-refresh""" - # Only handle pull-to-refresh when at the top - if self.vadjustment.get_value() <= 0: - if event.direction == Gdk.ScrollDirection.UP: - # Scrolling up at the top - toggle scan and force refresh - self.client.toggle_scan() - self.force_device_refresh() - return True # Consume the event - return False # Let normal scrolling continue - - def on_button_press(self, widget, event): - """Handle button press for touch/drag gestures""" - if self.vadjustment.get_value() <= 0: - self.pull_start_y = event.y - self.is_pulling = True - return False - - def on_button_release(self, widget, event): - """Handle button release for touch/drag gestures""" - if self.is_pulling: - pull_distance = event.y - self.pull_start_y - if pull_distance > self.pull_threshold: - # Toggle scan and force refresh - self.client.toggle_scan() - self.force_device_refresh() - # Hide refresh indicator - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("ready-to-refresh") - self.is_pulling = False - return False - - def on_motion_notify(self, widget, event): - """Handle motion events for visual feedback during pull""" - if self.is_pulling and self.vadjustment.get_value() <= 0: - pull_distance = event.y - self.pull_start_y - if pull_distance > 0: - # Show refresh indicator when pulling down - self.refresh_indicator.set_visible(True) - if pull_distance >= self.pull_threshold: - if self.client.scanning: - self.refresh_indicator.set_label("↑ Release to stop scanning") - else: - self.refresh_indicator.set_label("↑ Release to scan") - self.refresh_indicator.add_style_class("ready-to-refresh") - else: - if self.client.scanning: - self.refresh_indicator.set_label("↓ Pull to stop scanning") - else: - self.refresh_indicator.set_label("↓ Pull to scan for devices") - self.refresh_indicator.remove_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_visible(False) - return False - - def on_device_added(self, client: BluetoothClient, address: str): - """Handle when a new device is added""" - # Update the device list when devices are added - self.update_devices() diff --git a/modules/controlcenter/expanded_player.py b/modules/controlcenter/expanded_player.py deleted file mode 100644 index 99d0ad4c..00000000 --- a/modules/controlcenter/expanded_player.py +++ /dev/null @@ -1,1398 +0,0 @@ -# Standard library imports -import os -import re -import tempfile -import urllib.parse -import urllib.request -import threading -import weakref -from typing import List, Optional, Dict, Set -import gc - -# Fabric imports -from fabric.widgets.scale import Scale -from widgets.wayland import WaylandWindow as Window -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.utils import bulk_connect, invoke_repeater, cooldown -from fabric.utils.helpers import get_relative_path -from fabric.widgets.image import Image -from fabric.widgets.overlay import Overlay -from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from loguru import logger - -# Local imports -from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data - -CACHE_DIR = f"{data.CACHE_DIR}/media" - -# Global memory management -_shared_mpris_manager = None -_widget_cache = weakref.WeakValueDictionary() -_artwork_cache = {} -_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat - - -def get_shared_mpris_manager(): - """Get shared MPRIS manager instance to reduce memory usage.""" - global _shared_mpris_manager - if _shared_mpris_manager is None: - _shared_mpris_manager = MprisPlayerManager() - return _shared_mpris_manager - - -def cleanup_artwork_cache(): - """Clean up artwork cache to prevent memory leaks.""" - global _artwork_cache - if len(_artwork_cache) > _max_artwork_cache_size: - # Keep only the most recent items - items = list(_artwork_cache.items()) - _artwork_cache = dict(items[-_max_artwork_cache_size:]) - # Force garbage collection - gc.collect() - - -def cleanup_old_cache_files(): - """Clean up old artwork cache files aggressively to save memory.""" - try: - if not os.path.exists(CACHE_DIR): - return - - import time - - current_time = time.time() - # Clean files older than 2 hours (more aggressive) - two_hours_ago = current_time - (2 * 60 * 60) - - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mod_time = os.path.getmtime(filepath) - if file_mod_time < two_hours_ago: - os.remove(filepath) - logger.debug(f"Cleaned up old cache file: {filename}") - except Exception as e: - logger.warning(f"Failed to clean up cache file {filename}: {e}") - - except Exception as e: - logger.error(f"Error during cache cleanup: {e}") - - -def get_artwork_cached(url: str) -> Optional[str]: - """Get artwork with memory-efficient caching.""" - if not url: - return None - - # Check memory cache first - if url in _artwork_cache: - return _artwork_cache[url] - - # Clean cache if too large - cleanup_artwork_cache() - - try: - # Create cache directory if it doesn't exist - os.makedirs(CACHE_DIR, exist_ok=True) - - # Generate cache filename - safe_filename = re.sub( - r"[^\w\-_.]", "_", urllib.parse.urlparse(url).path.split("/")[-1] - ) - if not safe_filename: - safe_filename = f"artwork_{hash(url)}" - - cache_file = os.path.join(CACHE_DIR, safe_filename) - - # Check if cached file exists and is recent (within 2 hours) - if os.path.exists(cache_file): - import time - - if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours - _artwork_cache[url] = cache_file - return cache_file - - # Download and cache - urllib.request.urlretrieve(url, cache_file) - _artwork_cache[url] = cache_file - return cache_file - - except Exception as e: - logger.warning(f"Failed to cache artwork from {url}: {e}") - return None - - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < six_hours_ago: - os.unlink(filepath) - except Exception: - pass # Ignore individual file errors - except Exception: - pass # Ignore all errors in cleanup - - -class EmbeddedExpandedPlayer(Box): - """Embedded expanded player widget for use inside control center.""" - - def __init__(self, control_center, **kwargs): - super().__init__( - orientation="vertical", - h_expand=True, - name="embedded-expanded-player", - **kwargs, - ) - - self.control_center = control_center - self.mpris_manager = get_shared_mpris_manager() - - # Create back button (hidden in header) - self.back_button = Button( - name="back-button", - child=Label(label="← Back"), - on_clicked=self._on_back_clicked, - ) - - # Create expanded player content - self.player_content = PlayerBoxStack(self.mpris_manager) - - # Add escape key binding for navigation back - self._keybinding_added = False - try: - if hasattr(self.control_center, "add_keybinding"): - self.control_center.add_keybinding("Escape", self._on_back_clicked) - self._keybinding_added = True - except Exception: - pass # Ignore if keybinding fails - - self.children = [ - Box( - orientation="horizontal", - h_expand=True, - style_classes="menu", - visible=False, # Hide header to remove title and back button - children=[ - self.back_button, - Box(h_expand=True), # Spacer - Label(label="Now Playing", style_classes="title"), - Box(h_expand=True), # Spacer - ], - ), - self.player_content, - ] - - def _on_back_clicked(self, *_): - """Handle back button click""" - if self.control_center and hasattr( - self.control_center, "close_expanded_player" - ): - self.control_center.close_expanded_player() - - def refresh(self): - """Refresh the player content""" - # This will automatically update as MPRIS players change - pass - - def destroy(self): - """Clean up resources and prevent memory leaks""" - logger.debug("🗑️ EmbeddedExpandedPlayer cleanup starting") - - try: - # Destroy player content (PlayerBoxStack) - if hasattr(self, "player_content") and hasattr( - self.player_content, "destroy" - ): - self.player_content.destroy() - - # Clean up back button - if hasattr(self, "back_button") and hasattr(self.back_button, "destroy"): - self.back_button.destroy() - - # Clean up any other widgets we might have - for child in list(self.get_children()): - try: - child.destroy() - except Exception as e: - logger.warning(f"Failed to destroy child widget: {e}") - - # Clean up keybinding if it was added - if hasattr(self, "_keybinding_added") and self._keybinding_added: - try: - if hasattr(self.control_center, "remove_keybinding"): - self.control_center.remove_keybinding("Escape") - except Exception as e: - logger.warning(f"Failed to remove keybinding: {e}") - - # Aggressively clean global caches - global _widget_cache, _artwork_cache - _widget_cache.clear() - _artwork_cache.clear() - logger.debug("🗑️ Cleared global widget and artwork caches") - - # Clear references - if hasattr(self, "control_center"): - self.control_center = None - if hasattr(self, "mpris_manager"): - self.mpris_manager = None - - # Force garbage collection - gc.collect() - logger.debug("🗑️ EmbeddedExpandedPlayer cleanup completed") - - except Exception as e: - logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}") - finally: - super().destroy() - - def _periodic_cleanup(self): - """Light cleanup for EmbeddedExpandedPlayer reuse""" - try: - logger.debug("🧹 EmbeddedExpandedPlayer light cleanup starting") - - # Only clean up the player content lightly - if hasattr(self, "player_content") and hasattr( - self.player_content, "_periodic_cleanup" - ): - self.player_content._periodic_cleanup() - - # Clean global caches but don't destroy core functionality - global _artwork_cache - _artwork_cache.clear() - - # Light garbage collection - gc.collect() - - logger.debug("🧹 EmbeddedExpandedPlayer light cleanup completed") - - except Exception as e: - logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}") - - -class PlayerBoxStack(Box): - """Memory-optimized widget that displays current player information.""" - - def __init__(self, mpris_manager: MprisPlayerManager, **kwargs): - # Clean up old cache files on startup - cleanup_old_cache_files() - - # The player stack with memory-efficient settings - self.player_stack = Stack( - name="player-stack", - # Disable transitions to reduce memory usage - transition_type="none", - ) - self.current_stack_pos = 0 - - # Store player buttons - cleaned up via explicit removal - self.player_buttons: list[Button] = [] - self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary() - - # Track signal connections for cleanup using weak references - self._signal_connections = [] - - # Create a lightweight "No media playing" placeholder - self.no_media_box = self._create_no_media_box() - - super().__init__(orientation="v", name="media", children=[self.player_stack]) - - # Show the no media box initially - self.player_stack.children = [self.no_media_box] - self.set_visible(True) - - self.mpris_manager = mpris_manager - - # Track connections for cleanup - connections = bulk_connect( - self.mpris_manager, - { - "player-appeared": self.on_new_player, - "player-vanished": self.on_lost_player, - }, - ) - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) - - # Process existing players - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}" - ) - self.on_new_player(self.mpris_manager, player) - - # Schedule periodic memory cleanup and store source ID - self._cleanup_source_id = GLib.timeout_add_seconds( - 300, self._periodic_cleanup - ) # Every 5 minutes - - def _periodic_cleanup(self): - """Light cleanup for widget reuse - preserve functionality.""" - try: - logger.debug("Starting light expanded player cleanup for reuse") - - # Clean artwork cache to reduce memory - cleanup_artwork_cache() - - # Reset visual state but preserve connections and core functionality - # Only clear the player widgets that can be recreated - for widget in list(self._player_widgets.values()): - try: - if widget and hasattr(widget, "get_parent") and widget.get_parent(): - # Only remove from parent, don't destroy (let GTK handle it) - widget.get_parent().remove(widget) - except Exception: - pass - # Don't clear the _player_widgets dict - just let them be recreated - - # Reset stack to show no_media_box without destroying children - try: - if hasattr(self, "player_stack") and hasattr(self, "no_media_box"): - self.player_stack.set_visible_child(self.no_media_box) - self.current_stack_pos = 0 - except Exception: - pass - - # Light garbage collection - gc.collect() - - logger.debug("Light expanded player cleanup completed") - - except Exception as e: - logger.warning(f"Error during light cleanup: {e}") - - return True # Continue timer - - def destroy(self): - """Clean up resources when the widget is destroyed.""" - try: - # Cancel any pending cleanup timer - if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id: - try: - GLib.source_remove(self._cleanup_source_id) - except Exception: - pass # Timer may have already been removed - - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - if obj and hasattr(obj, "disconnect"): - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up player widgets - for widget in list(self._player_widgets.values()): - try: - if widget and hasattr(widget, "destroy"): - widget.destroy() - except Exception: - pass - self._player_widgets.clear() - - # Clean up player buttons explicitly - for button in list(self.player_buttons): - try: - if button and hasattr(button, "destroy"): - button.destroy() - except Exception: - pass - - # Clean up stack children - for child in self.player_stack.get_children(): - if hasattr(child, "destroy") and child != self.no_media_box: - try: - child.destroy() - except Exception: - pass - - # Force final garbage collection - gc.collect() - - except Exception as e: - logger.error(f"Error during PlayerBoxStack cleanup: {e}") - finally: - super().destroy() - - def _create_no_media_box(self): - """Create a placeholder box for when no media is playing.""" - fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - # Album cover with fallback image using Image widget - - album_cover = Box( - name="macos-album-image-no", - ) - album_cover.set_style(f"background-image:url('{fallback_cover_path}')") - - image_stack = Box(h_align="start", v_align="center", name="player-image-stack") - image_stack.children = [album_cover] - - # Track info showing "No media playing" - track_title = Label( - label="No media playing", - name="player-title-no", - justification="left", - max_chars_width=30, - ellipsization="end", - h_align="start", - ) - - track_artist = Label( - label="", - name="player-artist", - justification="left", - max_chars_width=12, - ellipsization="end", - h_align="start", - visible=False, # Hide artist and album when no media - ) - - track_album = Label( - label="", - name="player-album", - justification="left", - max_chars_width=12, - ellipsization="end", - h_align="start", - visible=False, # Hide artist and album when no media - ) - - track_info = Box( - name="track-info", - spacing=5, - orientation="v", - v_align="start", - h_align="start", - children=[track_title, track_artist, track_album], - ) - - # No control buttons for no media state - just an empty box - controls_box = Box( - name="player-controls", - visible=False, # Hide controls when no media - ) - - player_info_box = Box( - name="player-info-box", - v_align="center", - h_align="start", - orientation="v", - h_expand=True, - children=[track_info, controls_box], - ) - - inner_box = Box( - name="inner-player-box", - v_align="center", - h_align="start", - ) - - outer_box = Box( - name="outer-player-box", - h_align="start", - ) - - overlay_box = Overlay( - child=outer_box, - overlays=[ - inner_box, - player_info_box, - image_stack, - ], - ) - - no_media_box = Box( - h_align="center", - name="player-box", - h_expand=True, - children=[overlay_box], - ) - - return no_media_box - - def on_player_clicked(self, type): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - - if type == "next": - self.current_stack_pos = ( - self.current_stack_pos + 1 - if self.current_stack_pos != len(self.player_stack.get_children()) - 1 - else 0 - ) - elif type == "prev": - self.current_stack_pos = ( - self.current_stack_pos - 1 - if self.current_stack_pos != 0 - else len(self.player_stack.get_children()) - 1 - ) - - # set new active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - def on_player_clicked_by_index(self, index): - """Switch to player at given index""" - if 0 <= index < len(self.player_buttons): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - # set new position - self.current_stack_pos = index - # set new active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - # Update all player boxes with new button state - self._update_all_player_buttons() - - def on_new_player(self, mpris_manager, player): - # if player_name in self.config.get("ignore", []): - # return - - # Remove the no media box if it's the only child - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - self.player_stack.children = [] - self.current_stack_pos = 0 - - self.set_visible(True) - - new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self) - self.player_stack.children = [ - *self.player_stack.children, - new_player_box, - ] - - self.make_new_player_button(self.player_stack.get_children()[-1]) - logger.info( - f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", - ) - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - def on_lost_player(self, mpris_manager, player_name): - # the playerBox is automatically removed from mprisbox children on being removed - logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") - players: List[PlayerBox] = self.player_stack.get_children() - - # Find and properly destroy the player box - player_box_to_remove = None - for player_box in players: - if ( - hasattr(player_box, "player") - and player_box.player.player_name == player_name - ): - player_box_to_remove = player_box - break - - if player_box_to_remove: - try: - player_box_to_remove.destroy() - except Exception as e: - logger.warning(f"Failed to destroy player box: {e}") - - # Check if this was the last player - remaining_players = [ - p for p in self.player_stack.get_children() if p != player_box_to_remove - ] - if len(remaining_players) == 0: - # Show the no media box instead of hiding - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_buttons = [] # Clear player buttons - return - - # Adjust current position if needed - if self.current_stack_pos >= len(self.player_stack.get_children()): - self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) - - # Set active button if we have buttons and a valid position - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - if self.player_stack.get_children(): - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - def make_new_player_button(self, player_box): - new_button = Button(name="player-stack-button") - - def on_player_button_click(button: Button): - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - if button in self.player_buttons: - self.current_stack_pos = self.player_buttons.index(button) - button.add_style_class("active") - self.player_stack.set_visible_child(player_box) - - new_button.connect( - "clicked", - on_player_button_click, - ) - self.player_buttons.append(new_button) - - # This will automatically destroy our used button - def cleanup_button(*_): - try: - if new_button in self.player_buttons: - self.player_buttons.remove(new_button) - new_button.destroy() - except Exception as e: - logger.warning(f"Failed to cleanup button: {e}") - - player_box.connect("destroy", cleanup_button) - - def _update_all_player_buttons(self): - """Update all player boxes with the current button state""" - players: List[PlayerBox] = self.player_stack.get_children() - logger.info( - f"[PlayerBoxStack] Updating buttons for {len(players)} players, { - len(self.player_buttons) - } buttons" - ) - for player_box in players: - if hasattr(player_box, "update_buttons"): - player_box.update_buttons(self.player_buttons, len(players) > 1) - else: - logger.warning( - "[PlayerBoxStack] PlayerBox missing update_buttons method" - ) - - -class PlayerBox(Box): - """A widget that displays the current player information.""" - - def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): - super().__init__( - h_align="center", - name="player-box", - **kwargs, - h_expand=True, - ) - # Setup - self.player: MprisPlayer = player - self.player_stack = player_stack - self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - self.icon_size = 15 - - # State - self.exit = False - self.skipped = False - self._user_seeking = False # Flag to prevent choppy seeking - self._seekbar_timer_id = None # Track timer ID for cleanup - - # Memory management - self.temp_artwork_files = [] # Track temp files for cleanup - self.current_download_thread = None # Track current download thread - self._download_cancelled = False # Flag to cancel downloads - self._signal_connections = [] # Track signal connections - - # Use same CSS background approach as small player for consistency - self.album_cover = Box( - name="macos-album-image", - ) - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - self.album_cover.set_size_request(70, 70) - - self.image_stack = Box( - h_align="start", v_align="center", name="player-image-stack" - ) - self.image_stack.children = [*self.image_stack.children, self.album_cover] - - # Track Info - self.track_title = Label( - label="No Title", - name="macos-player-title", - justification="left", - max_chars_width=30, - ellipsization="end", - h_align="start", - h_expand=True, - ) - - self.track_artist = Label( - label="No Artist", - name="macos-player-artist", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - h_expand=True, - visible=True, - ) - self.track_album = Label( - label="No Album", - name="macos-player-album", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - visible=True, # Hide artist and album when no media - ) - - self.app_icon = Box( - children=Image( - icon_name=self.player.player_name, name="player-app-icon", icon_size=20 - ), - h_align="end", - v_align="end", - tooltip_text=self.player.player_name, # type: ignore - ) - self.image = Overlay( - child=self.image_stack, - overlays=[ - self.app_icon, - ], - ) - # Seek bar should not update automatically during user interaction - self._user_seeking = False - - self.seek_bar = Scale( - value=0, - min_value=0, - max_value=100, - increments=(1, 1), - name="expanded-seek-bar", - size=1, - h_expand=True, - ) - self.seek_bar.connect("value-changed", self._on_scale_value_changed) - self.seek_bar.connect("button-press-event", self._on_seek_start) - self.seek_bar.connect("button-release-event", self._on_seek_end) - self.player.bind("can-seek", "sensitive", self.seek_bar) - - # Position and length labels for seek bar - self.position_label = Label( - label="0:00", - name="macos-position-label", - justification="left", - h_align="start", - ) - - self.length_label = Label( - label="0:00", - name="macos-length-label", - justification="right", - h_align="end", - ) - - # Labels box for position and length below seek bar - self.labels_box = Box( - name="macos-labels-box", - orientation="h", - children=[ - self.position_label, - Box(h_expand=True), # Spacer to push labels to ends - self.length_label, - ], - ) - - # Seek bar with position labels below - self.seek_box = Box( - name="macos-seek-box", - orientation="v", - spacing=2, - children=[ - self.seek_bar, - self.labels_box, - ], - ) - - # Define buttons first - self.button_box = Box( - name="macos-button-box", - h_align="center", - h_expand=True, - spacing=10, # Reduced spacing for macOS look - ) - - # Define controls_box BEFORE using it in track_info - self.controls_box = Box( - name="macos-player-controls", - orientation="v", - h_expand=True, - spacing=6, - h_align="center", - children=[self.button_box], - ) - - # Track info with inline controls - expands to fill available space - self.track_info = Box( - name="macos-track-info", - spacing=4, # Reduced spacing - orientation="v", - v_align="start", - h_align="fill", # Fill all available horizontal space - h_expand=True, # Expand horizontally to take maximum space - v_expand=True, # Also expand vertically - children=[ - self.track_title, - self.track_artist, - self.track_album, - ], - ) - - # Bind player properties - self.player.bind_property( - "title", - self.track_title, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" - ), # type: ignore - ) - self.player.bind_property( - "artist", - self.track_artist, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore - ) - self.player.bind_property( - "album", - self.track_album, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album" - ), # type: ignore - ) - - # Player switcher buttons box (compact, minimal space) - self.stack_buttons_box = Box( - h_expand=False, # Fixed width, don't expand - v_expand=True, - name="macos-stack-buttons-box", - spacing=4, # Reduced spacing - orientation="h", # Vertical layout for compactness - h_align="center", - v_align="end", - ) - self.stack_buttons_box.hide() # Initially hidden - - # Create SVG icons from player directory - self.skip_next_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.skip_prev_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"), - ) - self.play_pause_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) - - self.play_pause_button = Button( - style_classes=["control-buttons"], - name="macos-play-button", - child=self.play_pause_icon, - on_clicked=self.player.play_pause, - ) - - self.player.bind_property("can_pause", self.play_pause_button, "sensitive") - - self.next_button = Button( - style_classes=["control-buttons"], - name="macos-control-button", - child=self.skip_next_icon, - on_clicked=self._on_player_next, - ) - self.player.bind_property("can_go_next", self.next_button, "sensitive") - - self.prev_button = Button( - name="macos-control-button", - child=self.skip_prev_icon, - style_classes=["control-buttons"], - on_clicked=self._on_player_prev, - ) - self.button_box.children = ( - self.prev_button, - self.play_pause_button, - self.next_button, - ) - - self.box = Box( - orientation="horizontal", - children=[ - self.image, # Album art on left (fixed width) - # Contains title, artist, seek bar AND controls (expands) - self.track_info, - ], - ) - self.inner_box = Box( - orientation="v", - h_expand=True, - h_align="fill", # Fill available space - children=[ - self.box, # Track info and album art - self.seek_box, # Seek bar with position labels - self.controls_box, # Controls now inline with track info - ], - ) - # Compact macOS layout: album art on left, expanded track info+controls, minimal switcher - self.outer_box = Box( - name="macos-outer-player-box", - orientation="v", - spacing=10, # Reduced spacing between elements - h_expand=True, - v_expand=True, - v_align="center", - h_align="fill", # Fill available space - children=[ - self.inner_box, # Track info and controls - # Compact switcher in corner (fixed width) - self.stack_buttons_box, - ], - ) - - self.children = [*self.children, self.outer_box] - - # Track signal connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.player, - { - "exit": self._on_player_exit, - "notify::playback-status": self._on_playback_change, - "notify::metadata": self._on_metadata, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.player, handler_id)) - - def destroy(self): - """Clean up all resources when the widget is destroyed.""" - # Set exit flag FIRST to stop any running timers - self.exit = True - - # Cancel any ongoing downloads immediately - self._download_cancelled = True - - # Cancel seek bar timer - if self._seekbar_timer_id: - try: - from gi.repository import GLib - - GLib.source_remove(self._seekbar_timer_id) - except: - pass - self._seekbar_timer_id = None - - # Wait for download thread to finish (with timeout) - if self.current_download_thread and self.current_download_thread.is_alive(): - try: - self.current_download_thread.join(timeout=1.0) # 1 second timeout - except Exception: - pass - - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up temp files aggressively - self._cleanup_temp_files() - - # Clear image references - if hasattr(self, "album_cover_image"): - try: - self.album_cover_image.set_from_pixbuf(None) - except Exception: - pass - - super().destroy() - - def __del__(self): - """Ensure cleanup happens even if player exits unexpectedly.""" - try: - self._cleanup_temp_files() - except Exception: - pass # Ignore errors during cleanup in destructor - - def update_buttons(self, player_buttons, show_buttons): - """Update the stack switcher buttons in this player box""" - logger.info( - f"[PlayerBox] update_buttons called: show_buttons={ - show_buttons - }, num_buttons={len(player_buttons)}" - ) - - # Clear existing buttons - for child in self.stack_buttons_box.get_children(): - try: - child.destroy() - except Exception: - pass - - if show_buttons and len(player_buttons) > 1: - logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons") - # Create macOS-style dot indicators for each player - for i, button in enumerate(player_buttons): - # Create a macOS-style dot button - dot_button = Button( - name="macos-player-switcher-dot", - style_classes=["macos-switcher-dot"], - ) - - # Set active state based on original button - if button.get_style_context().has_class("active"): - dot_button.add_style_class("active") - - # Connect click handler to switch to this player - def make_click_handler(index): - return lambda *_: self.player_stack.on_player_clicked_by_index( - index - ) - - dot_button.connect("clicked", make_click_handler(i)) - self.stack_buttons_box.children = [ - *self.stack_buttons_box.children, - dot_button, - ] - logger.info(f"[PlayerBox] Added dot button {i}") - - self.stack_buttons_box.show_all() - logger.info("[PlayerBox] Stack buttons box shown") - else: - self.stack_buttons_box.hide() - logger.info("[PlayerBox] Stack buttons box hidden") - - def length_str(self, length): - """Convert length in microseconds to MM:SS or H:MM:SS format like real media players.""" - if length is None or length <= 0: - return "0:00" - - # Convert microseconds to seconds - length_seconds = length / 1000000 - - hours = int(length_seconds // 3600) - minutes = int((length_seconds % 3600) // 60) - seconds = int(length_seconds % 60) - - if hours > 0: - return f"{hours}:{minutes:02d}:{seconds:02d}" - else: - return f"{minutes}:{seconds:02d}" - - def _on_metadata(self, *_): - self._set_image() - duration = self.player.length - - if duration: - self.length_label.set_label(self.length_str(duration)) - # Clamp duration to avoid 32-bit integer overflow in the scale widget - max_int32 = 2147483647 # 2^31 - 1 - safe_duration = min(max_int32, duration) - self.seek_bar.set_range(0, safe_duration) - - # Cancel existing timer before starting a new one - if self._seekbar_timer_id: - try: - from gi.repository import GLib - - GLib.source_remove(self._seekbar_timer_id) - except: - pass - self._seekbar_timer_id = None - - # Start new timer and store its ID - self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) - - def _cleanup_temp_files(self): - """Clean up temporary artwork files.""" - for temp_file in self.temp_artwork_files: - try: - if os.path.exists(temp_file): - os.unlink(temp_file) - except Exception as e: - logger.warning(f"Failed to cleanup temp file {temp_file}: {e}") - self.temp_artwork_files.clear() - - def _on_player_exit(self, _, value): - self.exit = value - self._cleanup_temp_files() # Clean up temp files before destroying - self.destroy() - - def _on_player_next(self, *_): - self.player.next() - - def _on_player_prev(self, *_): - self.player.previous() - - def _on_playback_change(self, player, status): - status = player.get_property("playback-status") - - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) - - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) - - def _update_image(self, image_path): - if image_path and os.path.isfile(image_path): - self.album_cover.set_style(f"background-image:url('{image_path}')") - else: - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - def _set_image(self, *_): - art_url = self.player.arturl - - # If no art URL or empty/None, use fallback - if not art_url: - self._update_image(None) - return - - parsed = urllib.parse.urlparse(art_url) - if parsed.scheme == "file": - local_arturl = urllib.parse.unquote(parsed.path) - self._update_image(local_arturl) - elif parsed.scheme in ("http", "https"): - # Cancel any existing download to prevent memory buildup - self._download_cancelled = True - - # Use threading.Thread instead of GLib.Thread for better control - if self.current_download_thread and self.current_download_thread.is_alive(): - # Thread will check _download_cancelled flag and exit early - pass - - self._download_cancelled = False - self.current_download_thread = threading.Thread( - target=self._download_and_set_artwork, - args=(art_url,), - daemon=True, # Dies with main thread - ) - self.current_download_thread.start() - else: - print(art_url) - self._update_image(art_url) - - def _download_and_set_artwork(self, arturl): - """ - Download the artwork from the given URL asynchronously and update the cover - using GLib.idle_add to ensure UI updates occur on the main thread. - """ - local_arturl = self.fallback_cover_path - temp_file_path = None - - try: - # Check if download was cancelled - if self._download_cancelled: - return - - # Clean up old temp files first (keep only last 1 to reduce memory) - if len(self.temp_artwork_files) > 1: - old_files = self.temp_artwork_files[:-1] - for old_file in old_files: - try: - if os.path.exists(old_file): - os.unlink(old_file) - except Exception: - pass - self.temp_artwork_files = self.temp_artwork_files[-1:] - - # Check again if cancelled - if self._download_cancelled: - return - - # Download artwork - parsed = urllib.parse.urlparse(arturl) - suffix = os.path.splitext(parsed.path)[1] or ".png" - - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() - - # Check one more time if cancelled - if self._download_cancelled: - return - - # Create temp file in cache directory instead of system temp - os.makedirs(CACHE_DIR, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, dir=CACHE_DIR - ) as temp_file: - temp_file.write(data) - temp_file_path = temp_file.name - local_arturl = temp_file_path - - # Track temp file for cleanup - if temp_file_path and not self._download_cancelled: - self.temp_artwork_files.append(temp_file_path) - - except Exception as e: - if not self._download_cancelled: - logger.warning(f"Failed to download artwork from {arturl}: {e}") - # Clean up failed temp file - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - except Exception: - pass - return - - # Only update UI if not cancelled - if not self._download_cancelled: - GLib.idle_add(self._update_image, local_arturl) - return None - - def _move_seekbar(self, *_): - if self.player is None or self.exit or self._user_seeking: - return True # Continue the timer but don't update while user is seeking - - # Additional safety checks to prevent GTK errors - if not hasattr(self, "seek_bar") or self.seek_bar is None: - return False # Stop the timer - - try: - position = self.player.position - self.position_label.set_label(self.length_str(position)) - - # Only update seek bar if user is not currently seeking - if not self._user_seeking: - # Clamp position to avoid 32-bit integer overflow - max_int32 = 2147483647 # 2^31 - 1 - safe_position = min(max_int32, position) if position else 0 - self.seek_bar.set_value(safe_position) - - except Exception as e: - # If any error occurs (widget destroyed, etc), stop the timer - logger.warning(f"Seek bar update failed, stopping timer: {e}") - return False - - return True - - def _on_seek_start(self, widget, event): - """User started seeking - disable automatic updates""" - self._user_seeking = True - return False - - def _on_seek_end(self, widget, event): - """User finished seeking - re-enable automatic updates""" - self._user_seeking = False - return False - - def _on_scale_value_changed(self, scale: Scale): - """Handle seek bar value changes - only when user is seeking""" - if self.player and not self.exit and self._user_seeking: - try: - new_position = int(scale.get_value()) - # Clamp to 32-bit signed integer range to avoid overflow - max_int32 = 2147483647 # 2^31 - 1 - min_int32 = -2147483648 # -2^31 - new_position = max(min_int32, min(max_int32, new_position)) - self.player.position = new_position - self.position_label.set_label(self.length_str(new_position)) - except Exception as e: - # If setting position fails, just update the label - try: - self.position_label.set_label(self.length_str(new_position)) - except Exception: - logger.warning(f"Failed to update position label: {e}") - - @cooldown(0.1) - def _on_scale_move(self, scale: Scale, event, pos: int): - try: - if not self.exit and self.player: - self.player.position = pos - self.position_label.set_label(self.length_str(pos)) - self.seek_bar.set_value(pos) - except Exception as e: - logger.warning(f"Failed to update seek position: {e}") - - -class Thing(Box): - def __init__(self, **kwargs): - super().__init__( - name="thing", - size=(480, 160), - orientation="vertical", - spacing=0, - children=[ - Label( - name="thing-label", - label="This is a thing", - style="font-size: 16px; padding: 10px;", - ), - ], - **kwargs, - ) - - -class ExpandedPlayer(Window): - def __init__(self, **kwargs): - super().__init__( - name="expanded-player", - title="modus", - anchor="top right", - layer="top", - exclusivity="auto", - child=PlayerBoxStack(get_shared_mpris_manager()), - visible=False, - ) - self.add_keybinding("Escape", self.set_child_visible(False)) - - def destroy(self): - """Clean up resources when the window is destroyed.""" - # Clean up the child PlayerBoxStack - if hasattr(self, "child") and hasattr(self.child, "destroy"): - try: - self.child.destroy() - except Exception as e: - logger.warning(f"Failed to destroy child PlayerBoxStack: {e}") - - super().destroy() - - def _init_mousecapture(self, mousecapture): - self._mousecapture_parent = mousecapture - - def hide_controlcenter(self, *_): - # self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) diff --git a/modules/controlcenter/nightlight.py b/modules/controlcenter/nightlight.py deleted file mode 100644 index c5414c2e..00000000 --- a/modules/controlcenter/nightlight.py +++ /dev/null @@ -1,146 +0,0 @@ -# Standard library imports -import subprocess - -# Fabric imports -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.svg import Svg - -# Local imports -from loguru import logger - - -class NightLightControl: - """Control night light using hyprsunset""" - - def __init__(self): - self.is_active = False - self._check_initial_state() - - def _check_initial_state(self): - """Check if hyprsunset is currently running""" - try: - result = subprocess.run( - ["pgrep", "-f", "hyprsunset"], capture_output=True, text=True - ) - self.is_active = bool(result.stdout.strip()) - except Exception as e: - logger.warning(f"Failed to check hyprsunset status: {e}") - self.is_active = False - - def toggle(self): - """Toggle night light on/off""" - try: - if self.is_active: - # Turn off night light by killing hyprsunset - subprocess.run(["pkill", "hyprsunset"], check=False) - self.is_active = False - logger.debug("Night light turned off") - else: - # Turn on night light with default temperature (3000K) - subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True) - self.is_active = True - logger.debug("Night light turned on") - return True - except Exception as e: - logger.warning(f"Failed to toggle night light: {e}") - return False - - def set_temperature(self, temperature: int): - """Set night light temperature (1000-6500K)""" - try: - # Kill existing process - subprocess.run(["pkill", "hyprsunset"], check=False) - - # Start with new temperature - subprocess.Popen( - ["hyprsunset", "-t", str(temperature)], start_new_session=True - ) - self.is_active = True - logger.debug(f"Night light temperature set to {temperature}K") - return True - except Exception as e: - logger.warning(f"Failed to set night light temperature: {e}") - return False - - -def create_night_light_widget(control_center): - """Create night light widget for control center""" - - # Initialize night light control - night_light = NightLightControl() - - # Create icon - night_light_icon = Svg( - name="nightlight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" - if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" - ), - size=42, - ) - - # Create status label - night_light_status_label = Label( - label="On" if night_light.is_active else "Off", - name="nightlight-widget-label", - style_classes="status-label", - max_chars_width=15, - ellipsization="end", - h_align="start", - ) - - def toggle_night_light(*_): - """Toggle night light and update UI""" - if night_light.toggle(): - # Update icon - night_light_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" - if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" - ) - ) - # Update status label - night_light_status_label.set_label("On" if night_light.is_active else "Off") - - # Create widget box (similar to bluetooth_widget structure) - night_light_widget = Box( - name="nightlight-widget", - orientation="h", - h_expand=True, - children=[ - Button( - name="nightlight-icon-button", - child=night_light_icon, - on_clicked=toggle_night_light, - ), - Button( - name="nightlight-info-button", - child=Box( - name="nightlight-widget-info", - h_expand=True, - v_expand=True, - v_align="center", - h_align="start", - orientation="vertical", - children=[ - Label( - name="nightlight-widget-name", - label="Night Light", - style_classes="ct", - h_align="start", - ), - night_light_status_label, - ], - ), - on_clicked=toggle_night_light, - ), - ], - ) - - return night_light_widget - diff --git a/modules/controlcenter/player.py b/modules/controlcenter/player.py deleted file mode 100644 index d5a6fd4c..00000000 --- a/modules/controlcenter/player.py +++ /dev/null @@ -1,955 +0,0 @@ -import os -import re -import tempfile -import urllib.parse -import urllib.request -from typing import List -import threading - -from fabric.utils import ( - bulk_connect, -) -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from fabric.widgets.centerbox import CenterBox -from loguru import logger - -from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data - -CACHE_DIR = f"{data.CACHE_DIR}/media" - - -def cleanup_old_cache_files(): - """Clean up old artwork cache files (older than 1 day) and limit total cache size.""" - try: - if not os.path.exists(CACHE_DIR): - return - - import time - - current_time = time.time() - one_day_ago = current_time - (24 * 60 * 60) # 24 hours - cache_files = [] - - # Collect all cache files with their modification times - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - file_size = os.path.getsize(filepath) - cache_files.append((filepath, file_mtime, file_size)) - except Exception: - pass # Ignore individual file errors - - # Remove files older than 1 day - total_size = 0 - recent_files = [] - for filepath, file_mtime, file_size in cache_files: - if file_mtime < one_day_ago: - try: - os.unlink(filepath) - except Exception: - pass - else: - recent_files.append((filepath, file_mtime, file_size)) - total_size += file_size - - # If cache is still too large (>50MB), remove oldest files - MAX_CACHE_SIZE = 50 * 1024 * 1024 # 50MB - if total_size > MAX_CACHE_SIZE: - # Sort by modification time (oldest first) - recent_files.sort(key=lambda x: x[1]) - for filepath, _, file_size in recent_files: - if total_size <= MAX_CACHE_SIZE: - break - try: - os.unlink(filepath) - total_size -= file_size - except Exception: - pass - - except Exception: - pass # Ignore all errors in cleanup - - -class PlayerBoxStack(Box): - """A widget that displays the current player information.""" - - def __init__( - self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs - ): - # Clean up old cache files on startup - cleanup_old_cache_files() - - # The player stack - self.player_stack = Stack( - # transition_type="slide-left-right", - # transition_duration=500, - name="player-stack", - ) - self.current_stack_pos = 0 - self.control_center = control_center - - # List to store player buttons - self.player_buttons: list[Button] = [] - - # Track signal connections for cleanup - self._signal_connections = [] - - # Create a "No media playing" placeholder - self.no_media_box = self._create_no_media_box() - - super().__init__(orientation="v", name="media", children=[self.player_stack]) - - # Show the no media box initially - self.player_stack.children = [self.no_media_box] - self.set_visible(True) - - self.mpris_manager = mpris_manager - - # Track connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.mpris_manager, - { - "player-appeared": self.on_new_player, - "player-vanished": self.on_lost_player, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) - - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}", - ) - self.on_new_player(self.mpris_manager, player) - - def destroy(self): - """Clean up resources when the widget is destroyed.""" - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up player buttons - for button in self.player_buttons: - try: - button.destroy() - except Exception: - pass - self.player_buttons.clear() - - # Clean up player boxes - for child in self.player_stack.get_children(): - if hasattr(child, "destroy") and child != self.no_media_box: - try: - child.destroy() - except Exception: - pass - - super().destroy() - - def _periodic_cleanup(self): - """Enhanced cleanup for reuse - clean internal state and free memory""" - try: - # Destroy all player boxes properly to free their resources - current_children = list(self.player_stack.get_children()) - for child in current_children: - if hasattr(child, "destroy") and child != self.no_media_box: - try: - child.destroy() - except Exception as e: - logger.warning(f"Failed to destroy player child: {e}") - - # Reset to no media state - self.player_stack.children = [self.no_media_box] - - # Clear player buttons - for button in self.player_buttons: - try: - button.destroy() - except Exception: - pass - self.player_buttons.clear() - - # Reset stack position - self.current_stack_pos = 0 - - # Clean up old cache files more aggressively - cleanup_old_cache_files() - - # Force garbage collection - import gc - - gc.collect() - - logger.debug("PlayerBoxStack enhanced cleanup completed") - except Exception as e: - logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}") - - def _create_no_media_box(self): - """Create a placeholder box for when no media is playing.""" - fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - # Album cover with fallback image - album_cover = Box(style_classes="album-image-c") - album_cover.set_style(f"background-image:url('{fallback_cover_path}')") - - image_stack = Box(h_align="start", v_align="center", name="player-image-stack") - image_stack.children = [album_cover] - - # Track info showing "No media playing" - track_title = Label( - label="No media playing", - name="player-title-c", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - ) - - track_artist = Label( - label="", - name="player-artist-c", - justification="left", - max_chars_width=15, - ellipsization="end", - h_align="start", - visible=False, # Hide artist and album when no media - ) - - track_info = Box( - name="track-info", - # spacing=5, - h_expand=True, - orientation="v", - v_align="start", - h_align="start", - children=[track_title, track_artist], - ) - - # No control buttons for no media state - just an empty box - controls_box = Box( - name="player-controls", - visible=False, # Hide controls when no media - ) - - player_info_box = Box( - name="player-info-box-c", - h_expand=True, - v_align="center", - h_align="center", - orientation="v", - children=[track_info, controls_box], - ) - - inner_box = CenterBox( - name="inner-player-box", - start_children=[ - image_stack, - ], - center_children=[ - player_info_box, - ], - ) - # resize the inner box - outer_box = Box( - spacing=5, - name="outer-no-player-box-c", - h_expand=True, - h_align="fill", - # children=[ - v_expand=True, - children=inner_box, - # inner_box, - # player_info_box, - # image, - # ], - ) - - box = Box( - name="box-c", - orientation="h", - v_expand=True, - h_align="fill", - h_expand=True, - children=[ - outer_box, - ], - ) - no_media_box = Box( - h_align="center", - name="player-box", - h_expand=True, - children=[box], - ) - - return no_media_box - - def _find_playing_player_index(self): - """Find the index of the currently playing player.""" - players: List[PlayerBox] = self.player_stack.get_children() - for i, player_box in enumerate(players): - if ( - hasattr(player_box, "player") - and player_box.player.playback_status == "playing" - ): - return i - return None - - def _switch_to_playing_player(self): - """Switch to the currently playing player if one exists.""" - playing_index = self._find_playing_player_index() - if playing_index is not None and playing_index != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Auto-switching to playing player at index { - playing_index - }" - ) - self.on_player_clicked_by_index(playing_index) - - def on_player_playback_changed(self, player_box, status): - """Called when a player's playback status changes.""" - if status == "playing": - # Find this player's index and switch to it - players: List[PlayerBox] = self.player_stack.get_children() - for i, pb in enumerate(players): - if pb == player_box: - if i != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Switching to playing player: { - player_box.player.player_name - }" - ) - self.on_player_clicked_by_index(i) - break - - def on_player_clicked(self, type): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - - if type == "next": - self.current_stack_pos = ( - self.current_stack_pos + 1 - if self.current_stack_pos != len(self.player_stack.get_children()) - 1 - else 0 - ) - elif type == "prev": - self.current_stack_pos = ( - self.current_stack_pos - 1 - if self.current_stack_pos != 0 - else len(self.player_stack.get_children()) - 1 - ) - - # set new active button - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - print( - f"[PlayerBoxStack] Switching to player at index { - self.current_stack_pos - }" - ) - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - def on_player_clicked_by_index(self, index): - """Switch to player at given index""" - if 0 <= index < len(self.player_buttons): - # unset active from prev active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - # set new position - self.current_stack_pos = index - # set new active button - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - # Update all player boxes with new button state - self._update_all_player_buttons() - - def on_new_player(self, mpris_manager, player): - player_name = player.props.player_name - - # if player_name in self.config.get("ignore", []): - # return - - # Remove the no media box if it's the only child - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - self.player_stack.children = [] - self.current_stack_pos = 0 - - self.set_visible(True) - - new_player_box = PlayerBox( - player=MprisPlayer(player), - player_stack=self, - control_center=self.control_center, - ) - self.player_stack.children = [ - *self.player_stack.children, - new_player_box, - ] - - self.make_new_player_button(self.player_stack.get_children()[-1]) - logger.info( - f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", - ) - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - # Check if this new player is playing and switch to it - self._switch_to_playing_player() - - def on_lost_player(self, mpris_manager, player_name): - # the playerBox is automatically removed from mprisbox children on being removed - logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") - players: List[PlayerBox] = self.player_stack.get_children() - - # Find and properly destroy the player box - player_box_to_remove = None - for player_box in players: - if ( - hasattr(player_box, "player") - and player_box.player.player_name == player_name - ): - player_box_to_remove = player_box - break - - if player_box_to_remove: - try: - player_box_to_remove.destroy() - except Exception as e: - logger.warning(f"Failed to destroy player box: {e}") - - # Check if this was the last player - remaining_players = [ - p for p in self.player_stack.get_children() if p != player_box_to_remove - ] - if len(remaining_players) == 0: - # Show the no media box instead of hiding - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_buttons = [] # Clear player buttons - return - - # Adjust current position if needed - if self.current_stack_pos >= len(self.player_stack.get_children()): - self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) - - # Set active button if we have buttons and a valid position - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - if self.player_stack.get_children(): - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos], - ) - - # Update all player boxes with current button state - self._update_all_player_buttons() - - # After a player is removed, check if we should switch to a playing player - self._switch_to_playing_player() - - def make_new_player_button(self, player_box): - new_button = Button(name="player-stack-button") - - def on_player_button_click(button: Button): - if self.player_buttons and self.current_stack_pos < len( - self.player_buttons - ): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - if button in self.player_buttons: - self.current_stack_pos = self.player_buttons.index(button) - button.add_style_class("active") - self.player_stack.set_visible_child(player_box) - - new_button.connect( - "clicked", - on_player_button_click, - ) - self.player_buttons.append(new_button) - - # This will automatically destroy our used button - def cleanup_button(*_): - try: - if new_button in self.player_buttons: - self.player_buttons.remove(new_button) - new_button.destroy() - except Exception as e: - logger.warning(f"Failed to cleanup button: {e}") - - player_box.connect("destroy", cleanup_button) - - def _update_all_player_buttons(self): - """Update all player boxes with the current button state""" - players: List[PlayerBox] = self.player_stack.get_children() - logger.info( - f"[PlayerBoxStack] Updating buttons for {len(players)} players, { - len(self.player_buttons) - } buttons" - ) - for player_box in players: - if hasattr(player_box, "update_buttons"): - player_box.update_buttons(self.player_buttons, len(players) > 1) - else: - logger.warning( - "[PlayerBoxStack] PlayerBox missing update_buttons method" - ) - - -class PlayerBox(Box): - """A widget that displays the current player information.""" - - def __init__( - self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs - ): - super().__init__( - h_align="center", - name="player-box", - **kwargs, - h_expand=True, - ) - # Setup - self.player: MprisPlayer = player - self.player_stack = player_stack - self.control_center = control_center - self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" - - # Add controls_box attribute early for compatibility - # Temporary placeholder - self.controls_box = Box(name="temp-controls-box") - - self.image_size = 50 - self.icon_size = 15 - - # State - self.exit = False - self.skipped = False - - # Memory management - self.temp_artwork_files = [] # Track temp files for cleanup - self.current_download_thread = None # Track current download thread - self._download_cancelled = False # Flag to cancel downloads - self._signal_connections = [] # Track signal connections - - self.album_cover = Box(style_classes="album-image-c") - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - self.image_stack = Box( - h_align="start", - v_align="center", - name="player-image-stack", - ) - self.image_stack.children = [*self.image_stack.children, self.album_cover] - - self.app_icon = Box( - children=Image( - icon_name=self.player.player_name, name="player-app-icon", icon_size=20 - ), - h_align="end", - v_align="end", - tooltip_text=self.player.player_name, # type: ignore - ) - self.image = Overlay( - child=self.image_stack, - overlays=[ - self.app_icon, - ], - ) - # Track Info - - self.track_title = Label( - label="No Title", - name="player-title-c", - justification="left", - max_chars_width=25, - ellipsization="end", - h_align="start", - ) - - self.track_artist = Label( - label="No Artist", - name="player-artist-c", - justification="left", - max_chars_width=23, - ellipsization="end", - h_align="start", - visible=True, - ) - - self.player.bind_property( - "title", - self.track_title, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" - ), # type: ignore - ) - self.player.bind_property( - "artist", - self.track_artist, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore - ) - - self.track_info = Box( - name="track-info", - spacing=5, - orientation="v", - v_align="start", - h_align="start", - children=[ - self.track_title, - self.track_artist, - ], - ) - - # Buttons with fixed sizing for layout stability - self.button_box = Box( - name="button-box-c", - h_expand=False, - spacing=2, - ) - - # Create SVG icons with consistent sizing - self.skip_next_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.play_pause_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) - - # Fixed size buttons to prevent layout shifts - self.play_pause_button = Button( - name="player-button", - child=self.play_pause_icon, - on_clicked=self.player.play_pause, - ) - # Set consistent button size - - self.player.bind_property("can_pause", self.play_pause_button, "sensitive") - - self.next_button = Button( - name="player-button", - child=self.skip_next_icon, - on_clicked=self._on_player_next, - ) - # Set consistent button size - # self.next_button.set_size_request(32, 32) - self.player.bind_property("can_go_next", self.next_button, "sensitive") - - self.button_box.children = ( - self.play_pause_button, - self.next_button, - ) - - # Assign button_box to controls_box for compatibility - self.controls_box = self.button_box - - self.player_info_box = Box( - name="player-info-box-c", - v_align="center", - h_expand=True, - h_align="start", - orientation="v", - children=[ - self.track_info, - ], - ) - - self.inner_box = Box( - name="inner-player-box", - h_expand=True, - v_align="center", - h_align="start", - children=[ - self.image, - self.player_info_box, - ], - ) - # resize the inner box - self.outer_box = Button( - spacing=5, - name="outer-player-box-c", - h_expand=True, - # style="background-color:#fff", - on_clicked=self._on_outer_box_clicked, - h_align="start", - # children=[ - child=self.inner_box, - # self.inner_box, - # self.player_info_box, - # self.image, - # ], - ) - - self.box = CenterBox( - name="box-c", - orientation="h", - h_align="center", - start_children=[ - self.outer_box, - # self.stack_buttons_box, - ], - end_children=[ - self.button_box, - ], - ) - - self.children = [ - *self.children, - self.box, - ] - - # Track signal connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.player, - { - "exit": self._on_player_exit, - "notify::playback-status": self._on_playback_change, - "notify::metadata": self._on_metadata, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.player, handler_id)) - - def destroy(self): - """Clean up all resources when the widget is destroyed.""" - # Cancel any ongoing downloads - self._download_cancelled = True - - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect signal: {e}") - self._signal_connections.clear() - - # Clean up temp files - self._cleanup_temp_files() - - super().destroy() - - def __del__(self): - """Ensure cleanup happens even if player exits unexpectedly.""" - try: - self._cleanup_temp_files() - except Exception: - pass # Ignore errors during cleanup in destructor - - def _on_prev_button_click(self, *_): - """Handle prev button click: open expanded player in control center""" - try: - # Open expanded player in control center instead of new window - if self.control_center and hasattr( - self.control_center, "open_expanded_player" - ): - self.control_center.open_expanded_player() - except Exception as e: - logger.warning(f"Failed to handle prev button click: {e}") - - def _on_outer_box_clicked(self, *_): - """Handle outer box click with proper error handling.""" - try: - # Open expanded player in control center instead of new window - if self.control_center and hasattr( - self.control_center, "open_expanded_player" - ): - self.control_center.open_expanded_player() - except Exception as e: - logger.warning(f"Failed to handle outer box click: {e}") - import traceback - - logger.error(f"Full traceback: {traceback.format_exc()}") - - def update_buttons(self, player_buttons, show_buttons): - # """Update the stack switcher buttons in this player box""" - pass - - def _on_metadata(self, *_): - self._set_image() - - def _cleanup_temp_files(self): - """Clean up temporary artwork files.""" - for temp_file in self.temp_artwork_files: - try: - if os.path.exists(temp_file): - os.unlink(temp_file) - except Exception as e: - logger.warning(f"Failed to cleanup temp file {temp_file}: {e}") - self.temp_artwork_files.clear() - - def _on_player_exit(self, _, value): - self.exit = value - self._cleanup_temp_files() # Clean up temp files before destroying - self.destroy() - - def _on_player_next(self, *_): - self.player.next() - - def _on_player_prev(self, *_): - self.player.previous() - - def _on_playback_change(self, player, status): - status = player.get_property("playback-status") - - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) - - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) - # Notify the player stack that this player started playing - if self.player_stack and hasattr( - self.player_stack, "on_player_playback_changed" - ): - self.player_stack.on_player_playback_changed(self, status) - - def _update_image(self, image_path): - if image_path and os.path.isfile(image_path): - self.album_cover.set_style(f"background-image:url('{image_path}')") - else: - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - def _set_image(self, *_): - art_url = self.player.arturl - - parsed = urllib.parse.urlparse(art_url) - if parsed.scheme == "file": - local_arturl = urllib.parse.unquote(parsed.path) - self._update_image(local_arturl) - elif parsed.scheme in ("http", "https"): - # Cancel any existing download to prevent memory buildup - self._download_cancelled = True - - # Use threading.Thread instead of GLib.Thread for better control - if self.current_download_thread and self.current_download_thread.is_alive(): - # Thread will check _download_cancelled flag and exit early - pass - - self._download_cancelled = False - self.current_download_thread = threading.Thread( - target=self._download_and_set_artwork, - args=(art_url,), - daemon=True, # Dies with main thread - ) - self.current_download_thread.start() - else: - self._update_image(art_url) - - def _download_and_set_artwork(self, arturl): - """ - Download the artwork from the given URL asynchronously and update the cover - using GLib.idle_add to ensure UI updates occur on the main thread. - """ - local_arturl = self.fallback_cover_path - temp_file_path = None - - try: - # Check if download was cancelled - if self._download_cancelled: - return - - # Clean up old temp files first (keep only last 1 to reduce memory) - if len(self.temp_artwork_files) > 1: - old_files = self.temp_artwork_files[:-1] - for old_file in old_files: - try: - if os.path.exists(old_file): - os.unlink(old_file) - except Exception: - pass - self.temp_artwork_files = self.temp_artwork_files[-1:] - - # Check again if cancelled - if self._download_cancelled: - return - - # Download artwork - parsed = urllib.parse.urlparse(arturl) - suffix = os.path.splitext(parsed.path)[1] or ".png" - - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() - - # Check one more time if cancelled - if self._download_cancelled: - return - - # Create temp file in cache directory instead of system temp - os.makedirs(CACHE_DIR, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, dir=CACHE_DIR - ) as temp_file: - temp_file.write(data) - temp_file_path = temp_file.name - local_arturl = temp_file_path - - # Track temp file for cleanup - if temp_file_path and not self._download_cancelled: - self.temp_artwork_files.append(temp_file_path) - - except Exception as e: - if not self._download_cancelled: - logger.warning(f"Failed to download artwork from {arturl}: {e}") - # Clean up failed temp file - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - except Exception: - pass - return - - # Only update UI if not cancelled - if not self._download_cancelled: - GLib.idle_add(self._update_image, local_arturl) - - def close_bluetooth(self, *args): - """Placeholder method for compatibility""" - pass diff --git a/modules/corners.py b/modules/corners.py deleted file mode 100644 index cf3c79e8..00000000 --- a/modules/corners.py +++ /dev/null @@ -1,68 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.shapes import Corner -from widgets.wayland import WaylandWindow as Window - - -class MyCorner(Box): - def __init__(self, corner): - super().__init__( - name="corner-container", - children=Corner( - name="corner", - orientation=corner, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - size=20, - ), - ) - - -class Corners(Window): - def __init__(self): - super().__init__( - name="corners", - layer="bottom", - anchor="top bottom left right", - exclusivity="normal", - # pass_through=True, - visible=False, - all_visible=False, - ) - - self.all_corners = Box( - name="all-corners", - orientation="v", - h_expand=True, - v_expand=True, - h_align="fill", - v_align="fill", - children=[ - Box( - name="top-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("top-left"), - Box(h_expand=True), - MyCorner("top-right"), - ], - ), - Box(v_expand=True), - Box( - name="bottom-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("bottom-left"), - Box(h_expand=True), - MyCorner("bottom-right"), - ], - ), - ], - ) - - self.add(self.all_corners) - - self.show_all() diff --git a/modules/dock.py b/modules/dock.py deleted file mode 100644 index 0c836f43..00000000 --- a/modules/dock.py +++ /dev/null @@ -1,843 +0,0 @@ -import json -import os -import re -import subprocess - -from fabric.utils.helpers import get_desktop_applications, get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.revealer import Revealer -from gi.repository import GLib, Gtk -from loguru import logger - -import config.data as data -from services.modus import modus_service -from utils.functions import read_json_file, write_json_file, is_special_workspace_id -from utils.icon_resolver import IconResolver -from utils.occlusion import check_occlusion -from widgets.wayland import WaylandWindow as Window - -# Pinned apps file -PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json") - - -class AppBar(Box): - def __init__(self, parent: Window): - self.client_buttons = {} # For running app instances - self.pinned_buttons = {} # For pinned apps - # Position tracking for hover effects - self.running_items_pos = [] - self.pinned_items_pos = [] - self._parent = parent - - # Set orientation based on dock position - orientation = ( - "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal" - ) - - super().__init__( - spacing=0, - name="dock", - orientation=orientation, - children=[], - ) - self.icon_resolver = IconResolver() - self._hyprland_connection = modus_service._hyprland_connection - - # Initialize GTK menu - self.menu = Gtk.Menu() - - self.pinned_apps = read_json_file(PINNED_APPS_FILE) or [] - self.pinned_apps_container = Box() - self.add(self.pinned_apps_container) - - self.separator = Box( - v_align="center", style_classes=["hidden", "dock_separator"] - ) - self.add(self.separator) - - self.running_apps_container = Box(name="dock_container") - self.add(self.running_apps_container) - - self._populate_pinned_apps() - self.setup_app_monitoring() - - def setup_app_monitoring(self): - def update_running_apps(): - try: - self.update_dock_apps() - except Exception as e: - logger.error(f"[AppBar] Error updating apps: {e}") - return True - - GLib.timeout_add(250, update_running_apps) - GLib.idle_add(self.update_dock_apps) - - def _populate_pinned_apps(self): - for child in self.pinned_apps_container.get_children(): - self.pinned_apps_container.remove(child) - - self.pinned_buttons = {} - self.pinned_items_pos = [] - - try: - desktop_apps = get_desktop_applications(include_hidden=False) - except Exception: - desktop_apps = [] - - for app_data in self.pinned_apps: - self._create_pinned_button(app_data, desktop_apps) - - # Add trash icon at the end of pinned apps - self._create_trash_button() - - def _create_pinned_button(self, app_data, desktop_apps): - if isinstance(app_data, dict): - app_identifier = app_data.get("name", "") or app_data.get( - "window_class", "" - ) - display_name = app_data.get("display_name", app_identifier) - app = self._find_desktop_app_from_data(app_data, desktop_apps) - - if app: - icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - else: - icon_name = app_data.get("window_class", "") or app_data.get("name", "") - icon_pixbuf = self.icon_resolver.get_icon_pixbuf( - icon_name, data.DOCK_ICON_SIZE - ) - else: - app_identifier = app_data - app = self._find_desktop_app_by_id(app_data, desktop_apps) - if not app: - return - - display_name = app.display_name or app.name - icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - - pinned_image = Image(name="dock_item_icon") - pinned_image.set_from_pixbuf(icon_pixbuf) - - main_container = Box( - name="dock_item_main_container", - orientation="v", - children=[pinned_image], - ) - - pinned_button = Button( - name="dock_item", - child=main_container, - tooltip_text=display_name, - on_button_press_event=lambda _, event: self._handle_pinned_app_click( - event, app_data - ), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - pinned_button, True - ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - pinned_button, True - ), - ) - - pinned_button.add_style_class("shown") - - self.pinned_buttons[app_identifier] = pinned_button - self.pinned_apps_container.add(pinned_button) - self.pinned_items_pos.append(pinned_button) - - def _create_trash_button(self): - """Create a trash button that opens the trash in file manager""" - # Get trash icon - trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf( - "user-trash", data.DOCK_ICON_SIZE - ) - - trash_image = Image(name="dock_item_icon") - trash_image.set_from_pixbuf(trash_icon_pixbuf) - - main_container = Box( - name="dock_item_main_container", - orientation="v", - children=[trash_image], - ) - - trash_button = Button( - name="dock_item", - child=main_container, - tooltip_text="Trash", - on_button_press_event=lambda _, event: self._handle_trash_click(event), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - trash_button, True - ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - trash_button, True - ), - ) - - trash_button.add_style_class("shown") - trash_button.is_trash = True - - self.pinned_buttons["trash"] = trash_button - self.pinned_apps_container.add(trash_button) - self.pinned_items_pos.append(trash_button) - - def _find_desktop_app_from_data(self, app_data: dict, desktop_apps): - for app in desktop_apps: - if ( - ( - app_data.get("name") - and app.name - and app.name.lower() == app_data["name"].lower() - ) - or ( - app_data.get("window_class") - and hasattr(app, "window_class") - and app.window_class - and app.window_class.lower() == app_data["window_class"].lower() - ) - or ( - app_data.get("executable") - and app.executable - and ( - app.executable.lower() == app_data["executable"].lower() - or os.path.basename(app.executable).lower() - == os.path.basename(app_data["executable"]).lower() - ) - ) - ): - return app - return None - - def _find_desktop_app_by_id(self, app_id: str, desktop_apps): - for app in desktop_apps: - if ( - (app.name and app.name.lower() == app_id.lower()) - or (app.display_name and app.display_name.lower() == app_id.lower()) - or ( - hasattr(app, "window_class") - and app.window_class - and app.window_class.lower() == app_id.lower() - ) - or ( - app.executable - and ( - app.executable.lower() == app_id.lower() - or os.path.basename(app.executable).lower() == app_id.lower() - ) - ) - ): - return app - return None - - def show_menu(self, app_id: str, client=None, instance_address=None): - for item in self.menu.get_children(): - self.menu.remove(item) - item.destroy() - - if client or instance_address: - close_item = Gtk.MenuItem(label="Close") - if instance_address: - close_item.connect( - "activate", lambda *_: self._close_running_app(instance_address) - ) - self.menu.add(close_item) - - if app_id: - separator = Gtk.SeparatorMenuItem() - self.menu.add(separator) - - if app_id: - is_pinned = self._is_app_pinned(app_id) - pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin") - - if is_pinned: - pin_item.connect("activate", lambda *_: self._unpin_app(app_id)) - else: - pin_item.connect("activate", lambda *_: self._pin_app(app_id)) - - self.menu.add(pin_item) - - self.menu.show_all() - - def _close_running_app(self, instance_address): - try: - self._hyprland_connection.send_command( - f"dispatch closewindow address:{instance_address}" - ) - except Exception as e: - logger.error(f"[AppBar] Error closing window: {e}") - - def _handle_pinned_app_click(self, event, app_data): - if event.button == 1: # Left click - launch app - self._launch_app_data(app_data) - elif event.button == 2: # Middle click - unpin app - app_identifier = self._get_app_identifier(app_data) - self._unpin_app(app_identifier) - elif event.button == 3: # Right click - show context menu - app_identifier = self._get_app_identifier(app_data) - self.show_menu(app_identifier) - self.menu.popup_at_pointer(event) - - def _handle_trash_click(self, event): - """Handle trash button click to open trash in file manager""" - if event.button == 1: # Left click - try: - trash_path = os.path.expanduser("~/.local/share/Trash/files") - file_managers = [ - "nautilus", - "dolphin", - "thunar", - "nemo", - "caja", - "pcmanfm", - ] - - for fm in file_managers: - try: - result = subprocess.run( - ["which", fm], capture_output=True, text=True - ) - if result.returncode == 0: - subprocess.Popen([fm, trash_path]) - return - except Exception: - continue - except Exception as e: - logger.error(f"[AppBar] Error opening trash: {e}") - - def _handle_item_hovered(self, item, pinned=False): - if pinned: - try: - index = self.pinned_items_pos.index(item) - if index > 0: - self.pinned_items_pos[index - 1].add_style_class("semi_hovered") - if index < len(self.pinned_items_pos) - 1: - self.pinned_items_pos[index + 1].add_style_class("semi_hovered") - except ValueError: - pass - else: - try: - index = self.running_items_pos.index(item) - if index > 0: - self.running_items_pos[index - 1].add_style_class("semi_hovered") - if index < len(self.running_items_pos) - 1: - self.running_items_pos[index + 1].add_style_class("semi_hovered") - except ValueError: - pass - - def _handle_item_unhovered(self, item, pinned=False): - if pinned: - try: - index = self.pinned_items_pos.index(item) - if index > 0: - self.pinned_items_pos[index - 1].remove_style_class("semi_hovered") - if index < len(self.pinned_items_pos) - 1: - self.pinned_items_pos[index + 1].remove_style_class("semi_hovered") - except ValueError: - pass - else: - try: - index = self.running_items_pos.index(item) - if index > 0: - self.running_items_pos[index - 1].remove_style_class("semi_hovered") - if index < len(self.running_items_pos) - 1: - self.running_items_pos[index + 1].remove_style_class("semi_hovered") - except ValueError: - pass - - def _get_app_identifier(self, app_data): - if isinstance(app_data, dict): - return app_data.get("name", "") or app_data.get("window_class", "") - return app_data - - def _launch_app_data(self, app_data): - try: - desktop_apps = get_desktop_applications(include_hidden=False) - - if isinstance(app_data, dict): - app = self._find_desktop_app_from_data(app_data, desktop_apps) - if app: - self._launch_app(app) - else: - self._launch_app_from_data(app_data) - else: - app = self._find_desktop_app_by_id(app_data, desktop_apps) - if app: - self._launch_app(app) - except Exception as e: - logger.error(f"[AppBar] Failed to launch app: {e}") - - def _launch_app(self, app): - try: - cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - except Exception: - try: - app.launch() - except Exception as fallback_error: - logger.error(f"[AppBar] Failed to launch app: {fallback_error}") - - def _launch_app_from_data(self, app_data): - try: - command_line = app_data.get("command_line", "") - if command_line: - cleaned_command = re.sub(r"%\w+", "", command_line).strip() - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - elif app_data.get("executable"): - final_command = ( - f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'" - ) - subprocess.Popen(final_command, shell=True) - else: - logger.error( - f"[AppBar] No command or executable found for app: {app_data}" - ) - except Exception as e: - logger.error(f"[AppBar] Failed to launch app from data: {e}") - - def _pin_app(self, app_class: str): - if self._is_app_pinned(app_class): - return False - - try: - desktop_apps = get_desktop_applications(include_hidden=False) - app = self._find_desktop_app_by_id(app_class, desktop_apps) - - if app: - app_data = { - "name": app.name, - "display_name": app.display_name or app.name, - "window_class": getattr(app, "window_class", None) or app_class, - "executable": app.executable, - "command_line": app.command_line, - } - else: - app_data = { - "name": app_class, - "display_name": app_class, - "window_class": app_class, - "executable": app_class, - "command_line": app_class, - } - - self.pinned_apps.append(app_data) - except Exception: - self.pinned_apps.append(app_class) - - write_json_file(self.pinned_apps, PINNED_APPS_FILE) - self._populate_pinned_apps() - return True - - def _unpin_app(self, app_identifier: str): - apps_to_remove = [] - - for i, pinned_app in enumerate(self.pinned_apps): - if self._matches_app_identifier(pinned_app, app_identifier): - apps_to_remove.append(i) - - for i in reversed(apps_to_remove): - self.pinned_apps.pop(i) - - if apps_to_remove: - write_json_file(self.pinned_apps, PINNED_APPS_FILE) - self._populate_pinned_apps() - return True - return False - - def _matches_app_identifier(self, pinned_app, app_identifier): - if not app_identifier: - return False - - if isinstance(pinned_app, dict): - window_class = pinned_app.get("window_class") or "" - name = pinned_app.get("name") or "" - return ( - window_class.lower() == app_identifier.lower() - or name.lower() == app_identifier.lower() - ) - return ( - isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower() - ) - - def get_clients(self): - try: - clients_data = self._hyprland_connection.send_command("j/clients").reply - if not clients_data: - return [] - return json.loads(clients_data.decode("utf-8")) - except Exception as e: - logger.error(f"[AppBar] Error getting clients: {e}") - return [] - - def get_focused_window(self): - try: - active_data = self._hyprland_connection.send_command("j/activewindow").reply - if not active_data: - return None - return json.loads(active_data.decode("utf-8")) - except Exception as e: - logger.error(f"[AppBar] Error getting focused window: {e}") - return None - - def update_dock_apps(self): - try: - clients = self.get_clients() - focused_window = self.get_focused_window() - focused_address = focused_window.get("address", "") if focused_window else "" - - current_instance_ids = set() - - for client in clients: - if client.get("hidden", False) or not self._should_show_app_instance(client): - continue - - instance_address = client.get("address", "") - app_class = client.get("class", "") or client.get("title", "") - if not instance_address or not app_class: - continue - - current_instance_ids.add(instance_address) - - if instance_address not in self.client_buttons: - self.create_instance_button(instance_address, client, app_class) - else: - self.update_instance_button(instance_address, client, app_class) - - button = self.client_buttons[instance_address] - if instance_address == focused_address: - button.add_style_class("activated") - else: - button.remove_style_class("activated") - - self._update_pinned_apps_state(clients) - self._update_separator_visibility() - - self._cleanup_removed_instances(current_instance_ids) - - except Exception as e: - logger.error(f"[AppBar] Error in update_dock_apps: {e}") - - def _update_pinned_apps_state(self, clients): - running_app_classes = { - client.get("class", "").lower() or client.get("title", "").lower() - for client in clients - if not client.get("hidden", False) - and (client.get("class") or client.get("title")) - and self._should_show_app_instance(client) - } - - for app_identifier, button in self.pinned_buttons.items(): - # Skip trash button as it's not a regular app - if app_identifier == "trash" or hasattr(button, "is_trash"): - continue - - if app_identifier.lower() in running_app_classes: - button.add_style_class("instance") - else: - button.remove_style_class("instance") - - def _cleanup_removed_instances(self, current_instance_ids): - buttons_to_remove = [ - instance_id - for instance_id in self.client_buttons.keys() - if instance_id not in current_instance_ids - ] - - # Clean up removed and orphaned buttons - for instance_id in buttons_to_remove + [k for k, v in self.client_buttons.items() - if not hasattr(v, 'instance_address') or not v.get_parent()]: - if instance_id in self.client_buttons: - button = self.client_buttons.pop(instance_id) - try: - if button in self.running_items_pos: - self.running_items_pos.remove(button) - button.remove_style_class("shown") - button.remove_style_class("activated") - if button.get_parent(): - button.get_parent().remove(button) - button.destroy() - except Exception as e: - logger.warning(f"[AppBar] Error during cleanup: {e}") - - def create_instance_button(self, instance_address, client, app_class): - try: - client_image = Image(name="dock_item_icon") - - try: - desktop_apps = get_desktop_applications(include_hidden=False) - desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps) - - if desktop_app: - pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - else: - pixbuf = self.icon_resolver.get_icon_pixbuf( - app_class, data.DOCK_ICON_SIZE - ) - - client_image.set_from_pixbuf(pixbuf) - except Exception as e: - logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}") - - workspace_id = self._get_workspace_id(client) - workspace_label = None - if workspace_id is not None: - workspace_label = Label( - label=str(workspace_id), - name="workspace-indicator", - h_align="end", - v_align="end", - ) - - image_overlay = Overlay(name="dock-image-overlay", child=client_image) - if workspace_label: - image_overlay.add_overlay(workspace_label) - - indicator = Box(name="dock_item_indicator", h_align="center") - main_container = Box( - name="dock_item_main_container", - orientation="v", - children=[image_overlay, indicator], - ) - - tooltip_text = client.get("title", app_class) - if tooltip_text != app_class: - tooltip_text = f"{app_class}: {tooltip_text}" - - client_button = Button( - name="dock_item", - child=main_container, - tooltip_text=tooltip_text, - on_button_press_event=lambda widget, event: self.handle_instance_click( - widget, event - ), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - client_button, False - ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - client_button, False - ), - ) - - client_button.instance_address = instance_address - client_button.client_data = client - client_button.app_class = app_class - client_button.workspace_label = workspace_label - client_button.add_style_class("shown") - - self.client_buttons[instance_address] = client_button - self.running_apps_container.add(client_button) - self.running_items_pos.append(client_button) - - except Exception as e: - logger.error(f"[AppBar] Error creating instance button for {app_class}: {e}") - - def _get_workspace_id(self, client): - workspace_data = client.get("workspace", {}) - if isinstance(workspace_data, dict): - return workspace_data.get("id") - elif isinstance(workspace_data, (int, str)): - return workspace_data - return None - - def _is_special_workspace_id(self, ws_id): - return is_special_workspace_id(ws_id) - - def _should_show_app_instance(self, client): - if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS: - return True - - workspace_id = self._get_workspace_id(client) - if workspace_id is None: - return True - - return not self._is_special_workspace_id(workspace_id) - - def update_instance_button(self, instance_address, client, app_class): - if instance_address not in self.client_buttons: - return - - button = self.client_buttons[instance_address] - button.client_data = client - button.app_class = app_class - - tooltip_text = client.get("title", app_class) - if tooltip_text != app_class: - tooltip_text = f"{app_class}: {tooltip_text}" - button.set_tooltip_text(tooltip_text) - - workspace_id = self._get_workspace_id(client) - existing_label = getattr(button, "workspace_label", None) - - container = button.get_child() - if hasattr(container, "get_children"): - children = container.get_children() - if children: - image_overlay = children[0] - if isinstance(image_overlay, Overlay): - # Remove existing workspace label - if existing_label and existing_label.get_parent(): - image_overlay.remove_overlay(existing_label) - - # Add new workspace label if needed - if workspace_id is not None: - new_label = Label( - label=str(workspace_id), - name="workspace-indicator", - h_align="end", - v_align="end", - ) - image_overlay.add_overlay(new_label) - button.workspace_label = new_label - else: - button.workspace_label = None - - def handle_instance_click(self, button_widget, event): - instance_address = getattr(button_widget, "instance_address", None) - app_class = getattr(button_widget, "app_class", None) - - if event.button == 1: # Left click - focus window - if instance_address: - try: - self._hyprland_connection.send_command( - f"dispatch focuswindow address:{instance_address}" - ) - except Exception as e: - logger.error(f"[AppBar] Error focusing window: {e}") - - elif event.button == 2: # Middle click - pin/unpin app - if app_class and not self._is_app_pinned(app_class): - self._pin_app(app_class) - - elif event.button == 3: # Right click - context menu - if app_class: - self.show_menu(app_class, instance_address=instance_address) - self.menu.popup_at_pointer(event) - - def _is_app_pinned(self, app_class: str) -> bool: - return any( - self._matches_app_identifier(pinned_app, app_class) - for pinned_app in self.pinned_apps - ) - - def _update_separator_visibility(self): - has_pinned_apps = len(self.pinned_items_pos) > 0 - has_running_apps = len(self.running_items_pos) > 0 - if has_pinned_apps and has_running_apps: - self.separator.remove_style_class("hidden") - else: - self.separator.add_style_class("hidden") - - -class Dock(Window): - def __init__(self): - if not data.DOCK_ENABLED: - anchor = self._get_anchor_from_position() - super().__init__(layer="top", title="dock", anchor=anchor) - self.children = Box() # Empty dock if disabled - return - - anchor = self._get_anchor_from_position() - super().__init__(layer="top", anchor=anchor) - - self.app_bar = AppBar(self) - - transition_type = self._get_transition_type() - - self.revealer = Revealer( - child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"), - transition_duration=200, - transition_type=transition_type, - ) - - self.children = EventBox( - events=["enter-notify", "leave-notify"], - child=Box(style="min-height: 1px", children=self.revealer), - on_enter_notify_event=lambda *_: self.on_hover_enter(), - on_leave_notify_event=lambda *_: self.on_hover_leave(), - ) - - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - - self.dock_height = 100 - self.is_hovered = False - self.hide_timeout_id = None - - # Only setup occlusion monitoring if auto-hide is enabled - if data.DOCK_AUTO_HIDE: - self.setup_occlusion_monitoring() - - def on_hover_enter(self): - self.is_hovered = True - if self.hide_timeout_id: - GLib.source_remove(self.hide_timeout_id) - self.hide_timeout_id = None - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - - def on_hover_leave(self): - self.is_hovered = False - # Add small delay before potential hiding to prevent rapid show/hide cycles - if self.hide_timeout_id: - GLib.source_remove(self.hide_timeout_id) - self.hide_timeout_id = GLib.timeout_add(100, lambda: None) - - def _get_anchor_from_position(self): - if data.DOCK_POSITION == "Left": - return "left center" - elif data.DOCK_POSITION == "Right": - return "right center" - else: # Bottom (default) - return "bottom center" - - def _get_transition_type(self): - if data.DOCK_POSITION == "Left": - return "slide-right" - elif data.DOCK_POSITION == "Right": - return "slide-left" - else: # Bottom (default) - return "slide-up" - - def _get_occlusion_position(self): - if data.DOCK_POSITION == "Left": - return ("left", self.dock_height) - elif data.DOCK_POSITION == "Right": - return ("right", self.dock_height) - else: # Bottom (default) - return ("bottom", self.dock_height) - - def setup_occlusion_monitoring(self): - def check_dock_occlusion(): - try: - if data.DOCK_ALWAYS_OCCLUDED: - is_occluded = True - else: - occlusion_position = self._get_occlusion_position() - is_occluded = check_occlusion(occlusion_position) - - if ( - is_occluded - and not self.is_hovered - and self.revealer.get_reveal_child() - ): - self.revealer.set_reveal_child(False) - self.app_bar.remove_style_class("shown") - elif not is_occluded and not self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - elif is_occluded and self.is_hovered: - if not self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") - except Exception as e: - logger.error(f"[Dock] Occlusion check error: {e}") - - return True - - GLib.timeout_add(300, check_dock_occlusion) diff --git a/modules/launcher/__init__.py b/modules/launcher/__init__.py deleted file mode 100644 index 547fa73f..00000000 --- a/modules/launcher/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Plugin-based launcher module for Fabric. -Similar to Albert Launcher with extensible plugin system. -""" - -from .main import Launcher - -__all__ = ["Launcher"] diff --git a/modules/launcher/main.py b/modules/launcher/main.py deleted file mode 100644 index 30ada343..00000000 --- a/modules/launcher/main.py +++ /dev/null @@ -1,1380 +0,0 @@ -from typing import List, Optional, Tuple - -from gi.repository import Gdk, GLib - -from fabric.core.service import Property -from fabric.widgets.box import Box -from fabric.widgets.entry import Entry -from modules.launcher.plugin_manager import PluginManager -from modules.launcher.result import Result -from modules.launcher.result_item import ResultItem -from modules.launcher.trigger_config import TriggerConfig -from fabric.widgets.scrolledwindow import ScrolledWindow -from widgets.wayland import WaylandWindow as Window - -# Constants -SEARCH_DEBOUNCE_MS = 50 -TRIGGER_SEARCH_DEBOUNCE_MS = 50 -CURSOR_POSITION_DELAY_MS = 10 -SCROLL_PADDING = 10 -DEFAULT_ITEM_HEIGHT = 68 -PAGE_NAVIGATION_STEP = 5 -LAUNCHER_WIDTH = 640 -LAUNCHER_HEIGHT = 400 - - -class Launcher(Window): - """ - Main launcher window with search functionality and plugin system. - Spotlight-style interface. - """ - - # Properties - query = Property(str, flags="read-write", default_value="") - visible = Property(bool, flags="read-write", default_value=False) - active_trigger = Property(str, flags="read-write", default_value="") - - def __init__(self, **kwargs): - super().__init__( - name="launcher-window", - title="modus-launcher", - layer="top", - anchor="center", - exclusivity="none", - keyboard_mode="exclusive", - visible=False, # Start hidden until explicitly shown - **kwargs, - ) - self._initializing = True - self._auto_adding_space = False - self._processing_backspace = False - self.plugin_manager = PluginManager() - self.trigger_config = TriggerConfig() - - self.results: List[Result] = [] - self.selected_index = 0 - self.max_results = 5 # Show 4 applications by default instead of list - - # Trigger system - self.triggered_plugin = None # Currently active triggered plugin - self.active_trigger = "" # Currently active trigger keyword - self.query = "" # Current search query - self.visible = False # Launcher visibility state - self.opened_with_trigger = False # Whether launcher was opened with a trigger - - # Focus management - self.focus_mode = "search" # "search", "results" - - # Mouse activity tracking - self._mouse_active = False - self._last_mouse_activity = 0 - self._mouse_activity_timeout = 1000 # 1 second timeout - self._keyboard_used_recently = False - self._last_keyboard_activity = 0 - self._keyboard_activity_timeout = 2000 # 2 second timeout - self._launcher_just_opened = False - self._mouse_interaction_delay = 500 # 500ms delay after opening - - # Setup UI - main_box = Box( - name="launcher", - orientation="v", - spacing=0, - h_align="center", - v_align="center", - ) - self.add(main_box) - - # Search entry with Spotlight-style large text - self.search_entry = Entry( - name="launcher-search", - placeholder="Spotlight Search", - h_expand=True, - h_align="fill", - notify_text=lambda entry, *_: self._on_search_changed(entry), - ) - self.search_entry.connect("changed", self._on_search_changed) - self.search_entry.connect("activate", self._on_entry_activate) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - self.search_entry, - ], - ) - - main_box.add(self.header_box) - - self.results_scroll = ScrolledWindow( - name="launcher-results-scroll", - h_scrollbar_policy="never", - min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), - max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), - propagate_width=False, - propagate_height=False, - ) - - self.results_box = Box( - name="launcher-results", - orientation="v", - spacing=0, - ) - self.results_scroll.add(self.results_box) - main_box.add(self.results_scroll) - - # Keep the results container visible initially - - self.connect("key-press-event", self._on_key_press) - - # Connect mouse events to track activity - self.results_scroll.connect("button-press-event", self._on_mouse_activity) - self.results_scroll.connect("motion-notify-event", self._on_mouse_activity) - self.results_scroll.connect("scroll-event", self._on_mouse_activity) - - # Also track mouse activity on the main window for better coverage - self.connect("button-press-event", self._on_mouse_activity) - self.connect("motion-notify-event", self._on_mouse_activity) - - self.hide() - - # Mark initialization as complete - self._initializing = False - - # Hide trigger suggestions at startup - self._clear_results() - - def show_launcher(self, trigger_keyword: str = None, external: bool = False): - """Show the launcher and focus the search entry, or execute command externally. - - Args: - trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app") - external: If True, execute the command without showing the launcher UI - """ - # Reset mouse activity tracking when launcher is shown - self._mouse_active = False - self._last_mouse_activity = 0 - self._keyboard_used_recently = False - self._last_keyboard_activity = 0 - self._launcher_just_opened = True - - # Schedule to allow mouse interactions after a delay - def enable_mouse_interactions(): - self._launcher_just_opened = False - return False - - GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions) - - if external and trigger_keyword: - # Execute command externally without showing launcher - return self._execute_external_command(trigger_keyword) - - self.show_all() - - if trigger_keyword: - # Set flag to track that launcher was opened with a trigger keyword - self.opened_with_trigger = True - - # Set the trigger keyword with a space and activate trigger mode - trigger_text = f"{trigger_keyword} " - self.search_entry.set_text(trigger_text) - - # Detect and activate the trigger - triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) - if triggered_plugin: - self.triggered_plugin = triggered_plugin - self.active_trigger = detected_trigger - - # Query the plugin with empty string to show default options - try: - results = triggered_plugin.query("") - self.results = results - self.selected_index = 0 - self._update_results_display() - except Exception as e: - print( - f"Error querying triggered plugin {triggered_plugin.name}: {e}" - ) - self._clear_results() - else: - # Trigger not found, clear and show error or fallback - self.search_entry.set_text("") - self._clear_results() - else: - # Normal launcher opening - show applications - self.opened_with_trigger = False - self.search_entry.set_text("") - # Trigger initial search to show applications immediately - self._perform_search("") - - # Reset focus mode to search - self.focus_mode = "search" - - # Focus search entry without selecting text - if trigger_keyword: - # For trigger keywords, we want the cursor at the end - self.search_entry.grab_focus() - - def position_cursor(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - return False # Only run once - - GLib.idle_add(position_cursor) - else: - # For normal opening, use our method that prevents text selection - self._focus_search_entry_without_selection() - - self.visible = True - - def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None: - """Position cursor at the end of search entry text.""" - if text_length is None: - text_length = len(self.search_entry.get_text()) - - def position_cursor(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region( - text_length, text_length - ) # No selection - return False # Only run once - - GLib.idle_add(position_cursor) - - def _add_space_to_trigger(self, trigger_word: str) -> None: - """Add space after trigger keyword and position cursor.""" - trigger_text_with_space = f"{trigger_word} " - - # Temporarily disable search change handling to prevent recursion - self._auto_adding_space = True - self.search_entry.set_text(trigger_text_with_space) - - # Position cursor at the end - def position_cursor(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region( - len(trigger_text_with_space), len(trigger_text_with_space) - ) # No selection - self._auto_adding_space = False - return False # Only run once - - GLib.idle_add(position_cursor) - - # Update query - self.query = trigger_text_with_space - return trigger_text_with_space - - def close_launcher(self): - """Hide the launcher and clear search.""" - self.hide() - self.search_entry.set_text("") - self._clear_results() - self.triggered_plugin = None - self.active_trigger = "" - self.visible = False - self.opened_with_trigger = False - - def _on_search_changed(self, entry): - """Handle search text changes.""" - # Skip if still initializing - if getattr(self, "_initializing", True): - return - - # Skip if we're automatically adding a space to prevent recursion - if getattr(self, "_auto_adding_space", False): - return - - # Skip if we're processing a backspace to prevent interference - if getattr(self, "_processing_backspace", False): - return - - # Reset focus to search when user types - if self.focus_mode != "search": - self.focus_mode = "search" - - query = entry.get_text().strip() - self.query = query - - # If query is exactly ':', show all triggers - if query == ":": - self._show_available_triggers() - # If query matches a trigger exactly (with or without space), handle trigger activation - elif any( - query == trig or query == f"{trig} " - for trig in [ - t.strip() - for p in self.plugin_manager.get_active_plugins() - for t in p.get_triggers() - ] - ): - # Check if we need to add space immediately for exact trigger matches - if not query.endswith(" "): - # This is an exact trigger match without space - add space immediately - trigger_text_with_space = self._add_space_to_trigger(query) - GLib.timeout_add( - TRIGGER_SEARCH_DEBOUNCE_MS, - self._perform_search, - trigger_text_with_space, - ) - else: - # Already has space, proceed with normal search - GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) - elif query: - # Debounce search to avoid too many queries - GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) - else: - # Show applications when query is empty - self._perform_search("") - - def _perform_search(self, query: str) -> bool: - """Perform search across all plugins.""" - # Only search if query hasn't changed - if query != self.query: - return False - - if not query: - # Empty query - show popular applications - self.triggered_plugin = None - self.active_trigger = "" - - # Get applications plugin and show popular apps - applications_plugin = self._get_applications_plugin() - if applications_plugin: - try: - # Query with empty string to get popular/frequently used applications - all_results = applications_plugin.query("") - # Limit to max_results for empty query - self.results = all_results[: self.max_results] - self.selected_index = 0 - self._update_results_display() - except Exception as e: - print(f"Error getting applications: {e}") - self._clear_results() - else: - self._clear_results() - return False - - # Check if we're already in trigger mode - if self.triggered_plugin and self.active_trigger: - # We're in trigger mode - search within the triggered plugin - try: - # Extract the search query after the trigger - remaining_query = self._extract_query_after_trigger( - query, self.active_trigger - ) - all_results = self.triggered_plugin.query(remaining_query) - except Exception as e: - print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}") - all_results = [] - else: - # Check for trigger activation - triggered_plugin, trigger = self._detect_trigger(query) - - if triggered_plugin: - # New trigger detected - check if we need to add space automatically - trigger_word = trigger.strip() - current_text = self.search_entry.get_text() - - # If the current text is exactly the trigger word (no space), add space automatically - if ( - current_text.strip() == trigger_word - and not current_text.endswith(" ") - and not getattr(self, "_auto_adding_space", False) - ): - query = self._add_space_to_trigger(trigger_word) - - # Enter trigger mode - self.triggered_plugin = triggered_plugin - self.active_trigger = trigger - - # Extract search query after trigger - remaining_query = self._extract_query_after_trigger(query, trigger) - - # Always call the plugin's query method, even with empty remaining query - # This allows plugins to show default options when just the trigger is typed - try: - all_results = triggered_plugin.query(remaining_query) - except Exception as e: - print(f"Error in triggered plugin {triggered_plugin.name}: {e}") - all_results = [] - else: - # No trigger detected - search applications and show trigger suggestions - self.triggered_plugin = None - self.active_trigger = "" - - all_results = [] - - # Search applications directly without trigger - applications_plugin = self._get_applications_plugin() - if applications_plugin: - try: - app_results = applications_plugin.query(query) - all_results.extend(app_results) - except Exception as e: - print(f"Error searching applications: {e}") - - # Also show trigger suggestions if query matches trigger prefixes - trigger_suggestions = self._get_trigger_suggestions(query) - all_results.extend(trigger_suggestions) - - # Sort results by relevance score - all_results.sort(key=lambda r: r.relevance, reverse=True) - - # Check if any results have bypass_max_results flag - has_bypass = any( - hasattr(r, "data") and r.data and r.data.get("bypass_max_results") - for r in all_results - ) - - # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions - if self.triggered_plugin and self.active_trigger: - # In trigger mode - show all results from the triggered plugin - self.results = all_results - elif not has_bypass: - # Global search - check if it's an application search - applications_plugin = self._get_applications_plugin() - is_application_search = applications_plugin and any( - hasattr(r, "plugin_name") and r.plugin_name == "Applications" - for r in all_results - ) - - if is_application_search: - # Don't limit application search results - self.results = all_results - else: - # Apply max_results limit for other global searches and trigger suggestions - self.results = all_results[: self.max_results] - else: - # Has bypass flag - show all results - self.results = all_results - self.selected_index = 0 - - # Update UI - self._update_results_display() - - return False # Don't repeat timeout - - def _extract_query_after_trigger(self, query: str, trigger: str) -> str: - """ - Extract the search query after removing the trigger. - - Args: - query: The full query string - trigger: The trigger keyword - - Returns: - The remaining query after the trigger - """ - if not query or not trigger: - return "" - - query_lower = query.lower() - trigger_lower = trigger.lower() - - # Handle trigger with space (e.g., "app ") - if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower): - return query[len(trigger) :].strip() - - # Handle trigger without space (e.g., "app") - trigger_word = trigger.strip().lower() - if query_lower.startswith(trigger_word): - # Check if it's followed by space or end of string - if len(query) == len(trigger_word): - return "" - elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ": - return query[len(trigger_word) + 1 :].strip() - elif len(query) > len(trigger_word): - # No space after trigger word, extract rest - return query[len(trigger_word) :].strip() - - return "" - - def _show_available_triggers(self): - """Show available triggers when launcher is first opened.""" - trigger_suggestions = self._get_trigger_suggestions("") - self.results = trigger_suggestions # Show all available triggers without limit - self.selected_index = 0 - self._update_results_display() - - def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]: - """ - Detect if query starts with a trigger keyword. - - Args: - query: The search query - - Returns: - Tuple of (plugin, trigger) if triggered, (None, "") otherwise - """ - if not query.strip(): - return None, "" - - # Check all plugins for triggers - for plugin in self.plugin_manager.get_active_plugins(): - trigger = plugin.get_active_trigger(query) - if trigger: - return plugin, trigger - - return None, "" - - def _get_applications_plugin(self): - """Get the applications plugin instance.""" - for plugin in self.plugin_manager.get_active_plugins(): - if ( - hasattr(plugin, "display_name") - and plugin.display_name == "Applications" - ): - return plugin - return None - - def _get_trigger_suggestions(self, query: str) -> List[Result]: - """ - Get trigger suggestions based on the current query. - - Args: - query: The search query - - Returns: - List of Result objects showing available triggers - """ - suggestions = [] - query_lower = query.lower().strip() - - # Get all available triggers from plugins - all_triggers = {} - for plugin in self.plugin_manager.get_active_plugins(): - triggers = plugin.get_triggers() - for trigger in triggers: - trigger_clean = trigger.strip() - if trigger_clean not in all_triggers: - all_triggers[trigger_clean] = { - "plugin": plugin, - "trigger": trigger, - "examples": [], - } - - # Get max examples to show from configuration - max_examples = self.trigger_config.settings.get("max_examples_shown", 2) - - # Show trigger suggestions based on query - if query_lower: - # Show triggers that match the query - for trigger_clean, _ in all_triggers.items(): - if trigger_clean.lower().startswith(query_lower): - result = self._create_trigger_result(trigger_clean, max_examples) - suggestions.append(result) - else: - # Empty query - show all available triggers - for trigger_clean, _ in all_triggers.items(): - result = self._create_trigger_result(trigger_clean, max_examples) - suggestions.append(result) - - return suggestions # Return all trigger suggestions without limit - - def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result: - """Create a Result object for a trigger suggestion.""" - examples = self.trigger_config.get_trigger_examples(trigger_clean) - icon_name = self.trigger_config.get_trigger_icon(trigger_clean) - description = self.trigger_config.get_trigger_description(trigger_clean) - - return Result( - title=f"{trigger_clean}", - subtitle=f"{description} - {', '.join(examples[:max_examples])}", - icon_name=icon_name, - action=lambda t=trigger_clean: self._activate_trigger(t), - # Shorter triggers get higher relevance - relevance=100 - len(trigger_clean), - data={"type": "trigger_suggestion", "trigger": trigger_clean}, - ) - - def _activate_trigger(self, trigger: str): - """ - Activate a trigger by setting it in the search entry. - - Args: - trigger: The trigger keyword to activate - """ - # Set the trigger text in the search entry - trigger_text = f"{trigger} " - self.search_entry.set_text(trigger_text) - self.search_entry.grab_focus() - - def clear_selection(): - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(-1) # Move caret to end - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region( - len(trigger_text), len(trigger_text) - ) # No selection - return False # Only run once - - GLib.idle_add(clear_selection) - - # Manually set the trigger mode to avoid search processing issues - triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) - if triggered_plugin: - self.triggered_plugin = triggered_plugin - self.active_trigger = detected_trigger - - # Clear results and show trigger ready state - self.results = [] - self.selected_index = 0 - self._update_results_display() - - # Focus back to the search entry for immediate typing - self.search_entry.grab_focus() - - # Don't hide the launcher - user should be able to continue typing - - def _execute_external_command(self, command_string: str): - """Execute a command externally without showing the launcher UI. - - Args: - command_string: Full command string (e.g., "wall random", "calc 2+2") - - Returns: - Result of the command execution or None if failed - """ - try: - # Parse the command to extract trigger and query - parts = command_string.strip().split(" ", 1) - if not parts: - return None - - trigger_part = parts[0] - query_part = parts[1] if len(parts) > 1 else "" - - # Find the plugin that handles this trigger - triggered_plugin = None - - for plugin in self.plugin_manager.get_active_plugins(): - trigger = plugin.get_active_trigger(f"{trigger_part} ") - if trigger: - triggered_plugin = plugin - break - - if not triggered_plugin: - print(f"No plugin found for trigger: {trigger_part}") - return None - - # Query the plugin with the remaining query - try: - results = triggered_plugin.query(query_part) - if not results: - print(f"No results found for query: {query_part}") - return None - - # Find the first result that matches the query exactly or has highest relevance - best_result = None - for result in results: - # For exact matches like "random", execute immediately - if ( - hasattr(result, "data") - and result.data - and result.data.get("action") == query_part.strip() - ): - best_result = result - break - # For partial matches, take the first high-relevance result - elif not best_result and result.relevance >= 0.9: - best_result = result - - # If no exact match, take the first result - if not best_result and results: - best_result = results[0] - - if best_result: - # Execute the result action - try: - result_value = best_result.activate() - print(f"External command executed: {command_string}") - return result_value - except Exception as e: - print(f"Error executing result action: {e}") - return None - else: - print(f"No suitable result found for: {command_string}") - return None - - except Exception as e: - print(f"Error querying plugin {triggered_plugin.name}: {e}") - return None - - except Exception as e: - print(f"Error executing external command '{command_string}': {e}") - return None - - def _update_results_display(self): - """Update the results display.""" - # Skip if still initializing or results_box not ready - if getattr(self, "_initializing", True) or not hasattr(self, "results_box"): - return - - # Update input field with trigger indication (Spotlight-style) - self._update_input_action_text() - - # Clear existing results - for child in self.results_box.get_children(): - self.results_box.remove(child) - - # Add new results - for i, result in enumerate(self.results): - # Check if this result has a custom widget - if result.custom_widget: - # Ensure the widget is not already parented - parent = result.custom_widget.get_parent() - if parent: - parent.remove(result.custom_widget) - - result.custom_widget.show_all() # Ensure widget is visible - self.results_box.add(result.custom_widget) - else: - # Create normal result item - result_item = ResultItem( - result=result, selected=(i == self.selected_index), index=i - ) - result_item.clicked.connect( - lambda _, idx=i: self._on_result_clicked(result_item, idx) - ) - result_item.hovered.connect( - lambda _, idx=i: self._on_result_hovered(idx) - ) - self.results_box.add(result_item) - - self.results_box.show_all() - - self.results_scroll.show() - - def _update_input_action_text(self): - """Update the input field with action text (Spotlight-style).""" - # Check if search_entry is initialized - if not hasattr(self, "search_entry") or self.search_entry is None: - return - - # Get the current input text - current_text = self.search_entry.get_text() - - # Spotlight-style placeholder text - if self.triggered_plugin: - if self.results: - # Show more minimal placeholder for triggered mode - self.search_entry.set_placeholder_text( - f"Search {self.active_trigger.strip()}..." - ) - else: - # In trigger mode but no results yet - if current_text == self.active_trigger.strip(): - # Just the trigger keyword - self.search_entry.set_placeholder_text( - f"Search {self.active_trigger.strip()}..." - ) - else: - # Searching within trigger - self.search_entry.set_placeholder_text( - f"Searching {self.active_trigger.strip()}..." - ) - else: - # Not in trigger mode - show Spotlight-style help - if current_text == ":": - self.search_entry.set_placeholder_text("Available search triggers") - else: - self.search_entry.set_placeholder_text("Spotlight Search") - - def _clear_results(self): - """Clear all results.""" - self.results = [] - self.selected_index = 0 - for child in self.results_box.get_children(): - self.results_box.remove(child) - # Keep the results scroll visible even when empty - - def _handle_escape_key(self) -> bool: - """Handle escape key press.""" - # First check if there's a password entry widget that should handle Escape - password_entry_widget = self._find_password_entry_widget() - if password_entry_widget: - # Cancel the password entry - password_entry_widget.cancel_password_entry() - return True - elif self.opened_with_trigger: - # If launcher was opened with a trigger keyword, close directly - self.close_launcher() - return True - else: - # Hide launcher - self.close_launcher() - return True - - def _handle_backspace_key(self) -> bool: - """Handle backspace key press in trigger mode.""" - if self.triggered_plugin: - trigger_text = self.active_trigger.strip() - current_text = self.search_entry.get_text() - - # Set flag to prevent search change handler from interfering - self._processing_backspace = True - - # Allow normal backspace behavior first, then check if we need to exit trigger mode - # Don't intercept the backspace - let GTK handle it normally - - # Schedule a check after the backspace is processed - def check_trigger_after_backspace(): - try: - # Get the text after backspace has been processed - new_text = self.search_entry.get_text() - - # Check if we should exit trigger mode - # We need to check if the text still matches the trigger pattern - should_exit_trigger = False - - # If the active trigger ends with a space (like "calc "), - # we should exit if the text doesn't contain that space anymore - if self.active_trigger.endswith(" "): - # For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc " - trigger_with_space = self.active_trigger.lower() - if not new_text.lower().startswith(trigger_with_space): - should_exit_trigger = True - else: - # For triggers without space, exit if text doesn't start with trigger - if not new_text.lower().startswith(trigger_text.lower()): - should_exit_trigger = True - - if should_exit_trigger: - self.triggered_plugin = None - self.active_trigger = "" - self._clear_results() - # Don't clear the text - let the user's edit stand - - # If we're still in trigger mode but the text changed, update the search - elif self.triggered_plugin and new_text != current_text: - # Trigger a search with the new text - self.query = new_text - GLib.timeout_add(50, self._perform_search, new_text) - - finally: - # Clear the backspace processing flag - self._processing_backspace = False - - return False # Don't repeat - - # Use idle_add to check after the backspace is processed - GLib.idle_add(check_trigger_after_backspace) - - # Allow the normal backspace to proceed - return False - - # Let normal backspace behavior continue for other cases - return False - - def _on_key_press(self, _widget, event): - """Handle key press events.""" - # Track keyboard activity - import time - - current_time = int(time.time() * 1000) - self._last_keyboard_activity = current_time - self._keyboard_used_recently = True - - keyval = event.keyval - - # Escape - handle password entry, exit trigger mode, or hide launcher - if keyval == Gdk.KEY_Escape: - return self._handle_escape_key() - - # Backspace - handle trigger mode backspace behavior - if keyval == Gdk.KEY_BackSpace: - return self._handle_backspace_key() - - # Up/Down - navigate results (alternative to Tab) - if keyval == Gdk.KEY_Up: - if self.focus_mode == "results" and self.results: - if self.selected_index > 0: - # Move to previous result - self.selected_index -= 1 - self._update_selection() - else: - # At first result, go back to search entry - self.focus_mode = "search" - self._focus_search_entry_without_selection() - elif self.results: - # If not in results mode but have results, enter results mode at last item - self.focus_mode = "results" - self.selected_index = len(self.results) - 1 - self._update_selection() - return True - - if keyval == Gdk.KEY_Down: - if self.focus_mode == "results" and self.results: - if self.selected_index < len(self.results) - 1: - # Move to next result - self.selected_index += 1 - self._update_selection() - else: - # At last result, wrap around to first result - self.selected_index = 0 - self._update_selection() - elif self.results: - # If not in results mode but have results, enter results mode at first item - self.focus_mode = "results" - self.selected_index = 0 - self._update_selection() - return True - - # Enter - activate selected result - if keyval == Gdk.KEY_Return: - # Check for Shift+Enter for alternative actions - if event.state & Gdk.ModifierType.SHIFT_MASK: - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.data: - # Check for generic alternative action first - if result.data.get("alt_action"): - result.data["alt_action"]() - return True - # Fallback to pin_action for backward compatibility - elif result.data.get("pin_action"): - result.data["pin_action"]() - return True - - # Check if the selected result has a custom widget with Entry fields - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget: - # Check if the custom widget contains Entry widgets that should handle Enter - if self._custom_widget_has_entry(result.custom_widget): - # Let the custom widget handle the Enter key - # Find the focused Entry widget and trigger its activate signal - focused_entry = self._find_focused_entry_in_widget( - result.custom_widget - ) - if focused_entry: - focused_entry.emit("activate") - return True - - # Normal Enter behavior - self._activate_selected() - return True - - # Tab - cycle through focus areas and results - if keyval == Gdk.KEY_Tab: - if event.state & Gdk.ModifierType.SHIFT_MASK: - # Shift+Tab - reverse direction - if self.focus_mode == "results": - # Navigate through results in reverse - self._navigate_results_backward() - else: - self._cycle_focus_backward() - else: - # Tab - forward direction - if self.focus_mode == "results": - # Navigate through results forward - self._navigate_results_forward() - else: - self._cycle_focus_forward() - return True - - # Page Up/Page Down - navigate results faster - if keyval == Gdk.KEY_Page_Up: - if self.results: - self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP) - self._update_selection() - return True - - if keyval == Gdk.KEY_Page_Down: - if self.results: - self.selected_index = min( - len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP - ) - self._update_selection() - return True - - # Home/End - go to first/last result - if keyval == Gdk.KEY_Home: - if self.results: - self.selected_index = 0 - self._update_selection() - return True - - if keyval == Gdk.KEY_End: - if self.results: - self.selected_index = len(self.results) - 1 - self._update_selection() - return True - - # Forward other keys to custom widgets if they can handle them - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget and hasattr(result.custom_widget, "on_key_press"): - # Try to forward the key event to the custom widget - if result.custom_widget.on_key_press(result.custom_widget, event): - return True - - return False - - def _custom_widget_has_entry(self, widget): - """Check if a custom widget contains Entry widgets.""" - - if isinstance(widget, Entry): - return True - - # Check children recursively - if hasattr(widget, "get_children"): - for child in widget.get_children(): - if self._custom_widget_has_entry(child): - return True - - return False - - def _find_focused_entry_in_widget(self, widget): - """Find the focused Entry widget within a custom widget.""" - - if isinstance(widget, Entry): - # Try multiple ways to check if this Entry is focused - try: - if widget.has_focus() or widget.is_focus(): - return widget - # Also check if this is the only Entry in the widget (likely to be the target) - return widget - except: - # If focus checking fails, assume this Entry should handle the event - return widget - - # Check children recursively - if hasattr(widget, "get_children"): - for child in widget.get_children(): - focused_entry = self._find_focused_entry_in_widget(child) - if focused_entry: - return focused_entry - - return None - - def _find_password_entry_widget(self): - """Find a NetworkPasswordEntry widget in the current results.""" - for result in self.results: - if result.custom_widget: - # Check if this is a NetworkPasswordEntry widget - if ( - hasattr(result.custom_widget, "__class__") - and result.custom_widget.__class__.__name__ - == "NetworkPasswordEntry" - ): - return result.custom_widget - # Also check if it has the cancel_password_entry method (duck typing) - elif hasattr(result.custom_widget, "cancel_password_entry"): - return result.custom_widget - return None - - def _on_entry_activate(self, _entry): - """Handle entry activation (Enter key).""" - self._activate_selected() - - def _on_result_clicked(self, _result_item, index): - """Handle result item click.""" - # Only allow clicks when mouse is active or keyboard hasn't been used recently - if self._should_allow_mouse_interaction(): - self.selected_index = index - self._activate_selected() - - def _on_result_hovered(self, index): - """Handle result item hover.""" - # Only allow hover selection when mouse is active and keyboard hasn't been used recently - if self._should_allow_mouse_interaction(): - if 0 <= index < len(self.results): - self.selected_index = index - self.focus_mode = "results" # Switch to results focus mode - self._update_selection_visual_only() - - def _on_mouse_activity(self, widget, event): - """Track mouse activity to determine when mouse interactions should be enabled.""" - import time - - current_time = int(time.time() * 1000) - self._last_mouse_activity = current_time - self._mouse_active = True - - # Schedule a check to disable mouse activity after timeout - def check_mouse_timeout(): - if current_time - self._last_mouse_activity >= self._mouse_activity_timeout: - self._mouse_active = False - return False - - GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout) - return False - - def _should_allow_mouse_interaction(self): - """Determine if mouse interactions should be allowed.""" - import time - - current_time = int(time.time() * 1000) - - # Don't allow mouse interactions immediately after launcher opens - if self._launcher_just_opened: - return False - - # Allow mouse interaction if: - # 1. Mouse is currently active (recent activity) - # 2. OR keyboard hasn't been used recently (2 seconds) - mouse_recently_active = ( - current_time - self._last_mouse_activity - ) < self._mouse_activity_timeout - keyboard_not_recently_used = ( - current_time - self._last_keyboard_activity - ) > self._keyboard_activity_timeout - - return mouse_recently_active or keyboard_not_recently_used - - def _is_mouse_over_results(self): - """Check if mouse is over the results area.""" - # Simple check - if we have results visible, assume user might be interacting - return len(self.results) > 0 and self.results_scroll.get_visible() - - def _update_selection(self): - """Update the visual selection of results with scrolling (for keyboard navigation).""" - children = self.results_box.get_children() - selected_widget = None - - for i, child in enumerate(children): - if isinstance(child, ResultItem): - is_selected = i == self.selected_index - child.set_selected(is_selected) - if is_selected: - selected_widget = child - # For custom widgets, we don't need to handle selection visually - # since they manage their own interaction - - # Focus custom widgets when selected for keyboard interaction - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget and result.custom_widget.get_can_focus(): - # Give focus to the custom widget for keyboard interaction - result.custom_widget.grab_focus() - - # Scroll to make the selected item visible - if selected_widget and self.results_scroll.get_visible(): - # Use immediate scrolling for better responsiveness - self._scroll_to_widget(selected_widget) - # Also schedule a more accurate scroll after layout is complete - GLib.idle_add(self._ensure_selected_visible) - - def _update_selection_visual_only(self): - """Update the visual selection of results without scrolling (for mouse hover).""" - children = self.results_box.get_children() - - for i, child in enumerate(children): - if isinstance(child, ResultItem): - is_selected = i == self.selected_index - child.set_selected(is_selected) - # For custom widgets, we don't need to handle selection visually - # since they manage their own interaction - - # Focus custom widgets when selected for keyboard interaction - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - if result.custom_widget and result.custom_widget.get_can_focus(): - # Give focus to the custom widget for keyboard interaction - result.custom_widget.grab_focus() - - def _scroll_to_widget(self, widget): - """Scroll the results container to make the widget visible.""" - if not widget or not self.results_scroll.get_visible(): - return - - # Debug output (can be removed in production) - # print(f"Scrolling to selected index: {self.selected_index}") - - # Get the scrolled window's vertical adjustment - vadjustment = self.results_scroll.get_vadjustment() - if not vadjustment: - return - - # Use a simpler approach: scroll to the selected item index - if self.results and 0 <= self.selected_index < len(self.results): - # Get all children to work with actual widgets - children = self.results_box.get_children() - if not children or self.selected_index >= len(children): - return - - # Get the selected child widget - selected_child = children[self.selected_index] - - # Try to get actual allocation, fallback to estimation - try: - allocation = selected_child.get_allocation() - item_height = allocation.height if allocation.height > 0 else 68 - item_y = allocation.y - except: - # Fallback to estimation - item_height = DEFAULT_ITEM_HEIGHT - item_y = self.selected_index * item_height - - # Get current scroll info - current_scroll = vadjustment.get_value() - page_size = vadjustment.get_page_size() - max_scroll = vadjustment.get_upper() - page_size - - # Calculate visible area - visible_top = current_scroll - visible_bottom = current_scroll + page_size - - # Check if selected item is visible - item_top = item_y - item_bottom = item_y + item_height - - # Add some padding for better visibility - # Scroll if needed - if item_top < visible_top + SCROLL_PADDING: - # Item is above visible area - scroll up - new_scroll = max(0, item_top - SCROLL_PADDING) - vadjustment.set_value(new_scroll) - elif item_bottom > visible_bottom - SCROLL_PADDING: - # Item is below visible area - scroll down - new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING) - vadjustment.set_value(new_scroll) - - def _ensure_selected_visible(self): - """Alternative method to ensure selected item is visible using GTK methods.""" - if ( - not self.results - or self.selected_index < 0 - or self.selected_index >= len(self.results) - ): - return False - - children = self.results_box.get_children() - if self.selected_index < len(children): - selected_child = children[self.selected_index] - - # Try to use widget's allocation for more accurate scrolling - try: - allocation = selected_child.get_allocation() - if allocation.height > 0: - vadjustment = self.results_scroll.get_vadjustment() - if vadjustment: - # Calculate the position to center the selected item - page_size = vadjustment.get_page_size() - target_pos = ( - allocation.y - (page_size / 2) + (allocation.height / 2) - ) - target_pos = max( - 0, min(target_pos, vadjustment.get_upper() - page_size) - ) - vadjustment.set_value(target_pos) - except Exception as e: - print(f"Error in _ensure_selected_visible: {e}") - - return False # Don't repeat the idle callback - - def _cycle_focus_forward(self): - """Cycle focus forward: search -> results""" - if self.focus_mode == "search": - if self.results: - self.focus_mode = "results" - self._update_selection() - - def _cycle_focus_backward(self): - """Cycle focus backward: results -> search""" - if self.focus_mode == "results": - self.focus_mode = "search" - self._focus_search_entry_without_selection() - - def _focus_search_entry_without_selection(self): - """Focus search entry and position cursor at end without selecting text.""" - # First grab focus - self.search_entry.grab_focus() - - # Use multiple approaches to prevent text selection - def clear_selection(): - try: - text_length = len(self.search_entry.get_text()) - - # Method 1: Set position and clear selection - if hasattr(self.search_entry, "set_position"): - self.search_entry.set_position(text_length) - if hasattr(self.search_entry, "select_region"): - self.search_entry.select_region(text_length, text_length) - - # Method 2: Try to access underlying GTK widget - try: - # For fabric Entry widgets, try to get the actual GTK Entry - if hasattr(self.search_entry, "_entry"): - gtk_entry = self.search_entry._entry - elif ( - hasattr(self.search_entry, "get_children") - and self.search_entry.get_children() - ): - gtk_entry = self.search_entry.get_children()[0] - else: - gtk_entry = self.search_entry - - if hasattr(gtk_entry, "set_position"): - gtk_entry.set_position(text_length) - if hasattr(gtk_entry, "select_region"): - gtk_entry.select_region(text_length, text_length) - - except Exception: - pass - - except Exception as e: - print(f"Could not clear selection: {e}") - return False - - # Schedule clearing selection after focus is established - GLib.idle_add(clear_selection) - # Also try with a small delay as backup - GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection) - - def _navigate_results_forward(self): - """Navigate to next result or wrap around to first result.""" - if self.results and self.selected_index < len(self.results) - 1: - # Move to next result - self.selected_index += 1 - self._update_selection() - else: - # At last result, wrap around to first result - self.selected_index = 0 - self._update_selection() - - def _navigate_results_backward(self): - """Navigate to previous result or exit results mode.""" - if self.results and self.selected_index > 0: - # Move to previous result - self.selected_index -= 1 - self._update_selection() - else: - # Exit results mode and go to search - self.focus_mode = "search" - self._focus_search_entry_without_selection() - - def _activate_selected(self): - """Activate the currently selected result.""" - if self.results and 0 <= self.selected_index < len(self.results): - result = self.results[self.selected_index] - try: - # Check if this result has a custom widget - if result.custom_widget: - # For custom widgets, we don't activate them since they're already displayed - # The widget handles its own interactions - return - - # Check if this is a trigger suggestion or should keep launcher open - is_trigger_suggestion = ( - result.data and result.data.get("type") == "trigger_suggestion" - ) - keep_launcher_open = result.data and result.data.get( - "keep_launcher_open", False - ) - - # Activate the result - result.activate() - - # Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag - if not is_trigger_suggestion and not keep_launcher_open: - self.close_launcher() - # For trigger suggestions and keep_launcher_open actions, the launcher stays open - except Exception as e: - print(f"Error activating result: {e}") diff --git a/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py deleted file mode 100644 index 13bd1fcd..00000000 --- a/modules/launcher/plugin_base.py +++ /dev/null @@ -1,172 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List - -from modules.launcher.result import Result - - -class PluginBase(ABC): - """ - Abstract base class for launcher plugins. - All plugins must inherit from this class. - """ - - def __init__(self): - self.name = self.__class__.__name__.lower() - self.display_name = self.__class__.__name__ - self.description = "A launcher plugin" - self.version = "1.0.0" - self.enabled = True - self._triggers = [] # List of trigger keywords - - @abstractmethod - def initialize(self): - """ - Initialize the plugin. - Called when the plugin is activated. - """ - pass - - @abstractmethod - def cleanup(self): - """ - Cleanup the plugin. - Called when the plugin is deactivated. - """ - pass - - @abstractmethod - def query(self, query_string: str) -> List[Result]: - """ - Process a search query and return results. - - Args: - query_string: The search query from the user - - Returns: - List of Result objects - """ - pass - - def get_triggers(self) -> List[str]: - """ - Get list of trigger keywords for this plugin. - If the query starts with any of these, this plugin gets priority. - - Returns: - List of trigger strings (e.g., ["calc", "=", "math"]) - """ - return self._triggers - - def set_triggers(self, triggers: List[str]): - """ - Set the trigger keywords for this plugin. - - Args: - triggers: List of trigger keywords - """ - self._triggers = triggers - - def handles_query(self, query_string: str) -> bool: - """ - Check if this plugin should handle the given query. - - Args: - query_string: The search query - - Returns: - True if this plugin should process the query - """ - if not self.enabled: - return False - - # Check triggers - triggers = self.get_triggers() - if triggers: - query_lower = query_string.lower().strip() - return any(query_lower.startswith(trigger.lower()) for trigger in triggers) - - # Default: handle all queries - return True - - def get_active_trigger(self, query_string: str) -> str: - """ - Get the active trigger for the given query. - - Args: - query_string: The search query - - Returns: - The trigger keyword if found, empty string otherwise - """ - if not self.enabled: - return "" - - triggers = self.get_triggers() - if triggers: - query_lower = query_string.lower().strip() - - # Sort triggers by length (longest first) to match more specific triggers first - sorted_triggers = sorted(triggers, key=len, reverse=True) - - for trigger in sorted_triggers: - trigger_lower = trigger.lower() - - # Exact match with trigger (including space if present) - if query_lower.startswith(trigger_lower): - return trigger - - # Match trigger word followed by space - trigger_word = trigger.strip().lower() - if ( - query_lower.startswith(trigger_word + " ") - or query_lower == trigger_word - ): - return trigger - - return "" - - def query_triggered(self, query_string: str, trigger: str) -> List[Result]: - """ - Process a triggered query (when plugin is in sticky mode). - Default implementation removes trigger and calls query(). - - Args: - query_string: The full query string including trigger - trigger: The trigger that activated this plugin - - Returns: - List of Result objects - """ - # Remove trigger from query and process remaining text - remaining_query = query_string[len(trigger) :].strip() - return self.query(remaining_query) - - def get_config(self) -> dict: - """ - Get plugin configuration. - - Returns: - Dictionary of configuration options - """ - return { - "name": self.name, - "display_name": self.display_name, - "description": self.description, - "version": self.version, - "enabled": self.enabled, - } - - def set_config(self, config: dict): - """ - Set plugin configuration. - - Args: - config: Dictionary of configuration options - """ - self.enabled = config.get("enabled", self.enabled) - - def __str__(self): - return f"Plugin({self.name})" - - def __repr__(self): - return self.__str__() diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py deleted file mode 100644 index caf002e9..00000000 --- a/modules/launcher/plugin_manager.py +++ /dev/null @@ -1,183 +0,0 @@ -import importlib -import importlib.util -import os -from typing import Dict, List, Type - -from modules.launcher.plugin_base import PluginBase - - -class PluginManager: - """ - Manages launcher plugins. - """ - - def __init__(self): - self.plugins: Dict[str, PluginBase] = {} - self.plugin_classes: Dict[str, Type[PluginBase]] = {} - self.active_plugins: List[str] = [] - - # Load built-in plugins - self._load_builtin_plugins() - - # Load external plugins - self._load_external_plugins() - - # Activate default plugins - self._activate_default_plugins() - - def _load_builtin_plugins(self): - """Load built-in plugins from the plugins directory.""" - plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") - - if not os.path.exists(plugins_dir): - return - - for filename in os.listdir(plugins_dir): - if filename.endswith(".py") and not filename.startswith("_"): - plugin_name = filename[:-3] # Remove .py extension - self._load_plugin_from_file(plugins_dir, plugin_name) - - def _load_external_plugins(self): - """Load external plugins from user directory.""" - # Could be implemented to load from ~/.config/launcher/plugins/ - pass - - def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str): - """Load a plugin from a Python file.""" - try: - plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py") - spec = importlib.util.spec_from_file_location( - f"modules.launcher.plugins.{plugin_name}", plugin_path - ) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - - # Add the module to sys.modules to support relative imports - import sys - - sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module - - spec.loader.exec_module(module) - - # Look for plugin class - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - isinstance(attr, type) - and issubclass(attr, PluginBase) - and attr != PluginBase - ): - self.plugin_classes[plugin_name] = attr - break - - except Exception as e: - print(f"Failed to load plugin {plugin_name}: {e}") - - def _activate_default_plugins(self): - """Activate default plugins.""" - default_plugins = [ - "applications", - "calculator", - "system", - "clipboard", - "power", - "caffeine", - "screencapture", - "emoji", - "wallpaper", - "websearch", - "reminders", - "otp", - "password", - "bookmarks", - "bash_scripts", - "tmux", - ] - - for plugin_name in default_plugins: - self.activate_plugin(plugin_name) - - def activate_plugin(self, plugin_name: str) -> bool: - """Activate a plugin by name.""" - if plugin_name in self.plugins: - # Already activated - return True - - if plugin_name not in self.plugin_classes: - return False - - try: - # Instantiate plugin - plugin_class = self.plugin_classes[plugin_name] - plugin_instance = plugin_class() - - # Initialize plugin - plugin_instance.initialize() - - # Store plugin - self.plugins[plugin_name] = plugin_instance - self.active_plugins.append(plugin_name) - - return True - - except Exception as e: - print(f"Failed to activate plugin {plugin_name}: {e}") - return False - - def deactivate_plugin(self, plugin_name: str) -> bool: - """Deactivate a plugin by name.""" - if plugin_name not in self.plugins: - return False - - try: - # Cleanup plugin - plugin = self.plugins[plugin_name] - plugin.cleanup() - - # Remove from active plugins - del self.plugins[plugin_name] - if plugin_name in self.active_plugins: - self.active_plugins.remove(plugin_name) - - return True - - except Exception as e: - print(f"Failed to deactivate plugin {plugin_name}: {e}") - return False - - def get_active_plugins(self) -> List[PluginBase]: - """Get list of active plugin instances.""" - return [ - self.plugins[name] for name in self.active_plugins if name in self.plugins - ] - - def get_plugin_names(self) -> List[str]: - """Get list of available plugin names.""" - return list(self.plugin_classes.keys()) - - def get_active_plugin_names(self) -> List[str]: - """Get list of active plugin names.""" - return self.active_plugins.copy() - - def reload_plugin(self, plugin_name: str) -> bool: - """Reload a plugin.""" - was_active = plugin_name in self.active_plugins - - # Deactivate if active - if was_active: - self.deactivate_plugin(plugin_name) - - # Remove from classes - if plugin_name in self.plugin_classes: - del self.plugin_classes[plugin_name] - - # Reload from file - plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") - self._load_plugin_from_file(plugins_dir, plugin_name) - - # Reactivate if it was active - if was_active and plugin_name in self.plugin_classes: - return self.activate_plugin(plugin_name) - - return plugin_name in self.plugin_classes diff --git a/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py deleted file mode 100644 index 666274b8..00000000 --- a/modules/launcher/plugins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Built-in plugins for the launcher. -""" diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py deleted file mode 100644 index 832d75b8..00000000 --- a/modules/launcher/plugins/applications.py +++ /dev/null @@ -1,185 +0,0 @@ -import json -import re -from typing import List -import subprocess - -from fabric.utils import DesktopApp -from fabric.utils.helpers import get_desktop_applications, get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from utils.roam import modus_service - - -class ApplicationsPlugin(PluginBase): - def __init__(self): - super().__init__() - self.display_name = "Applications" - self.description = "Search and launch desktop applications" - - def initialize(self): - pass - - def cleanup(self): - pass - - def _pin_application(self, app): - """Pin an application to the dock.""" - config_path = get_relative_path("../../../config/assets/dock.json") - try: - with open(config_path, "r") as file: - pinned_apps = json.load(file) - except (FileNotFoundError, json.JSONDecodeError): - pinned_apps = [] - - # Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list) - if isinstance(pinned_apps, dict): - pinned_apps = pinned_apps.get("pinned_apps", []) - - # Check if app is already pinned (by name/app_id) - app_id = app.name # Use app.name as the identifier - if app_id not in pinned_apps: - pinned_apps.append(app_id) - - # Save the updated list - with open(config_path, "w") as file: - json.dump(pinned_apps, file, indent=4) - - # Notify dock about the change via modus_service - if modus_service: - try: - dock_apps_json = json.dumps(pinned_apps) - modus_service.dock_apps = dock_apps_json - except Exception as e: - print(f"Failed to notify dock about pinned app change: {e}") - - def query(self, query_string: str) -> List[Result]: - """Search applications based on query.""" - if not query_string.strip(): - return self._get_all_applications() - - try: - applications = get_desktop_applications(include_hidden=False) - except Exception as e: - print(f"Failed to load applications: {e}") - applications = [] - - query = query_string.lower().strip() - results = [] - - for app in applications: - relevance = self._calculate_relevance(app, query) - if relevance > 0: - description = app.description or app.generic_name or "" - if len(description) > 80: - description = description[:70] + "..." - - result = Result( - title=app.display_name or app.name, - subtitle=description, - icon=app.get_icon_pixbuf(size=48), - action=lambda a=app: self._launch_application(a), - relevance=relevance, - plugin_name=self.display_name, - data={ - "app": app, - "pin_action": lambda a=app: self._pin_application(a), - }, - ) - results.append(result) - - return results - - def _calculate_relevance(self, app, query: str) -> float: - """Calculate relevance score for an application.""" - if not query: - return 0.0 - - # Get searchable text - name = (app.name or "").lower() - display_name = (app.display_name or "").lower() - description = (app.description or "").lower() - generic_name = (app.generic_name or "").lower() - executable = (app.executable or "").lower() - - # Exact matches get highest score - if query == name or query == display_name: - return 1.0 - - # Starts with matches get high score - if ( - name.startswith(query) - or display_name.startswith(query) - or generic_name.startswith(query) - ): - return 0.9 - - # Contains matches get medium score - if ( - query in name - or query in display_name - or query in description - or query in generic_name - ): - return 0.7 - - # Fuzzy matching for partial matches - if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name): - return 0.5 - - # Executable name matching - if executable and query in executable: - return 0.4 - - return 0.0 - - def _fuzzy_match(self, query: str, text: str) -> bool: - """Simple fuzzy matching algorithm.""" - if not query or not text: - return False - - # Create regex pattern for fuzzy matching - pattern = ".*".join(re.escape(char) for char in query) - return bool(re.search(pattern, text, re.IGNORECASE)) - - def _launch_application(self, app: DesktopApp): - - # Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.) - cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() - - # Final command with hyprctl dispatch - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - - # app.launch() - - def _get_all_applications(self) -> List[Result]: - """Get a list of all available applications.""" - try: - applications = get_desktop_applications(include_hidden=False) - except Exception as e: - print(f"Failed to load applications: {e}") - return [] - - results = [] - - for app in applications: - # Truncate description - description = app.description or app.generic_name or "" - if len(description) > 80: - description = description[:70] + "..." - - result = Result( - title=app.display_name or app.name, - subtitle=description, - icon=app.get_icon_pixbuf(size=48), - action=lambda a=app: self._launch_application(a), - relevance=0.5, # Default relevance for all apps - plugin_name=self.display_name, - data={ - "app": app, - "pin_action": lambda a=app: self._pin_application(a), - }, - ) - results.append(result) - - return results diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py deleted file mode 100644 index 44bb2e80..00000000 --- a/modules/launcher/plugins/bash_scripts.py +++ /dev/null @@ -1,431 +0,0 @@ -import json -import os -import threading -import time -from typing import Dict, List - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class BashScriptsPlugin(PluginBase): - """ - Plugin for managing and executing bash scripts. - """ - - def __init__(self): - super().__init__() - self.display_name = "Bash Scripts" - self.description = "Manage and execute bash scripts" - - # Configuration - self.scripts_cache_file = os.path.join(data.CACHE_DIR, "bash_scripts.json") - - # Default script directory to scan (only Modus scripts) - self.modus_scripts_dir = os.path.expanduser("~/.config/Modus/scripts") - - # Scripts to exclude from discovery - self.excluded_scripts = { - "screen-capture.sh" # Exclude screen-capture.sh as it's handled by screencapture plugin - } - - # In-memory cache - self._scripts_cache: Dict[str, Dict] = {} - self._last_cache_update = 0 - self._cache_update_interval = 300 # 5 minutes - - # Background cache building - self._cache_building = False - self._cache_thread = None - - def initialize(self): - """Initialize the bash scripts plugin.""" - self.set_triggers(["sh"]) - self._load_scripts_cache() - self._start_background_cache_update() - - def cleanup(self): - """Cleanup the bash scripts plugin.""" - self._scripts_cache.clear() - if self._cache_thread and self._cache_thread.is_alive(): - # Note: We don't join the thread to avoid blocking cleanup - pass - - def _load_scripts_cache(self): - """Load scripts cache from JSON file.""" - try: - if os.path.exists(self.scripts_cache_file): - with open(self.scripts_cache_file, "r", encoding="utf-8") as f: - cache_data = json.load(f) - self._scripts_cache = cache_data.get("scripts", {}) - self._last_cache_update = cache_data.get("last_update", 0) - else: - print( - "BashScriptsPlugin: No cache file found, will build cache in background" - ) - except Exception as e: - print(f"BashScriptsPlugin: Error loading scripts cache: {e}") - self._scripts_cache = {} - self._last_cache_update = 0 - - def _save_scripts_cache(self): - """Save scripts cache to JSON file.""" - try: - os.makedirs(data.CACHE_DIR, exist_ok=True) - cache_data = { - "scripts": self._scripts_cache, - "last_update": self._last_cache_update, - } - with open(self.scripts_cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - except Exception as e: - print(f"BashScriptsPlugin: Error saving scripts cache: {e}") - - def _start_background_cache_update(self): - """Start background thread to update scripts cache.""" - current_time = time.time() - - # Check if cache needs updating - if ( - current_time - self._last_cache_update > self._cache_update_interval - or not self._scripts_cache - ): - if not self._cache_building: - self._cache_building = True - self._cache_thread = threading.Thread( - target=self._build_scripts_cache_background, daemon=True - ) - self._cache_thread.start() - - def _build_scripts_cache_background(self): - """Build scripts cache in background thread.""" - try: - new_cache = {} - - # Scan Modus scripts directory for discovered scripts - if os.path.exists(self.modus_scripts_dir) and os.path.isdir( - self.modus_scripts_dir - ): - try: - self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache) - except (PermissionError, FileNotFoundError, OSError) as e: - print( - f"BashScriptsPlugin: Error scanning Modus scripts directory: { - e - }" - ) - - # Update cache atomically - self._scripts_cache = new_cache - self._last_cache_update = time.time() - - # Save to disk - self._save_scripts_cache() - - except Exception as e: - print(f"BashScriptsPlugin: Error building scripts cache: {e}") - finally: - self._cache_building = False - - def _scan_directory_for_scripts(self, directory: str, cache: Dict): - """Scan a directory for bash scripts and add them to cache.""" - try: - with os.scandir(directory) as entries: - for entry in entries: - if entry.is_file(follow_symlinks=False): - script_path = entry.path - script_name = entry.name - - # Skip excluded scripts - if script_name in self.excluded_scripts: - continue - - # Check if it's a script file - if self._is_script_file(script_path): - cache[script_name] = { - "path": script_path, - "name": script_name, - "description": self._get_script_description( - script_path - ), - "type": "discovered", - "executable": os.access(script_path, os.X_OK), - "args": [], - "category": os.path.basename(directory), - } - - except (PermissionError, FileNotFoundError, OSError) as e: - print(f"BashScriptsPlugin: Error scanning directory {directory}: {e}") - except Exception as e: - print(f"BashScriptsPlugin: Unexpected error scanning {directory}: {e}") - - def _is_script_file(self, file_path: str) -> bool: - """Check if a file is a bash script.""" - try: - # Check file extension first (most common case) - if file_path.endswith((".sh", ".bash")): - return True - - # For files without extension, check shebang - try: - with open(file_path, "rb") as f: - first_line = f.readline(100).decode("utf-8", errors="ignore") - if first_line.startswith("#!") and ( - "bash" in first_line or "sh" in first_line - ): - return True - except (PermissionError, FileNotFoundError, UnicodeDecodeError): - pass - - return False - except Exception: - return False - - def _get_script_description(self, script_path: str) -> str: - """Extract description from script comments.""" - try: - with open(script_path, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines() - - # Look for description in first few comment lines - for line in lines[:10]: - line = line.strip() - if line.startswith("#") and not line.startswith("#!"): - # Remove leading # and whitespace - desc = line[1:].strip() - if desc and len(desc) > 5: # Meaningful description - return desc - - return f"Script: {os.path.basename(script_path)}" - except (PermissionError, FileNotFoundError, UnicodeDecodeError): - return f"Script: {os.path.basename(script_path)}" - - def query(self, query_string: str) -> List[Result]: - """Search for bash scripts matching the query.""" - query = query_string.strip() - - # Start background update if needed (non-blocking) - if not self._scripts_cache or ( - time.time() - self._last_cache_update > self._cache_update_interval - ): - self._start_background_cache_update() - - results = [] - - # Handle special commands - if not query: - # Show all scripts when no query - results.extend(self._list_all_scripts()) - else: - # Search for scripts - results.extend(self._search_scripts(query)) - - return results - - def _list_all_scripts(self) -> List[Result]: - """List all available scripts.""" - results = [] - max_results = 20 - - # Sort scripts by name for consistent ordering - sorted_scripts = sorted( - self._scripts_cache.items(), key=lambda x: x[1].get("name", "") - ) - - # Add scripts (limit to max_results) - for script_name, script_info in sorted_scripts: - script_results = self._create_script_results_with_args( - script_name, script_info, 0.8 - ) - for script_result in script_results: - if len(results) < max_results: - results.append(script_result) - else: - break - if len(results) >= max_results: - break - - return results - - def _search_scripts(self, query: str) -> List[Result]: - """Search for scripts matching the query.""" - results = [] - query_lower = query.lower() - max_results = 15 - - # Categorize matches for better sorting - exact_matches = [] - prefix_matches = [] - partial_matches = [] - description_matches = [] - - for script_name, script_info in self._scripts_cache.items(): - script_name_lower = script_name.lower() - description_lower = script_info.get("description", "").lower() - - # Skip if no match at all - if ( - query_lower not in script_name_lower - and query_lower not in description_lower - ): - continue - - # Categorize matches - if script_name_lower == query_lower: - exact_matches.append((script_name, script_info, 1.0)) - elif script_name_lower.startswith(query_lower): - prefix_matches.append((script_name, script_info, 0.9)) - elif query_lower in script_name_lower: - partial_matches.append((script_name, script_info, 0.7)) - elif query_lower in description_lower: - description_matches.append((script_name, script_info, 0.5)) - - # Combine results in priority order - all_matches = ( - exact_matches + prefix_matches + partial_matches + description_matches - ) - - # Convert to Result objects - for script_name, script_info, relevance in all_matches: - script_results = self._create_script_results_with_args( - script_name, script_info, relevance - ) - for script_result in script_results: - if len(results) < max_results: - results.append(script_result) - else: - break - if len(results) >= max_results: - break - - return results - - def _create_script_result( - self, script_name: str, script_info: Dict, relevance: float - ) -> Result: - """Create a Result object for a script.""" - script_path = script_info.get("path", "") - description = script_info.get("description", "") - script_type = script_info.get("type", "discovered") - executable = script_info.get("executable", False) - category = script_info.get("category", "") - - # Create subtitle with additional info - subtitle_parts = [] - if description: - subtitle_parts.append(description) - if category: - subtitle_parts.append(f"[{category}]") - if not executable: - subtitle_parts.append("(not executable)") - - subtitle = ( - " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}" - ) - - # Choose icon based on script type and status - if not executable: - icon_name = "gtk-file" - elif script_type == "custom": - icon_name = "folder-script-symbolic" - else: - icon_name = "terminalc" - - return Result( - title=script_name, - subtitle=subtitle, - icon_name=icon_name, - action=self._create_script_action(script_name, script_info), - relevance=relevance, - plugin_name=self.display_name, - data={ - "script_name": script_name, - "script_path": script_path, - "type": script_type, - }, - ) - - def _create_script_results_with_args( - self, script_name: str, script_info: Dict, relevance: float - ) -> List[Result]: - """Create multiple Result objects for scripts that support arguments.""" - results = [] - - # Check for special scripts that need argument variants - if script_name == "hyprpicker.sh": - # For hyprpicker, only show the argument variants (skip basic version) - variants = [ - ("-rgb", "Pick RGB color"), - ("-hex", "Pick HEX color"), - ("-hsv", "Pick HSV color"), - ] - - for arg, desc in variants: - variant_result = Result( - title=f"{script_name} {arg}", - subtitle=f"{desc} | [scripts]", - icon_name="terminal-symbolic", - action=self._create_script_action_with_args( - script_name, script_info, [arg] - ), - relevance=relevance - + 0.1, # Slightly higher relevance for specific variants - plugin_name=self.display_name, - data={ - "script_name": script_name, - "script_path": script_info.get("path", ""), - "type": script_info.get("type", "discovered"), - "args": [arg], - }, - ) - results.append(variant_result) - else: - # For other scripts, create the basic result - basic_result = self._create_script_result( - script_name, script_info, relevance - ) - results.append(basic_result) - - return results - - def _create_script_action(self, script_name: str, script_info: Dict): - """Create an action function for executing a script.""" - - def action(): - self._execute_script(script_name, script_info) - - return action - - def _create_script_action_with_args( - self, script_name: str, script_info: Dict, args: List[str] - ): - """Create an action function for executing a script with specific arguments.""" - - def action(): - # Create a modified script_info with the specific arguments - modified_script_info = script_info.copy() - modified_script_info["args"] = args - self._execute_script(script_name, modified_script_info) - - return action - - def _execute_script(self, script_name: str, script_info: Dict): - """Execute a bash script.""" - try: - script_path = script_info.get("path", "") - script_args = script_info.get("args", []) - - if not os.path.exists(script_path): - return - - if not script_info.get("executable", False): - return - - # Build command - command = [script_path] + script_args - exec_shell_command_async(command) - - except Exception as e: - print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}") diff --git a/modules/launcher/plugins/bookmarks.py b/modules/launcher/plugins/bookmarks.py deleted file mode 100644 index ee551398..00000000 --- a/modules/launcher/plugins/bookmarks.py +++ /dev/null @@ -1,786 +0,0 @@ -import json -import subprocess -import threading -import time -from pathlib import Path -from typing import Dict, List, Optional -from urllib.parse import urlparse - -from thefuzz import fuzz - -from fabric.utils.helpers import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class BookmarkManager: - """Manages user's custom bookmarks.""" - - def __init__(self, storage_file: Path): - self.storage_file = storage_file - self.bookmarks: List[Dict] = [] - self.cache_lock = threading.Lock() - self.last_loaded = 0 - self.cache_ttl = 30 # Cache for 30 seconds - self._load_bookmarks() - - def _get_favicon_url(self, url: str) -> str: - """Generate favicon URL for a given website URL.""" - try: - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" - except: - return "" - - def _extract_domain(self, url: str) -> str: - """Extract domain from URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove www. prefix - if domain.startswith("www."): - domain = domain[4:] - return domain - except: - return url - - def _normalize_url(self, url: str) -> str: - """Normalize URL by adding protocol if missing.""" - url = url.strip() - if not url.startswith(("http://", "https://")): - if url.startswith("www."): - url = "https://" + url - else: - url = "https://" + url - return url - - def _load_bookmarks(self): - """Load bookmarks from JSON file with caching.""" - with self.cache_lock: - current_time = time.time() - - # Check if cache is still valid - if ( - current_time - self.last_loaded - ) < self.cache_ttl and self.last_loaded > 0: - return - - try: - if self.storage_file.exists(): - with open(self.storage_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.bookmarks = data.get("bookmarks", []) - else: - # File doesn't exist, start with empty list but don't save yet - self.bookmarks = [] - - self.last_loaded = current_time - except Exception as e: - print(f"Error loading bookmarks: {e}") - self.bookmarks = [] - - def get_bookmarks(self) -> List[Dict]: - """Get bookmarks, loading from file if needed.""" - self._load_bookmarks() - return self.bookmarks - - def _save_bookmarks_unlocked(self): - """Save bookmarks to JSON file without acquiring lock.""" - try: - self.storage_file.parent.mkdir(parents=True, exist_ok=True) - data = { - "bookmarks": self.bookmarks, - "last_updated": time.time(), - } - with open(self.storage_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # Update cache timestamp - self.last_loaded = time.time() - except Exception as e: - print(f"Error saving bookmarks: {e}") - - def _save_bookmarks(self): - """Save bookmarks to JSON file.""" - with self.cache_lock: - self._save_bookmarks_unlocked() - - def add_bookmark( - self, title: str, url: str, description: str = "", tags: List[str] = None - ) -> bool: - """Add a new bookmark.""" - try: - url = self._normalize_url(url) - - # Check if bookmark already exists - for bookmark in self.bookmarks: - if bookmark["url"] == url: - return False # Already exists - - new_bookmark = { - "title": title.strip(), - "url": url, - "description": description.strip(), - "tags": tags or [], - "created": time.time(), - "accessed": 0, - } - - self.bookmarks.append(new_bookmark) - self._save_bookmarks() - - # Clear cache to force reload - self.last_loaded = 0 - - return True - - except Exception as e: - print(f"Error adding bookmark: {e}") - return False - - def remove_bookmark(self, identifier: str) -> bool: - """Remove a bookmark by title or URL.""" - try: - identifier = identifier.lower().strip() - - for i, bookmark in enumerate(self.bookmarks): - if ( - bookmark["title"].lower() == identifier - or bookmark["url"].lower() == identifier - or self._extract_domain(bookmark["url"]).lower() == identifier - ): - self.bookmarks.pop(i) - self._save_bookmarks() - - # Clear cache to force reload - self.last_loaded = 0 - - return True - - return False - - except Exception as e: - print(f"Error removing bookmark: {e}") - return False - - def update_access_time(self, url: str): - """Update the last accessed time for a bookmark.""" - try: - for bookmark in self.bookmarks: - if bookmark["url"] == url: - bookmark["accessed"] = time.time() - self._save_bookmarks() - break - except Exception as e: - print(f"Error updating access time: {e}") - - def get_bookmark_count(self) -> int: - """Get total number of bookmarks.""" - return len(self.get_bookmarks()) - - -class BookmarksPlugin(PluginBase): - """ - User bookmarks plugin for the launcher. - Allows users to add, remove, and search their own bookmarks. - """ - - def __init__(self): - super().__init__() - self.display_name = "Bookmarks" - self.description = "Manage and search your personal bookmarks" - - # Initialize bookmark manager with storage file - self.bookmark_file = Path( - get_relative_path("../../../config/assets/bookmarks.json") - ) - self.bookmark_manager = BookmarkManager(self.bookmark_file) - self.max_results = 15 - - # Cache for results - self._results_cache = {} - self._cache_timestamps = {} - self._cache_ttl = 30 # 30 seconds - - # Launcher instance for refreshing - self._launcher_instance = None - self._original_close_launcher = None - - def initialize(self): - """Initialize the bookmarks plugin.""" - self.set_triggers(["bm"]) - self._setup_launcher_hooks() - - def cleanup(self): - """Cleanup the bookmarks plugin.""" - self._results_cache.clear() - self._cache_timestamps.clear() - self._cleanup_launcher_hooks() - - def query(self, query_string: str) -> List[Result]: - """Process bookmark queries with caching.""" - query_key = query_string.strip() - current_time = time.time() - - # Check cache first (except for add/remove commands which should always execute) - if ( - not query_key.startswith(("add ", "remove ", "delete ", "rm ")) - and query_key in self._results_cache - and (current_time - self._cache_timestamps.get(query_key, 0)) - < self._cache_ttl - ): - return self._results_cache[query_key] - - query = query_key.lower() - results = [] - - if not query: - # Show recent/popular bookmarks when no query - results = self._get_recent_bookmarks() - elif query.startswith("add "): - # Add new bookmark (don't cache) - results = self._handle_add_command(query[4:].strip()) - elif query.startswith(("remove ", "delete ", "rm ")): - # Remove bookmark (don't cache) - command_parts = query_key.split(" ", 1) - if len(command_parts) > 1: - results = self._handle_remove_command(command_parts[1].strip()) - else: - results = self._show_remove_help() - else: - # Search bookmarks - results = self._search_bookmarks(query) - - # Cache results (except for add/remove commands) - if not query.startswith(("add ", "remove ", "delete ", "rm ")): - self._results_cache[query_key] = results - self._cache_timestamps[query_key] = current_time - - return results - - def _search_bookmarks(self, query: str) -> List[Result]: - """Search through bookmarks.""" - bookmarks = self.bookmark_manager.get_bookmarks() - - if not bookmarks: - return [ - Result( - title="No Bookmarks Found", - subtitle="Use 'add <url>' to add first bookmark", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "info", "keep_launcher_open": True}, - ) - ] - - # Search bookmarks - results = [] - for bookmark in bookmarks: - relevance = self._calculate_relevance(bookmark, query) - if relevance > 0.3: # Only show relevant results - result = self._create_bookmark_result(bookmark, relevance) - if result: - results.append(result) - - # Sort by relevance and limit results - results.sort(key=lambda r: r.relevance, reverse=True) - return results[: self.max_results] - - def _get_recent_bookmarks(self) -> List[Result]: - """Get recent/popular bookmarks when no query is provided.""" - bookmarks = self.bookmark_manager.get_bookmarks() - - if not bookmarks: - return [ - Result( - title="No Bookmarks Yet", - subtitle="Use 'add <title> <url>' to add first bookmark", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Example: Add Google", - subtitle="add Google https://google.com", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "example", "keep_launcher_open": True}, - ), - ] - - # Sort by access time (most recent first) and show top 10 - sorted_bookmarks = sorted( - bookmarks, key=lambda b: b.get("accessed", 0), reverse=True - ) - results = [] - for bookmark in sorted_bookmarks[:10]: - result = self._create_bookmark_result(bookmark, 0.8) - if result: - results.append(result) - - return results - - def _handle_add_command(self, args: str) -> List[Result]: - """Handle add bookmark command.""" - if not args: - return [ - Result( - title="Add Bookmark", - subtitle="Usage: add <title> <url> [description]", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Example", - subtitle="add Google https://google.com Search engine", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "example", "keep_launcher_open": True}, - ), - ] - - # Parse arguments: title url [description] - parts = args.split() - if len(parts) < 2: - return [ - Result( - title="Invalid Format", - subtitle="Usage: add <title> <url> [description]", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - title = parts[0] - url = parts[1] - description = " ".join(parts[2:]) if len(parts) > 2 else "" - - # Check if bookmark already exists - normalized_url = self.bookmark_manager._normalize_url(url) - existing_bookmarks = self.bookmark_manager.get_bookmarks() - already_exists = any( - bookmark["url"] == normalized_url for bookmark in existing_bookmarks - ) - - if already_exists: - # Truncate URL for display to prevent launcher resize - display_url = normalized_url - if len(display_url) > 35: - display_url = display_url[:32] + "..." - - return [ - Result( - title="Bookmark Already Exists", - subtitle=f"URL '{display_url}' already exists", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Show add action - will execute on Enter - domain = self.bookmark_manager._extract_domain(normalized_url) - - # Truncate domain if too long - if len(domain) > 25: - domain = domain[:22] + "..." - - subtitle = f"Click to add: {domain}" - if description: - # Truncate description to prevent launcher resize - max_desc_len = 35 - len(domain) # Account for domain + separator - if len(description) > max_desc_len: - description = description[: max_desc_len - 3] + "..." - subtitle += f" • {description}" - - # Truncate title for display - display_title = title - if len(display_title) > 25: - display_title = display_title[:22] + "..." - - return [ - Result( - title=f"Add bookmark '{display_title}'", - subtitle=subtitle, - icon_name="plus", - action=lambda: self._add_bookmark_action(title, url, description), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "add", "name": title, "keep_launcher_open": True}, - ) - ] - - def _handle_remove_command(self, identifier: str) -> List[Result]: - """Handle remove bookmark command.""" - if not identifier: - return self._show_remove_help() - - # Find matching bookmarks - bookmarks = self.bookmark_manager.get_bookmarks() - identifier_lower = identifier.lower().strip() - - matching_bookmarks = [] - for bookmark in bookmarks: - if ( - bookmark["title"].lower() == identifier_lower - or bookmark["url"].lower() == identifier_lower - or self.bookmark_manager._extract_domain(bookmark["url"]).lower() - == identifier_lower - ): - matching_bookmarks.append(bookmark) - - if not matching_bookmarks: - return [ - Result( - title="Bookmark Not Found", - subtitle=f"No bookmark found matching '{identifier}'", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Show remove action - will execute on Enter - bookmark = matching_bookmarks[0] # Take first match - title = bookmark.get("title", "Untitled") - domain = self.bookmark_manager._extract_domain(bookmark.get("url", "")) - - # Truncate title and domain for display - display_title = title - if len(display_title) > 25: - display_title = display_title[:22] + "..." - - display_domain = domain - if len(display_domain) > 30: - display_domain = display_domain[:27] + "..." - - return [ - Result( - title=f"Remove '{display_title}'?", - subtitle=f"Click to confirm: {display_domain}", - icon_name="trash", - action=lambda: self._remove_bookmark_action(identifier), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "remove", "name": title, "keep_launcher_open": True}, - ) - ] - - def _show_remove_help(self) -> List[Result]: - """Show help for remove command.""" - bookmarks = self.bookmark_manager.get_bookmarks() - results = [ - Result( - title="Remove Bookmark", - subtitle="Usage: remove <title|url|domain>", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - # Show available bookmarks to remove - if bookmarks: - results.append( - Result( - title="Available Bookmarks:", - subtitle=f"{len(bookmarks)} bookmarks available to remove", - icon_name="bookmarks-organize", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "info", "keep_launcher_open": True}, - ) - ) - - # Show first few bookmarks as examples - for bookmark in bookmarks[:3]: - title = bookmark.get("title", "Untitled") - domain = self._extract_domain(bookmark.get("url", "")) - - # Truncate title and domain for consistent display - display_title = title - if len(display_title) > 20: - display_title = display_title[:17] + "..." - - display_domain = domain - if len(display_domain) > 20: - display_domain = display_domain[:17] + "..." - - results.append( - Result( - title=f"remove {display_title}", - subtitle=f"Click to remove: {display_title} ({display_domain})", - icon_name="trash", - action=lambda t=title: self._remove_bookmark_action(t), - relevance=0.8, - plugin_name=self.display_name, - data={ - "type": "remove_option", - "bookmark": bookmark, - "keep_launcher_open": True, - }, - ) - ) - - return results - - def _add_bookmark_action(self, title: str, url: str, description: str = ""): - """Execute the add bookmark action.""" - success = self.bookmark_manager.add_bookmark(title, url, description) - if success: - print(f"✓ Added bookmark '{title}' - {url}") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"✗ Failed to add bookmark '{title}' - already exists") - - def _remove_bookmark_action(self, identifier: str): - """Execute the remove bookmark action.""" - success = self.bookmark_manager.remove_bookmark(identifier) - if success: - print(f"✓ Removed bookmark '{identifier}'") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"✗ Failed to remove bookmark '{identifier}' - not found") - - def _remove_bookmark_with_reset(self, identifier: str): - """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger.""" - success = self.bookmark_manager.remove_bookmark(identifier) - if success: - print(f"✓ Removed bookmark '{identifier}'") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"✗ Failed to remove bookmark '{identifier}' - not found") - - def _calculate_relevance(self, bookmark: Dict, query: str) -> float: - """Calculate relevance score for a bookmark.""" - title = bookmark.get("title", "").lower() - url = bookmark.get("url", "").lower() - description = bookmark.get("description", "").lower() - - # Exact title match - if query == title: - return 1.0 - - # Title starts with query - if title.startswith(query): - return 0.95 - - # Query in title - if query in title: - position = title.index(query) - position_score = 1.0 - (position / len(title)) - return 0.8 + (position_score * 0.1) - - # Query in URL - if query in url: - return 0.7 - - # Query in description - if query in description: - return 0.6 - - # Fuzzy matching for title - if len(query) >= 3: - fuzzy_score = fuzz.partial_ratio(query, title) / 100.0 - if fuzzy_score >= 0.7: - return fuzzy_score * 0.6 - - return 0.0 - - def _create_bookmark_result( - self, bookmark: Dict, relevance: float - ) -> Optional[Result]: - """Create a Result object for a bookmark.""" - try: - title = bookmark.get("title", "Untitled") - url = bookmark.get("url", "") - description = bookmark.get("description", "") - - # Truncate long titles to prevent launcher resize - if len(title) > 45: - title = title[:42] + "..." - - # Create subtitle with domain and description - domain = self._extract_domain(url) - - # Truncate domain if too long - if len(domain) > 30: - domain = domain[:27] + "..." - - if description: - # Truncate description to prevent launcher resize - # Account for domain + separator - max_desc_len = 50 - len(domain) - if len(description) > max_desc_len: - description = description[: max_desc_len - 3] + "..." - subtitle = f"{domain} • {description}" - else: - subtitle = domain - - # Final subtitle length check to ensure consistent launcher size - if len(subtitle) > 60: - subtitle = subtitle[:57] + "..." - - return Result( - title=title, - subtitle=subtitle, - icon_name="bookmark-organize", - action=lambda u=url: self._open_bookmark(u), - relevance=relevance, - plugin_name=self.display_name, - data={ - "type": "bookmark", - "url": url, - "domain": domain, - "description": description, - "keep_launcher_open": False, - "alt_action": lambda t=title: self._remove_bookmark_with_reset(t), - }, - ) - except Exception as e: - print(f"Error creating bookmark result: {e}") - return None - - def _extract_domain(self, url: str) -> str: - """Extract domain from URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove www. prefix - if domain.startswith("www."): - domain = domain[4:] - return domain - except: - return url - - def _open_bookmark(self, url: str): - """Open bookmark URL in default browser and update access time.""" - try: - # Update access time - self.bookmark_manager.update_access_time(url) - - # Open URL - subprocess.Popen( - ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - print(f"Failed to open bookmark: {e}") - - def _setup_launcher_hooks(self): - """Setup hooks to monitor launcher state.""" - try: - # Try to find the launcher instance - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "close_launcher") - ): - self._launcher_instance = obj - break - except Exception as e: - print(f"Warning: Could not setup launcher hooks: {e}") - - def _cleanup_launcher_hooks(self): - """Cleanup launcher hooks.""" - try: - self._launcher_instance = None - except Exception as e: - print(f"Warning: Could not cleanup launcher hooks: {e}") - - def _reset_to_trigger(self): - """Reset launcher to trigger word and refresh.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "search_entry" - ): - # Get the current trigger (bookmark or bm) - current_text = self._launcher_instance.search_entry.get_text() - trigger = "bookmark " - - # Determine which trigger was used - if current_text.lower().startswith("bm "): - trigger = "bm " - - # Reset to trigger word with space - try: - from gi.repository import GLib - - def reset_and_refresh(): - # Set text to trigger word - self._launcher_instance.search_entry.set_text(trigger) - # Position cursor at end - self._launcher_instance.search_entry.set_position(-1) - # Trigger search to show default bookmarks - self._launcher_instance._perform_search(trigger) - return False - - GLib.timeout_add(50, reset_and_refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance.search_entry.set_text(trigger) - self._launcher_instance.search_entry.set_position(-1) - self._launcher_instance._perform_search(trigger) - except Exception as e: - print(f"Could not reset to trigger: {e}") - - def _force_launcher_refresh(self): - """Force the launcher to refresh and show updated results.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "_perform_search" - ): - # Get current search text - current_text = "" - if hasattr(self._launcher_instance, "search_entry"): - current_text = self._launcher_instance.search_entry.get_text() - - # Trigger a search to refresh results - try: - from gi.repository import GLib - - def refresh(): - self._launcher_instance._perform_search(current_text) - return False - - GLib.timeout_add(50, refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance._perform_search(current_text) - except Exception as e: - print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/caffeine.py b/modules/launcher/plugins/caffeine.py deleted file mode 100644 index decde979..00000000 --- a/modules/launcher/plugins/caffeine.py +++ /dev/null @@ -1,208 +0,0 @@ -import subprocess -from threading import Timer -from typing import List - -import gi - -import config.data as data -from fabric.utils import get_relative_path -from fabric.utils.helpers import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - -gi.require_version("Gtk", "3.0") - - -class CaffeinePlugin(PluginBase): - """ - Plugin for preventing system idle using the inhibit script. - """ - - def __init__(self): - super().__init__() - self.display_name = "Caffeine" - self.description = "Prevent system from going idle" - self.inhibit_script = get_relative_path("../../../utils/inhibit.py") - - # Predefined durations - self.durations = { - "30m": "30 minutes", - "1h": "1 hour", - "2h": "2 hours", - "4h": "4 hours", - "8h": "8 hours", - "on": "On", - "off": "Off", - } - - def initialize(self): - """Initialize the caffeine plugin.""" - self.set_triggers(["caffeine"]) - - def cleanup(self): - """Cleanup the caffeine plugin.""" - pass - - def _create_inhibit_action(self, duration: str): - """Create an inhibit action that properly captures the duration.""" - - def action(): - try: - # Run the script in the background without waiting - subprocess.Popen( - ["python3", self.inhibit_script, duration], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, # Run in a new session to prevent hanging - ) - - # Send notification based on duration - if duration.lower() == "off": - # Deactivation notification - exec_shell_command_async( - f"notify-send '☕ Caffeine' 'Deactivated' -a '{ - data.APP_NAME_CAP - }' -e" - ) - else: - # Activation notification with duration - duration_text = self.durations.get(duration, duration) - exec_shell_command_async( - f"notify-send '☕ Caffeine' 'Activated for { - duration_text - }' -a '{data.APP_NAME_CAP}' -e" - ) - - # Schedule expiration notification for timed durations - if duration.lower() not in ["on", "off"]: - self._schedule_expiration_notification(duration, duration_text) - - except Exception as e: - print(f"Error starting inhibit script: {e}") - - return action - - def _parse_duration_to_seconds(self, duration_str: str) -> int: - """Parse duration string into seconds. Same logic as inhibit.py""" - try: - if duration_str.lower() in ["on", "off"]: - return 0 - elif duration_str.endswith("h"): - return int(float(duration_str[:-1]) * 3600) - elif duration_str.endswith("m"): - return int(float(duration_str[:-1]) * 60) - elif duration_str.endswith("s"): - return int(float(duration_str[:-1])) - else: - return int(duration_str) - except ValueError: - return 0 - - def _schedule_expiration_notification(self, duration: str, duration_text: str): - """Schedule a notification for when the caffeine effect expires.""" - seconds = self._parse_duration_to_seconds(duration) - if seconds > 0: - - def send_expiration_notification(): - exec_shell_command_async( - f"notify-send '☕ Caffeine' 'Expired after {duration_text}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - - # Schedule the notification - timer = Timer(seconds, send_expiration_notification) - timer.daemon = True # Don't prevent program exit - timer.start() - - def _is_valid_duration(self, query: str) -> bool: - """Check if the query is a valid duration format.""" - if query in self.durations: - return True - if query.isdigit(): - return True - if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit(): - return True - return False - - def _get_default_action(self, query: str): - """Get the default action for a direct duration input.""" - if self._is_valid_duration(query): - return self._create_inhibit_action(query) - return None - - def query(self, query_string: str) -> List[Result]: - """Search caffeine durations based on query.""" - # For empty queries, show all available durations - if not query_string.strip(): - query_string = "" # Will match all durations in the loop below - - query = query_string.lower().strip() - results = [] - - # Handle direct search entry (e.g., "caffeine 30m" or just "30m") - if query.startswith("caffeine "): - query = query[9:].strip() # Remove "caffeine " prefix - # If query becomes empty after removing prefix, show all durations - if not query: - query = "" # Will match all durations in the loop below - elif query == "caffeine": - # Handle just "caffeine" without space - show all durations - query = "" - elif not query: - # Handle empty query - show all durations - pass - elif ( - query in self.durations - or query.isdigit() - or ( - query.endswith(("h", "m", "s")) - and query[:-1].replace(".", "").isdigit() - ) - ): - # If query is a valid duration without prefix, use it directly - pass - else: - return [] - - # Add custom duration option - if query.isdigit() or ( - query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit() - ): - result = Result( - title=f"Custom: {query}", - subtitle="Set custom duration", - icon_name="caffeine", - action=self._create_inhibit_action(query), - relevance=1.0, - plugin_name=self.display_name, - data={"duration": query}, - ) - results.append(result) - - # Add predefined durations - for duration, description in self.durations.items(): - # If query is empty, show all durations; otherwise filter by query - if not query or query in duration or query in description.lower(): - # Special handling for "off" - it should stop inhibition - if duration.lower() == "off": - subtitle = "Stop idle inhibition" - else: - subtitle = f"Prevent idle for {description.lower()}" - - result = Result( - title=description, - subtitle=subtitle, - icon_name="caffeine", - action=self._create_inhibit_action(duration), - relevance=0.9 if query == duration else 0.7, - plugin_name=self.display_name, - data={"duration": duration}, - ) - results.append(result) - - # Set default action for direct duration input - if results: - results[0].default_action = self._get_default_action(query) - - return results diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py deleted file mode 100644 index f97dddbe..00000000 --- a/modules/launcher/plugins/calculator.py +++ /dev/null @@ -1,163 +0,0 @@ -import math -import re -import subprocess -import time -from typing import List - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from utils.conversion import Conversion - - -class CalculatorPlugin(PluginBase): - """ - Plugin for calculating mathematical expressions and converting units. - """ - - def __init__(self): - super().__init__() - self.display_name = "Calculator" - self.description = "Evaluate mathematical expressions and convert units" - - # Safe functions for evaluation - self.safe_functions = { - "abs": abs, - "round": round, - "min": min, - "max": max, - "sum": sum, - "pow": pow, - "sqrt": math.sqrt, - "sin": math.sin, - "cos": math.cos, - "tan": math.tan, - "asin": math.asin, - "acos": math.acos, - "atan": math.atan, - "log": math.log, - "log10": math.log10, - "exp": math.exp, - "pi": math.pi, - "e": math.e, - } - - # Initialize conversion utility - self.converter = Conversion() - - # Pre-compiled regex patterns - self.expression_pattern = re.compile(r"[\d+\-*/^()=]") - self.number_pattern = re.compile(r"\d") - self.conversion_pattern = re.compile( - r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)" - ) - - # Cache for conversion results - self._conversion_cache = {} - self._last_cache_cleanup = time.time() - self._cache_cleanup_interval = 300 # 5 minutes - - def initialize(self): - """Initialize the files plugin.""" - self.set_triggers(["="]) - - def _cleanup_cache(self): - """Clean up old cache entries.""" - current_time = time.time() - if current_time - self._last_cache_cleanup > self._cache_cleanup_interval: - self._conversion_cache.clear() - self._last_cache_cleanup = current_time - - def query(self, query: str) -> List[Result]: - """Process a query and return results.""" - if not query: - return [] - - # Clean up cache periodically - self._cleanup_cache() - - # Check if it's a conversion query - conversion_match = self.conversion_pattern.match(query) - if conversion_match: - try: - value, from_unit, to_unit = conversion_match.groups() - value = float(value) - - # Check cache first - cache_key = f"{value}_{from_unit}_{to_unit}" - if cache_key in self._conversion_cache: - result = self._conversion_cache[cache_key] - subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" - else: - # Use the conversion utility - result = self.converter.convert(value, from_unit, to_unit) - # Cache the result - self._conversion_cache[cache_key] = result - subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" - - return [ - Result( - title=f"{result:.6g} {to_unit}", - subtitle=subtitle, - icon_name="calculator-symbolic", - action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), - relevance=1.0, - plugin_name=self.display_name, - data={"from": (value, from_unit), "to": (result, to_unit)}, - ) - ] - except ValueError as e: - return [ - Result( - title="Invalid conversion", - subtitle=str(e), - icon_name="calculator-symbolic", - relevance=0.0, - plugin_name=self.display_name, - ) - ] - - # Check if it's a math expression - if self.expression_pattern.search(query): - try: - # Evaluate the expression - result = eval(query, {"__builtins__": {}}, self.safe_functions) - if isinstance(result, (int, float)): - return [ - Result( - title=f"{result:.6g}", - subtitle=f"{query} = {result:.6g}", - icon_name="calculator-symbolic", - action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), - relevance=1.0, - plugin_name=self.display_name, - ) - ] - except Exception: - pass - - return [] - - def _format_cache_age(self, age_seconds: float) -> str: - """Format cache age for display.""" - if age_seconds < 60: - return f"{int(age_seconds)}s ago" - elif age_seconds < 3600: - return f"{int(age_seconds // 60)}m ago" - else: - return f"{int(age_seconds // 3600)}h ago" - - def _copy_to_clipboard(self, text: str): - """Copy text to clipboard using cliphist.""" - try: - # First copy to clipboard - subprocess.run(["wl-copy"], input=text.encode(), check=True) - # Then store in cliphist - subprocess.run(["cliphist", "store"], input=text.encode(), check=True) - except subprocess.CalledProcessError: - # If cliphist fails, at least we have the text in clipboard - pass - - def cleanup(self): - """Clean up resources.""" - self._conversion_cache.clear() - self.converter.cleanup() diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py deleted file mode 100644 index e332d193..00000000 --- a/modules/launcher/plugins/clipboard.py +++ /dev/null @@ -1,438 +0,0 @@ -import os -import subprocess -import sys -import tempfile -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from typing import Dict, List, Optional - -from gi.repository import GdkPixbuf, GLib - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class ClipboardPlugin(PluginBase): - def __init__(self): - super().__init__() - self.name = "clipboard" - self.display_name = "Clipboard History" - self.description = "Search and manage clipboard history using cliphist" - - # Performance settings - show more history like example_cliphist.py - self.max_results = 50 - # Cache clipboard items for 5 seconds (more responsive) - self.cache_ttl = 5 - - # Initialize cache and temp directory - self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") - # Cache images forever like example_cliphist.py - self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {} - self.clipboard_items_cache: List[str] = [] - self.cache_timestamp = 0 - - # Threading - self.executor = ThreadPoolExecutor( - max_workers=2, thread_name_prefix="clipboard" - ) - self.cache_lock = threading.Lock() - - # State tracking - self._loading = False - self._pending_updates = False - - def initialize(self): - """Initialize the plugin.""" - self.set_triggers(["clip"]) - try: - subprocess.run( - ["cliphist", "list"], capture_output=True, check=True, timeout=5 - ) - except (subprocess.SubprocessError, FileNotFoundError): - raise RuntimeError("cliphist is not installed or not working properly") - - # Pre-warm cache in background - self.executor.submit(self._load_clipboard_items_cached) - - def cleanup(self): - """Cleanup the plugin.""" - try: - # Shutdown executor - if hasattr(self, "executor"): - self.executor.shutdown(wait=False) - - # Clean up temp files - if os.path.exists(self.tmp_dir): - import shutil - - shutil.rmtree(self.tmp_dir) - - # Clear caches - with self.cache_lock: - self.image_cache.clear() - self.clipboard_items_cache.clear() - except Exception as e: - print(f"Error cleaning up temporary files: {e}", file=sys.stderr) - - def invalidate_cache(self): - """Force invalidation of the clipboard cache.""" - with self.cache_lock: - self.clipboard_items_cache.clear() - self.cache_timestamp = 0 - # Keep image cache forever like example_cliphist.py - - def _force_launcher_refresh(self): - """Force the launcher to refresh its results.""" - try: - from gi.repository import GLib - - def trigger_refresh(): - try: - # Try to access the launcher through the fabric Application - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if ( - launcher - and hasattr(launcher, "search_entry") - and hasattr(launcher, "_perform_search") - ): - # Get current search text to preserve the query - current_text = launcher.search_entry.get_text() - # Trigger the search to refresh results - launcher._perform_search(current_text) - return False - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - ): - if hasattr(obj, "search_entry") and hasattr( - obj, "_perform_search" - ): - current_text = obj.search_entry.get_text() - obj._perform_search(current_text) - return False - - except Exception as e: - print(f"Error forcing launcher refresh: {e}") - - return False # Don't repeat - - # Use immediate refresh - GLib.timeout_add(10, trigger_refresh) - - except Exception as e: - print(f"Could not trigger refresh: {e}") - - def _load_clipboard_items_cached(self) -> List[str]: - """Load clipboard items from cliphist with caching and change detection.""" - current_time = time.time() - - # Always load fresh data to check for changes - try: - result = subprocess.run( - ["cliphist", "list"], capture_output=True, check=True, timeout=5 - ) - stdout_str = result.stdout.decode("utf-8", errors="replace") - if stdout_str.strip(): - lines = stdout_str.strip().split("\n") - items = [ - line for line in lines if line and "<meta http-equiv" not in line - ] - else: - items = [] - - # Check if data has changed - with self.cache_lock: - data_changed = ( - not self.clipboard_items_cache - or len(items) != len(self.clipboard_items_cache) - or items != self.clipboard_items_cache - ) - - # If cache is still valid and data hasn't changed, return cached data - if ( - not data_changed - and self.clipboard_items_cache - and current_time - self.cache_timestamp < self.cache_ttl - ): - return self.clipboard_items_cache.copy() - - # Update cache with fresh data - self.clipboard_items_cache = items - self.cache_timestamp = current_time - - # Keep image cache forever like example_cliphist.py - don't clear on data changes - - return items - except subprocess.CalledProcessError as e: - print(f"Error loading clipboard history: {e}", file=sys.stderr) - return [] - except Exception as e: - print(f"Unexpected error: {e}", file=sys.stderr) - return [] - - def _load_clipboard_items(self) -> List[str]: - """Load clipboard items (legacy method for compatibility).""" - return self._load_clipboard_items_cached() - - def _create_pixbuf_from_bytes( - self, image_data: bytes, max_size: int = 100 - ) -> GdkPixbuf.Pixbuf: - """Create a GdkPixbuf from image bytes with size limit.""" - try: - loader = GdkPixbuf.PixbufLoader() - loader.write(image_data) - loader.close() - pixbuf = loader.get_pixbuf() - - # Scale image if needed - width, height = pixbuf.get_width(), pixbuf.get_height() - if width > height: - new_width = max_size - new_height = int(height * (max_size / width)) - else: - new_height = max_size - new_width = int(width * (max_size / height)) - - return pixbuf.scale_simple( - new_width, new_height, GdkPixbuf.InterpType.BILINEAR - ) - except GLib.Error: - return None - - def _is_image_data(self, content: str) -> bool: - """Determine if clipboard content is likely an image (like example_cliphist.py).""" - return ( - content.startswith("data:image/") - or content.startswith("\x89PNG") - or content.startswith("GIF8") - or content.startswith("\xff\xd8\xff") - or "binary" in content.lower() - and any( - ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"] - ) - ) - - def _get_text_preview(self, content: str) -> str: - """Get a text preview of the content.""" - if len(content) > 50: - return content[:37] + "..." - return content - - def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]: - """Load image preview with forever caching (like example_cliphist.py).""" - # Check cache first - cache forever like example_cliphist.py - with self.cache_lock: - if item_id in self.image_cache: - return self.image_cache[item_id] - - try: - result = subprocess.run( - ["cliphist", "decode", item_id], - capture_output=True, - check=True, - timeout=3, - ) - pixbuf = self._create_pixbuf_from_bytes(result.stdout) - if pixbuf: - with self.cache_lock: - self.image_cache[item_id] = pixbuf - - return pixbuf - except Exception as e: - print(f"Error loading image preview: {e}", file=sys.stderr) - return None - - def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]: - """Load image preview (legacy method for compatibility).""" - return self._load_image_preview_cached(item_id) - - def query(self, query_string: str) -> List[Result]: - """Search clipboard history using cliphist with optimized performance.""" - results = [] - - # Handle query string - if query_string.lower() == "clip": - query_string = "" # Show all items - - try: - # Load clipboard items from cache - clipboard_items = self._load_clipboard_items_cached() - - # Early exit if no items - if not clipboard_items: - return results - - # Filter items based on query - search through ALL items first - filtered_items = [] - query_lower = query_string.lower() if query_string else "" - - for item in clipboard_items: - parts = item.split("\t", 1) - content = parts[1] if len(parts) > 1 else item - content_lower = content.lower() - - # Fast filtering - search through all items, don't limit here - if not query_lower or query_lower in content_lower: - # Calculate relevance score for better sorting - relevance = 1.0 - if query_lower: - if content_lower.startswith(query_lower): - relevance = 1.0 # Exact start match - elif query_lower in content_lower: - # Position-based scoring: earlier matches get higher scores - position = content_lower.find(query_lower) - relevance = max( - 0.5, 1.0 - (position / len(content_lower)) * 0.4 - ) - - filtered_items.append((item, relevance)) - - # Sort by relevance (highest first) and then limit results - filtered_items.sort(key=lambda x: x[1], reverse=True) - filtered_items = filtered_items[: self.max_results] - - # Process items with lazy image loading - for i, (item, relevance) in enumerate(filtered_items): - parts = item.split("\t", 1) - item_id = parts[0] if len(parts) > 1 else str(i) - content = parts[1] if len(parts) > 1 else item - - # Handle image content like example_cliphist.py - if self._is_image_data(content): - # Check if image is already cached (forever cache like example_cliphist.py) - cached_pixbuf = None - with self.cache_lock: - cached_pixbuf = self.image_cache.get(item_id) - - if cached_pixbuf: - # Use cached image immediately - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon=cached_pixbuf, - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True}, - ) - else: - # Try to load image immediately like example_cliphist.py - try: - immediate_pixbuf = self._load_image_preview_cached(item_id) - if immediate_pixbuf: - # Successfully loaded immediately - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon=immediate_pixbuf, - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard( - id - ), - data={"bypass_max_results": True}, - ) - else: - # Show placeholder if loading failed - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon_name="image-x-generic", - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard( - id - ), - data={"bypass_max_results": True}, - ) - except Exception: - # Fallback to placeholder - result = Result( - title="Image from clipboard", - subtitle="Click to copy image to clipboard", - description="Image content", - icon_name="image-x-generic", - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True}, - ) - - results.append(result) - continue - - # Handle text content - display_text = self._get_text_preview(content) - result = Result( - title=display_text, - subtitle="Text from clipboard", - description=( - content if len(content) <= 100 else content[:97] + "..." - ), - icon_name="edit-paste", - relevance=relevance, - plugin_name=self.name, - action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True}, - ) - results.append(result) - - except Exception as e: - # Handle errors gracefully - results.append( - Result( - title="Error accessing clipboard history", - subtitle=str(e), - icon_name="dialog-error", - relevance=0.0, - plugin_name=self.name, - data={"bypass_max_results": True}, - ) - ) - - return results - - def _copy_to_clipboard(self, entry_id: str): - """Copy entry to clipboard using cliphist with timeout.""" - try: - result = subprocess.run( - ["cliphist", "decode", entry_id], - capture_output=True, - check=True, - timeout=5, - ) - # Use wl-copy for Wayland or xclip for X11 - try: - subprocess.run(["wl-copy"], input=result.stdout, check=True, timeout=3) - except subprocess.SubprocessError: - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=result.stdout, - check=True, - timeout=3, - ) - - # Don't invalidate image cache - keep images cached forever like example_cliphist.py - # Only invalidate clipboard items cache - with self.cache_lock: - self.clipboard_items_cache.clear() - self.cache_timestamp = 0 - - except subprocess.SubprocessError as e: - print(f"Error copying to clipboard: {e}", file=sys.stderr) - except subprocess.TimeoutExpired as e: - print(f"Timeout copying to clipboard: {e}", file=sys.stderr) diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py deleted file mode 100644 index 8842e804..00000000 --- a/modules/launcher/plugins/emoji.py +++ /dev/null @@ -1,187 +0,0 @@ -import json -import os -import subprocess -import time -from collections import OrderedDict -from typing import Dict, List - -import config.data as data -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class EmojiPlugin(PluginBase): - """ - Plugin for searching and copying emojis. - """ - - def __init__(self): - super().__init__() - self.name = "emoji" - self.display_name = "Emoji" - self.description = "Search and copy emojis" - self.emoji_data = {} - self.emoji_path = get_relative_path("../../../config/assets/emoji.json") - - # Use cache directory for recent emojis (save directly in cache dir) - self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json") - self.recent_emojis = OrderedDict() - self.max_recent_emojis = 20 # Maximum number of recent emojis to track - - def initialize(self): - """Initialize the emoji plugin.""" - self.set_triggers(["em"]) - self._load_emoji_data() - self._load_recent_emojis() - - def cleanup(self): - """Cleanup the emoji plugin.""" - pass - - def _load_emoji_data(self): - """Load emoji data from JSON file.""" - try: - if os.path.exists(self.emoji_path): - with open(self.emoji_path, "r", encoding="utf-8") as f: - self.emoji_data = json.load(f) - else: - print(f"Emoji file not found: {self.emoji_path}") - except Exception as e: - print(f"Error loading emoji data: {e}") - - def _load_recent_emojis(self): - """Load recently used emojis from JSON file.""" - try: - if os.path.exists(self.recent_emoji_path): - with open(self.recent_emoji_path, "r", encoding="utf-8") as f: - recent_data = json.load(f) - # Convert to OrderedDict to maintain order - self.recent_emojis = OrderedDict(recent_data) - else: - # Create empty recent emojis file - self.recent_emojis = OrderedDict() - self._save_recent_emojis() - except Exception as e: - print(f"Error loading recent emoji data: {e}") - self.recent_emojis = OrderedDict() - - def _save_recent_emojis(self): - """Save recently used emojis to JSON file.""" - try: - # Ensure the cache directory exists - os.makedirs(data.CACHE_DIR, exist_ok=True) - - with open(self.recent_emoji_path, "w", encoding="utf-8") as f: - json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2) - except Exception as e: - print(f"Error saving recent emoji data: {e}") - - def _add_to_recent(self, emoji: str): - """Add an emoji to the recent list.""" - # Remove if already exists (to move it to front) - if emoji in self.recent_emojis: - del self.recent_emojis[emoji] - - # Add to front with current timestamp - self.recent_emojis[emoji] = time.time() - - # Keep only the most recent emojis - while len(self.recent_emojis) > self.max_recent_emojis: - # Remove the oldest item - self.recent_emojis.popitem(last=False) - - # Save to file - self._save_recent_emojis() - - def _copy_to_clipboard(self, emoji: str): - """Copy emoji to clipboard and track usage.""" - try: - # Try Wayland first - try: - subprocess.run(["wl-copy"], input=emoji.encode(), check=True) - except subprocess.SubprocessError: - # Fall back to X11 - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=emoji.encode(), - check=True, - ) - - # Track this emoji as recently used - self._add_to_recent(emoji) - - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - def query(self, query_string: str) -> List[Result]: - """Search emojis based on query.""" - results = [] - query = query_string.lower().strip() - - # If no query, show recently used emojis - if not query: - if self.recent_emojis: - # Show recent emojis in reverse order (most recent first) - for emoji in reversed(list(self.recent_emojis.keys())): - if emoji in self.emoji_data: - emoji_info = self.emoji_data[emoji] - results.append( - self._create_emoji_result(emoji, emoji_info, 1.0) - ) - else: - # If no recent emojis, show some popular ones as fallback - popular_emojis = ["😀", "👍", "❤️", "🎉", "🔥", "✨", "🚀", "🌈"] - for emoji in popular_emojis: - if emoji in self.emoji_data: - emoji_info = self.emoji_data[emoji] - results.append( - self._create_emoji_result(emoji, emoji_info, 1.0) - ) - return results - - # Search by name, group, or the emoji itself - for emoji, info in self.emoji_data.items(): - relevance = 0 - name = info.get("name", "").lower() - group = info.get("group", "").lower() - slug = info.get("slug", "").lower() - - # Exact match with emoji - if query == emoji: - relevance = 1.0 - # Name contains query - elif query in name: - relevance = 0.9 - # Slug contains query - elif query in slug: - relevance = 0.8 - # Group contains query - elif query in group: - relevance = 0.7 - - if relevance > 0: - results.append(self._create_emoji_result(emoji, info, relevance)) - - # Sort by relevance - results.sort(key=lambda x: x.relevance, reverse=True) - return results[:20] # Limit to 20 results - - def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: - """Create a Result object for an emoji.""" - name = info.get("name", "") - group = info.get("group", "") - - # Check if this is a recently used emoji - is_recent = emoji in self.recent_emojis - subtitle = f"{group}" + (" • Recently used" if is_recent else "") - - return Result( - title=name, # Show only the name, not the emoji - subtitle=subtitle, - icon_markup=emoji, # Use the emoji itself as the icon - action=lambda e=emoji: self._copy_to_clipboard(e), - relevance=relevance, - plugin_name=self.display_name, - data={"emoji": emoji, "name": name, "group": group, "recent": is_recent}, - ) diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py deleted file mode 100644 index 7ab0b6de..00000000 --- a/modules/launcher/plugins/otp.py +++ /dev/null @@ -1,850 +0,0 @@ -import json -import subprocess -import threading -import time -from pathlib import Path -from typing import Dict, List, Optional - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from services.auth import ( - generate_totp, - get_time_remaining_with_blink, - parse_otpauth_uri, - scan_qr_and_add_account, - validate_base32_secret, -) - - -class OTPPlugin(PluginBase): - """Plugin for managing TOTP (Time-based One-Time Password) codes.""" - - def __init__(self): - super().__init__() - self.display_name = "OTP Manager" - self.description = "Manage TOTP codes and 2FA authentication" - - self.secrets_file = Path( - get_relative_path("../../../config/assets/accounts.json") - ) - self.secrets: Dict[str, Dict] = {} - self.last_update = 0 - - # Threading for auto-refresh - self.refresh_thread = None - self.stop_refresh = threading.Event() - - def initialize(self): - """Initialize the OTP plugin.""" - self.set_triggers(["otp"]) - self._load_secrets() - self._ensure_config_file() - self._start_refresh_thread() - - def cleanup(self): - """Cleanup the OTP plugin.""" - if self.refresh_thread and self.refresh_thread.is_alive(): - self.stop_refresh.set() - self.refresh_thread.join(timeout=1) - - def _load_secrets(self): - """Load secrets from JSON file.""" - try: - if self.secrets_file.exists(): - with open(self.secrets_file, "r", encoding="utf-8") as f: - self.secrets = json.load(f) - else: - self.secrets = {} - except Exception as e: - print(f"Error loading OTP secrets: {e}") - self.secrets = {} - - def _save_secrets(self): - """Save secrets to JSON file.""" - try: - self.secrets_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.secrets_file, "w", encoding="utf-8") as f: - json.dump(self.secrets, f, indent=2) - except Exception as e: - print(f"Error saving OTP secrets: {e}") - - def _ensure_config_file(self): - """Ensure the config file exists.""" - if not self.secrets_file.exists(): - self.secrets_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.secrets_file, "w", encoding="utf-8") as f: - json.dump({}, f, indent=2) - - def _start_refresh_thread(self): - """Start background thread for auto-refreshing tokens.""" - - def refresh_loop(): - while not self.stop_refresh.wait(5): - current_time = time.time() - if current_time - self.last_update >= 5: - self.last_update = current_time - # Only refresh if we have secrets and launcher is likely active - if self.secrets: - try: - self._selective_force_refresh() - except Exception: - pass - - self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) - self.refresh_thread.start() - - def _selective_force_refresh(self): - """Update time display in existing OTP result items.""" - try: - import gc - - from gi.repository import GLib - - def do_update(): - try: - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "results_box") - and hasattr(obj, "visible") - and obj.visible - and hasattr(obj, "results") - and obj.results - ): - has_otp_results = any( - result.data and result.data.get("type") == "totp" - for result in obj.results - if hasattr(result, "data") and result.data - ) - - if has_otp_results: - self._update_existing_result_labels(obj.results_box) - return False - except Exception: - pass - return False - - GLib.idle_add(do_update) - except Exception: - pass - - def _update_existing_result_labels(self, results_box): - """Update subtitle labels in existing ResultItem widgets.""" - try: - time_display = self._get_time_remaining_with_blink() - for child in results_box.get_children(): - if ( - hasattr(child, "__class__") - and child.__class__.__name__ == "ResultItem" - and hasattr(child, "result") - and hasattr(child.result, "data") - and child.result.data - and child.result.data.get("type") == "totp" - ): - self._update_result_item_content(child, time_display) - except Exception as e: - print(f"Error updating result labels: {e}") - - def _update_result_item_content(self, result_item, time_display): - """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem.""" - try: - account_name = result_item.result.data.get("account", "") - if not account_name or account_name not in self.secrets: - return - - account_data = self.secrets[account_name] - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {account_name}" if issuer else account_name - - current_totp_code = self._generate_totp(secret) - if not current_totp_code: - return - - old_code = result_item.result.data.get("code", "") - if current_totp_code != old_code: - result_item.result.data["code"] = current_totp_code - self._find_and_update_title_label(result_item, current_totp_code) - result_item.result.action = ( - lambda code=current_totp_code: self._copy_to_clipboard(code) - ) - - new_subtitle_markup = f"{display_name} • {time_display} remaining" - self._find_and_update_subtitle_label(result_item, new_subtitle_markup) - except Exception as e: - print(f"Error updating result item: {e}") - - def _find_and_update_title_label(self, result_item, new_title): - """Find the title label widget and update its text.""" - - def find_title_label(widget): - if hasattr(widget, "get_name") and widget.get_name() == "result-item-title": - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_title_label(child) - if found: - return found - return None - - title_label = find_title_label(result_item) - if title_label and hasattr(title_label, "set_label"): - title_label.set_label(new_title) - - def _find_and_update_subtitle_label(self, result_item, new_markup): - """Find the subtitle label widget and update its markup.""" - - def find_subtitle_label(widget): - if ( - hasattr(widget, "get_name") - and widget.get_name() == "result-item-subtitle" - ): - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_subtitle_label(child) - if found: - return found - return None - - subtitle_label = find_subtitle_label(result_item) - if subtitle_label and hasattr(subtitle_label, "set_markup"): - subtitle_label.set_markup(new_markup) - - def _copy_to_clipboard(self, text: str): - """Copy text to clipboard.""" - try: - try: - subprocess.run(["wl-copy"], input=text.encode(), check=True) - except subprocess.SubprocessError: - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=text.encode(), - check=True, - ) - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - def _trigger_refresh(self): - """Trigger launcher refresh to return to default OTP view.""" - try: - from gi.repository import GLib - - # Use a small delay to ensure the action completes first - def trigger_refresh(): - try: - # Try to access the launcher through the fabric Application - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if launcher and hasattr(launcher, "search_entry"): - # Clear the search entry and set it to just "otp " - launcher.search_entry.set_text("otp ") - # Position cursor at the end - launcher.search_entry.set_position(-1) - # Trigger the search to show default OTP view - if hasattr(launcher, "_perform_search"): - launcher._perform_search("otp ") - return False - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - ): - if hasattr(obj, "search_entry") and hasattr( - obj, "_perform_search" - ): - obj.search_entry.set_text("otp ") - obj.search_entry.set_position(-1) - obj._perform_search("otp ") - return False - - except Exception as e: - print(f"Error forcing launcher refresh: {e}") - - return False # Don't repeat - - # Use a small delay to ensure the action completes first - GLib.timeout_add(50, trigger_refresh) - - except Exception as e: - print(f"Could not trigger refresh: {e}") - - def _remove_account_and_refresh(self, account_name: str): - """Remove an account and trigger refresh to return to default OTP view.""" - try: - if account_name in self.secrets: - # Remove the account - del self.secrets[account_name] - self._save_secrets() - - # Trigger refresh to return to default OTP view - self._trigger_refresh() - except Exception as e: - print(f"Error removing account {account_name}: {e}") - - def _generate_totp(self, secret: str) -> Optional[str]: - """Generate TOTP code from secret.""" - return generate_totp(secret) - - def _get_time_remaining_with_blink(self) -> str: - """Get time remaining with blinking effect.""" - return get_time_remaining_with_blink() - - def query(self, query_string: str) -> List[Result]: - """Process OTP queries.""" - query = query_string.strip() - - if not query: - return self._list_otp_codes() - - query_lower = query.lower() - if query_lower.startswith("add "): - add_content = query[4:].strip() - - # Handle both formats: with ``` and without ``` - if "```" in add_content: - # Old format: add account```secret``` - parts = add_content.split("```", 1) - if len(parts) == 2: - account_name = parts[0].strip() - secret_or_uri = parts[1].strip() - return self._handle_direct_add(account_name, secret_or_uri) - elif " " in add_content: - # New format: add account secret - parts = add_content.split(" ", 1) - if len(parts) == 2: - account_name = parts[0].strip() - secret_or_uri = parts[1].strip() - return self._handle_direct_add(account_name, secret_or_uri) - - return self._handle_add_command(add_content) - elif query_lower == "remove" or query_lower.startswith("remove "): - # Handle both "remove" and "remove accountname" - if query_lower == "remove": - remove_content = "" - else: - remove_content = query[7:].strip() - return self._handle_remove_command(remove_content) - elif query_lower == "qr" or query_lower.startswith("qr "): - # Handle QR scanning command - if query_lower == "qr": - qr_content = "" - else: - qr_content = query[3:].strip() - return self._handle_qr_command(qr_content) - else: - return self._search_accounts(query) - - def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]: - """Handle direct addition of OTP account.""" - if not account_name or not secret_or_uri: - return [ - Result( - title="Invalid format", - subtitle="Usage: add <account_name> <secret> or add <account_name>```<secret>```", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - try: - if secret_or_uri.startswith("otpauth://"): - return self._handle_otpauth_uri(account_name, secret_or_uri) - else: - return self._handle_base32_secret(account_name, secret_or_uri) - except Exception as e: - print(f"OTP Debug: Error in _handle_direct_add: {e}") - return [ - Result( - title="Error adding account", - subtitle=f"Debug: {str(e)}", - icon_name="cancel", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - def _list_otp_codes(self) -> List[Result]: - """List all OTP codes with current tokens.""" - results = [] - - if not self.secrets: - results.append( - Result( - title="No OTP accounts configured", - subtitle="Use 'add <account> <secret>' to add your first account", - icon_name="gtk-authentication-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <account> <secret> | remove <account> | qr <account>", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - return results - - time_display = self._get_time_remaining_with_blink() - - for account_name, account_data in self.secrets.items(): - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - totp_code = self._generate_totp(secret) - - if totp_code: - display_name = f"{issuer} - {account_name}" if issuer else account_name - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"{display_name} • { - time_display - } remaining • Shift+Enter: remove", - icon_name="gtk-authentication-symbolic", - action=lambda code=totp_code: self._copy_to_clipboard(code), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "totp", - "account": account_name, - "code": totp_code, - "alt_action": lambda acc=account_name: self._remove_account_and_refresh( - acc - ), - }, - ) - ) - else: - results.append( - Result( - title=f"Error: {account_name}", - subtitle="Invalid secret or configuration", - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={ - "type": "error", - "account": account_name, - "keep_launcher_open": True, - }, - ) - ) - - return results - - def _search_accounts(self, query: str) -> List[Result]: - """Search accounts by name or issuer.""" - results = [] - query_lower = query.lower() - - for account_name, account_data in self.secrets.items(): - issuer = account_data.get("issuer", "").lower() - account_lower = account_name.lower() - - if query_lower in account_lower or query_lower in issuer: - secret = account_data.get("secret", "") - totp_code = self._generate_totp(secret) - - if totp_code: - display_name = ( - f"{account_data.get('issuer', '')} - {account_name}" - if account_data.get("issuer") - else account_name - ) - time_display = self._get_time_remaining_with_blink() - - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"{display_name} • { - time_display - } remaining • Shift+Enter: remove", - icon_name="gtk-authentication-symbolic", - action=lambda code=totp_code: self._copy_to_clipboard(code), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "totp", - "account": account_name, - "code": totp_code, - "alt_action": lambda acc=account_name: self._remove_account_and_refresh( - acc - ), - }, - ) - ) - - if not results: - results.append( - Result( - title=f"No accounts found for '{query}'", - subtitle="Use 'add <account> <secret>' to create new account", - icon_name="edit-find-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "no_results", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <account> <secret> | remove <account> | qr <account>", - icon_name="info", - action=lambda: None, - relevance=0.4, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - - return results - - def _handle_add_command(self, account_name: str) -> List[Result]: - """Handle manual addition of OTP secret.""" - if not account_name: - return [ - Result( - title="Enter account name", - subtitle="Usage: add <account_name> <secret>", - icon_name="dialog-question-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - return [ - Result( - title=f"To add '{account_name}':", - subtitle=f"Type: add {account_name} <secret>", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "instruction", - "account": account_name, - "keep_launcher_open": True, - }, - ), - Result( - title="Base32 Secret Format:", - subtitle="Example: add gmail JBSWY3DPEHPK3PXP", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="otpauth URI Format:", - subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Remove Account:", - subtitle="Example: remove gmail", - icon_name="trash", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - ] - - def _handle_qr_command(self, account_name: str) -> List[Result]: - """Handle QR scanning command.""" - if not account_name: - return [ - Result( - title="Scan QR Code", - subtitle="Click to scan QR code from screen", - icon_name="view-barcode-qr-symbolic", - action=lambda: self._scan_qr_and_add_account(""), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "qr_scan"}, - ), - Result( - title="QR Scan Instructions:", - subtitle="Use 'qr <account_name>' to specify account name", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - ] - else: - return [ - Result( - title=f"Scan QR Code for '{account_name}'", - subtitle="Click to scan QR code from screen", - icon_name="view-barcode-qr-symbolic", - action=lambda name=account_name: self._scan_qr_and_add_account( - name - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "qr_scan"}, - ), - ] - - def _scan_qr_and_add_account(self, account_name: str): - """Scan QR code and add OTP account.""" - print(f"QR scan action called for account: '{account_name}'") - print("Starting QR scan process asynchronously...") - - # Run QR scanning asynchronously so launcher closes immediately - import threading - - thread = threading.Thread(target=self._scan_qr_async, args=(account_name,)) - thread.daemon = True - thread.start() - - def _scan_qr_async(self, account_name: str): - """Async QR scanning process.""" - result = scan_qr_and_add_account(account_name, str(self.secrets_file)) - - if result["success"]: - print(result["message"]) - # Reload secrets from file - self._load_secrets() - # Trigger refresh to show the new account - self._trigger_refresh() - else: - print(f"QR scan failed: {result['error']}") - - def _handle_remove_command(self, account_name: str) -> List[Result]: - """Handle removal of OTP account.""" - if not account_name: - # Show all available accounts for removal - results = [] - - if not self.secrets: - results.append( - Result( - title="No OTP accounts to remove", - subtitle="Use 'add <account> <secret>' to add accounts first", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - else: - results.append( - Result( - title="Select account to remove:", - subtitle="Type: remove <account_name> to remove an account", - icon_name="user-trash-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - - # Get time display for consistency with main OTP view - time_display = self._get_time_remaining_with_blink() - - # Show all accounts with their current OTP codes and remove actions - for acc_name, account_data in self.secrets.items(): - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {acc_name}" if issuer else acc_name - - # Generate current TOTP code - totp_code = self._generate_totp(secret) - - if totp_code: - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"Press Enter to remove • { - time_display - } remaining", - icon_name="user-trash-symbolic", - action=lambda acc=acc_name: self._remove_account_and_refresh( - acc - ), - relevance=0.9, - plugin_name=self.display_name, - data={ - "type": "remove_instruction", - "account": acc_name, - "code": totp_code, - "keep_launcher_open": True, - }, - ) - ) - else: - results.append( - Result( - title=f"Error: {acc_name}", - subtitle="Press Enter to remove (Invalid secret)", - icon_name="user-trash-symbolic", - action=lambda acc=acc_name: self._remove_account_and_refresh( - acc - ), - relevance=0.8, - plugin_name=self.display_name, - data={ - "type": "remove_instruction", - "account": acc_name, - "keep_launcher_open": True, - }, - ) - ) - - return results - - # Check if account exists - if account_name not in self.secrets: - return [ - Result( - title=f"Account '{account_name}' not found", - subtitle="Use 'remove' to see all available accounts", - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Get account info for confirmation - account_data = self.secrets[account_name] - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {account_name}" if issuer else account_name - - # Show confirmation for removal - return [ - Result( - title=f"Remove '{display_name}'?", - subtitle="Press Enter to confirm removal", - icon_name="user-trash-symbolic", - action=lambda acc=account_name: self._remove_account_and_refresh(acc), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "remove_confirm", - "account": account_name, - "keep_launcher_open": True, - }, - ) - ] - - def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]: - """Handle raw Base32 secret.""" - result = validate_base32_secret(secret) - - if not result["success"]: - return [ - Result( - title="Invalid Base32 secret", - subtitle=result["error"], - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - self.secrets[account_name] = { - "secret": result["secret"], - "issuer": "", - "algorithm": "SHA1", - "digits": 6, - "period": 30, - } - self._save_secrets() - - return [ - Result( - title=f"✓ Added '{account_name}'", - subtitle=f"OTP account added successfully (secret: { - result['secret'][:4] - }...)", - icon_name="emblem-ok-symbolic", - action=lambda: self._trigger_refresh(), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "success", "keep_launcher_open": True}, - ) - ] - - def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]: - """Handle otpauth:// URI.""" - result = parse_otpauth_uri(uri, account_name) - - if not result["success"]: - return [ - Result( - title="Error parsing otpauth URI", - subtitle=result["error"], - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - self.secrets[result["account_name"]] = { - "secret": result["secret"], - "issuer": result["issuer"], - "algorithm": result["algorithm"], - "digits": result["digits"], - "period": result["period"], - } - self._save_secrets() - - display_name = ( - f"{result['issuer']} - {result['account_name']}" - if result["issuer"] - else result["account_name"] - ) - return [ - Result( - title=f"✓ Added '{display_name}'", - subtitle="OTP account added from URI", - icon_name="emblem-ok-symbolic", - action=lambda: self._trigger_refresh(), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "success", "keep_launcher_open": True}, - ) - ] diff --git a/modules/launcher/plugins/password.py b/modules/launcher/plugins/password.py deleted file mode 100644 index 1551b200..00000000 --- a/modules/launcher/plugins/password.py +++ /dev/null @@ -1,690 +0,0 @@ -import base64 -import json -import subprocess -import threading -import time -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class PasswordManager: - """Simple password manager with basic encryption and caching.""" - - def __init__(self, storage_file: Path): - self.storage_file = storage_file - self.passwords: Dict[str, Dict] = {} - self._cache_lock = threading.Lock() - self._last_loaded = 0 - self._cache_ttl = 30 # Cache for 30 seconds - self._load_passwords() - - def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str: - """Simple encryption using XOR with base64 encoding.""" - key_bytes = key.encode("utf-8") - text_bytes = text.encode("utf-8") - - # XOR encryption - encrypted = bytearray() - for i, byte in enumerate(text_bytes): - encrypted.append(byte ^ key_bytes[i % len(key_bytes)]) - - # Base64 encode - return base64.b64encode(encrypted).decode("utf-8") - - def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str: - """Simple decryption using XOR with base64 decoding.""" - try: - key_bytes = key.encode("utf-8") - - # Base64 decode - encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8")) - - # XOR decryption - decrypted = bytearray() - for i, byte in enumerate(encrypted_bytes): - decrypted.append(byte ^ key_bytes[i % len(key_bytes)]) - - return decrypted.decode("utf-8") - except Exception: - return encrypted_text # Return as-is if decryption fails - - def _load_passwords(self): - """Load passwords from JSON file with caching.""" - with self._cache_lock: - current_time = time.time() - - # Check if cache is still valid - if (current_time - self._last_loaded) < self._cache_ttl and self.passwords: - return - - try: - if self.storage_file.exists(): - with open(self.storage_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.passwords = data.get("passwords", {}) - else: - self.passwords = {} - - self._last_loaded = current_time - except Exception as e: - print(f"Error loading passwords: {e}") - self.passwords = {} - - def _save_passwords(self): - """Save passwords to JSON file.""" - with self._cache_lock: - try: - self.storage_file.parent.mkdir(parents=True, exist_ok=True) - data = { - "passwords": self.passwords, - "last_modified": datetime.now().isoformat(), - } - with open(self.storage_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - - # Update cache timestamp - self._last_loaded = time.time() - except Exception as e: - print(f"Error saving passwords: {e}") - - def add_password(self, name: str, password: str, description: str = "") -> bool: - """Add a new password entry.""" - try: - encrypted_password = self._simple_encrypt(password) - self.passwords[name] = { - "password": encrypted_password, - "description": description, - "created": datetime.now().isoformat(), - "last_accessed": None, - } - self._save_passwords() - return True - except Exception as e: - print(f"Error adding password: {e}") - return False - - def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]: - """Get decrypted password by name.""" - # Ensure we have fresh data - self._load_passwords() - - if name in self.passwords: - try: - encrypted = self.passwords[name]["password"] - decrypted = self._simple_decrypt(encrypted) - - # Update last accessed time only if requested (to avoid frequent saves) - if update_access_time: - self.passwords[name]["last_accessed"] = datetime.now().isoformat() - # Don't save immediately - batch saves for better performance - - return decrypted - except Exception as e: - print(f"Error decrypting password: {e}") - return None - return None - - def remove_password(self, name: str) -> bool: - """Remove a password entry.""" - if name in self.passwords: - del self.passwords[name] - self._save_passwords() - return True - return False - - def list_passwords(self) -> List[str]: - """Get list of all password names.""" - # Ensure we have fresh data - self._load_passwords() - return list(self.passwords.keys()) - - def get_password_info(self, name: str) -> Optional[Dict]: - """Get password metadata without decrypting.""" - if name in self.passwords: - info = self.passwords[name].copy() - info.pop("password", None) # Remove encrypted password - return info - return None - - -class PasswordPlugin(PluginBase): - """ - Password manager plugin for the launcher. - Stores passwords securely and allows easy access. - """ - - def __init__(self): - super().__init__() - self.display_name = "Password Manager" - self.description = "Secure password storage and management" - - # Initialize password manager - self.password_file = Path( - get_relative_path("../../../config/assets/passwords.json") - ) - self.password_manager = PasswordManager(self.password_file) - - # State for password visibility - self.revealed_passwords: Dict[str, str] = {} - - # Cache for results to avoid repeated queries - self._results_cache: Dict[str, List[Result]] = {} - self._cache_timestamps: Dict[str, float] = {} - self._cache_ttl = 5 # Cache results for 5 seconds - - # Track launcher state for auto-hiding passwords - self._launcher_instance = None - - def initialize(self): - """Initialize the password plugin.""" - self.set_triggers(["pass"]) - self._setup_launcher_hooks() - - def cleanup(self): - """Cleanup the password plugin.""" - self.revealed_passwords.clear() - self._results_cache.clear() - self._cache_timestamps.clear() - self._cleanup_launcher_hooks() - - def query(self, query_string: str) -> List[Result]: - """Process password manager queries with caching.""" - query_key = query_string.strip() - current_time = time.time() - - # Check cache first (except for add/remove commands which should always execute) - if ( - not query_key.startswith(("add ", "remove ", "delete ")) - and query_key in self._results_cache - and (current_time - self._cache_timestamps.get(query_key, 0)) - < self._cache_ttl - ): - return self._results_cache[query_key] - - results = [] - query = query_key.lower() - - # Handle different commands - if not query: - # Show all passwords - results.extend(self._list_all_passwords()) - elif query.startswith("add "): - # Add new password (don't cache) - results.extend(self._handle_add_command(query_string)) - elif query.startswith("remove ") or query.startswith("delete "): - # Remove password (don't cache) - results.extend(self._handle_remove_command(query_string)) - else: - # Search for specific password - results.extend(self._search_passwords(query)) - - # Cache results (except for add/remove commands) - if not query.startswith(("add ", "remove ", "delete ")): - self._results_cache[query_key] = results - self._cache_timestamps[query_key] = current_time - - return results - - def _list_all_passwords(self) -> List[Result]: - """List all stored passwords.""" - results = [] - password_names = self.password_manager.list_passwords() - - if not password_names: - results.append( - Result( - title="No passwords stored", - subtitle="Use 'pass add <name> <password>' to add your first password", - icon_name="password-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <name> <password> | remove <name> | <name> (to search)", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - return results - - # Sort passwords alphabetically - password_names.sort() - - for name in password_names: - info = self.password_manager.get_password_info(name) - description = info.get("description", "") if info else "" - - # Check if password is revealed - if name in self.revealed_passwords: - title = f"{name}: {self.revealed_passwords[name]}" - subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" - else: - title = f"{name}: {'*' * 8}" - subtitle = "Enter: copy | Shift+Enter: reveal password" - - if description: - subtitle += f" | {description}" - - results.append( - Result( - title=title, - subtitle=subtitle, - icon_name="key", - action=lambda n=name: self._copy_password_to_clipboard(n), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "password", - "name": name, - "keep_launcher_open": False, - "alt_action": lambda n=name: self._toggle_password_visibility( - n - ), - }, - ) - ) - - return results - - def _handle_add_command(self, query_string: str) -> List[Result]: - """Handle add password command.""" - results = [] - parts = query_string.strip().split(" ", 3) - - if len(parts) < 3: - results.append( - Result( - title="Add Password - Invalid format", - subtitle="Usage: add <name> <password> [description]", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - return results - - name = parts[1] - password = parts[2] - description = parts[3] if len(parts) > 3 else "" - - # Check if password already exists - if name in self.password_manager.list_passwords(): - results.append( - Result( - title=f"Update password for '{name}'?", - subtitle="Password already exists. Click to update it.", - icon_name="key", - action=lambda: self._add_password_action( - name, password, description, update=True - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "update", "name": name, "keep_launcher_open": False}, - ) - ) - else: - results.append( - Result( - title=f"Add password for '{name}'", - subtitle="Click to save password" - + (f" | {description}" if description else ""), - icon_name="plus", - action=lambda: self._add_password_action( - name, password, description - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "add", "name": name, "keep_launcher_open": False}, - ) - ) - - return results - - def _handle_remove_command(self, query_string: str) -> List[Result]: - """Handle remove password command.""" - results = [] - parts = query_string.strip().split(" ", 1) - - if len(parts) < 2: - results.append( - Result( - title="Remove Password - Invalid format", - subtitle="Usage: remove <name>", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - return results - - name = parts[1] - - if name not in self.password_manager.list_passwords(): - results.append( - Result( - title=f"Password '{name}' not found", - subtitle="Check the name and try again", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - else: - results.append( - Result( - title=f"Remove password '{name}'?", - subtitle="Click to confirm deletion (this cannot be undone)", - icon_name="trash", - action=lambda: self._remove_password_action(name), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "remove", "name": name, "keep_launcher_open": False}, - ) - ) - - return results - - def _search_passwords(self, query: str) -> List[Result]: - """Search for passwords by name.""" - results = [] - password_names = self.password_manager.list_passwords() - - # Filter passwords that match the query - matching_passwords = [ - name for name in password_names if query.lower() in name.lower() - ] - - if not matching_passwords: - results.append( - Result( - title=f"No passwords found matching '{query}'", - subtitle="Try a different search term or use 'pass' to see all passwords", - icon_name="magnifier", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "no_results", "keep_launcher_open": True}, - ) - ) - return results - - # Sort by relevance (exact match first, then starts with, then contains) - def get_relevance(name: str) -> float: - name_lower = name.lower() - query_lower = query.lower() - - if name_lower == query_lower: - return 1.0 - elif name_lower.startswith(query_lower): - return 0.9 - else: - return 0.7 - - matching_passwords.sort(key=get_relevance, reverse=True) - - for name in matching_passwords: - info = self.password_manager.get_password_info(name) - description = info.get("description", "") if info else "" - - # Check if password is revealed - if name in self.revealed_passwords: - title = f"{name}: {self.revealed_passwords[name]}" - subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" - else: - title = f"{name}: {'*' * 8}" - subtitle = "Enter: copy | Shift+Enter: reveal password" - - if description: - subtitle += f" | {description}" - - results.append( - Result( - title=title, - subtitle=subtitle, - icon_name="key", - action=lambda n=name: self._copy_password_to_clipboard(n), - relevance=get_relevance(name), - plugin_name=self.display_name, - data={ - "type": "password", - "name": name, - "keep_launcher_open": False, - "alt_action": lambda n=name: self._toggle_password_visibility( - n - ), - }, - ) - ) - - return results - - def _add_password_action( - self, name: str, password: str, description: str = "", update: bool = False - ): - """Action to add/update a password.""" - try: - success = self.password_manager.add_password(name, password, description) - if success: - action_word = "updated" if update else "added" - # Clear cache to force refresh - self._results_cache.clear() - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password '{name}' {action_word} successfully", - ] - ) - except: - pass - else: - print(f"Failed to add password '{name}'") - except Exception as e: - print(f"Error adding password: {e}") - - def _remove_password_action(self, name: str): - """Action to remove a password.""" - try: - success = self.password_manager.remove_password(name) - if success: - # Remove from revealed passwords if present - self.revealed_passwords.pop(name, None) - - # Clear cache to force refresh - self._results_cache.clear() - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password '{name}' removed successfully", - ] - ) - except: - pass - else: - print(f"Failed to remove password '{name}'") - except Exception as e: - print(f"Error removing password: {e}") - - def _copy_password_to_clipboard(self, name: str): - """Copy password to clipboard and reveal it temporarily.""" - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - # Copy to clipboard (use timeout to avoid hanging) - try: - subprocess.run( - ["wl-copy"], input=password.encode(), check=True, timeout=2 - ) - except subprocess.SubprocessError: - # Fall back to X11 - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=password.encode(), - check=True, - timeout=2, - ) - - # Reveal password temporarily - self.revealed_passwords[name] = password - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password for '{name}' copied to clipboard", - ] - ) - except: - pass - - # Clear cache to force refresh - self._results_cache.clear() - - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error copying password: {e}") - - def _reveal_password(self, name: str): - """Reveal password without copying to clipboard.""" - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - self.revealed_passwords[name] = password - # Clear cache to force refresh with revealed password - self._results_cache.clear() - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error revealing password: {e}") - - def _hide_password(self, name: str): - """Hide revealed password.""" - self.revealed_passwords.pop(name, None) - - def _hide_all_passwords(self): - """Hide all revealed passwords.""" - if self.revealed_passwords: - self.revealed_passwords.clear() - # Clear cache to force refresh with hidden passwords - self._results_cache.clear() - - def _setup_launcher_hooks(self): - """Setup hooks to monitor launcher state.""" - try: - # Try to find the launcher instance - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "close_launcher") - ): - self._launcher_instance = obj - # Store original close_launcher method - self._original_close_launcher = obj.close_launcher - # Replace with our wrapper - obj.close_launcher = self._wrapped_close_launcher - break - except Exception as e: - print(f"Warning: Could not setup launcher hooks: {e}") - - def _cleanup_launcher_hooks(self): - """Cleanup launcher hooks.""" - try: - if self._launcher_instance and hasattr(self, "_original_close_launcher"): - # Restore original close_launcher method - self._launcher_instance.close_launcher = self._original_close_launcher - self._launcher_instance = None - except Exception as e: - print(f"Warning: Could not cleanup launcher hooks: {e}") - - def _wrapped_close_launcher(self): - """Wrapper for launcher close that hides passwords.""" - # Hide all passwords when launcher closes - self._hide_all_passwords() - # Call original close_launcher method - if hasattr(self, "_original_close_launcher"): - self._original_close_launcher() - - def _toggle_password_visibility(self, name: str): - """Toggle password visibility when Shift+Enter is pressed.""" - if name in self.revealed_passwords: - # Hide password - self.revealed_passwords.pop(name, None) - self._results_cache.clear() - else: - # Reveal password - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - self.revealed_passwords[name] = password - self._results_cache.clear() - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error revealing password: {e}") - - # Force refresh of the launcher to show updated state - self._force_launcher_refresh() - - def _force_launcher_refresh(self): - """Force the launcher to refresh and show updated results.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "_perform_search" - ): - # Get current search text - current_text = "" - if hasattr(self._launcher_instance, "search_entry"): - current_text = self._launcher_instance.search_entry.get_text() - - # Trigger a search to refresh results - try: - from gi.repository import GLib - - def refresh(): - self._launcher_instance._perform_search(current_text) - return False - - GLib.timeout_add(50, refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance._perform_search(current_text) - except Exception as e: - print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/power.py b/modules/launcher/plugins/power.py deleted file mode 100644 index 38b9cd86..00000000 --- a/modules/launcher/plugins/power.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import List - -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class PowerPlugin(PluginBase): - """ - Plugin for system power management operations. - """ - - def __init__(self): - super().__init__() - self.display_name = "Power" - self.description = "System power management and control" - self.commands = { - "shutdown": { - "description": "Shutdown the system", - "icon": "system-shutdown-symbolic", - "action": self.shutdown, - }, - "restart": { - "description": "Restart the system", - "icon": "system-reboot-symbolic", - "action": self.restart, - }, - "lock": { - "description": "Lock the screen", - "icon": "system-lock-screen-symbolic", - "action": self.lock, - }, - "suspend": { - "description": "Suspend the system", - "icon": "system-suspend-symbolic", - "action": self.suspend, - }, - "logout": { - "description": "Logout from current session", - "icon": "system-log-out-symbolic", - "action": self.logout, - }, - } - - def initialize(self): - """Initialize the power plugin.""" - self.set_triggers(["power"]) - - def cleanup(self): - """Cleanup the power plugin.""" - pass - - def query(self, query_string: str) -> List[Result]: - """Search power commands based on query.""" - query = query_string.lower().strip() - results = [] - - # If no query, show all power commands - if not query: - for cmd, info in self.commands.items(): - result = Result( - title=cmd.capitalize(), - subtitle=info["description"], - icon_name=info["icon"], - action=info["action"], - relevance=1.0, - plugin_name=self.display_name, - data={"command": cmd}, - ) - results.append(result) - else: - # Filter commands based on query - for cmd, info in self.commands.items(): - if query in cmd.lower() or query in info["description"].lower(): - result = Result( - title=cmd.capitalize(), - subtitle=info["description"], - icon_name=info["icon"], - action=info["action"], - relevance=1.0 if query == cmd else 0.7, - plugin_name=self.display_name, - data={"command": cmd}, - ) - results.append(result) - - return results - - def shutdown(self, *args) -> None: - exec_shell_command_async("systemctl poweroff") - - def restart(self, *args) -> None: - exec_shell_command_async("systemctl reboot") - - def lock(self, *args) -> None: - exec_shell_command_async("loginctl lock-session") - - def suspend(self, *args) -> None: - exec_shell_command_async("systemctl suspend") - - def logout(self, *args) -> None: - exec_shell_command_async("hyprctl dispatch exit") diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py deleted file mode 100644 index ded8c20b..00000000 --- a/modules/launcher/plugins/reminders.py +++ /dev/null @@ -1,448 +0,0 @@ -import re -import subprocess -import threading -from datetime import datetime, timedelta -from typing import Dict, List, Optional - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class Reminder: - """ - Represents a single reminder with its timer and metadata. - """ - - def __init__( - self, - reminder_id: int, - message: str, - target_time: datetime, - timer: threading.Timer, - ): - self.id = reminder_id - self.message = message - self.target_time = target_time - self.timer = timer - self.created_time = datetime.now() - - def cancel(self): - """Cancel this reminder.""" - if self.timer: - self.timer.cancel() - - def get_time_remaining(self) -> str: - """Get formatted time remaining until reminder.""" - now = datetime.now() - if self.target_time <= now: - return "Overdue" - - delta = self.target_time - now - total_seconds = int(delta.total_seconds()) - - if total_seconds < 60: - return f"{total_seconds}s" - elif total_seconds < 3600: - minutes = total_seconds // 60 - seconds = total_seconds % 60 - return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" - else: - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" - - def get_target_time_str(self) -> str: - """Get formatted target time.""" - return self.target_time.strftime("%H:%M") - - -class RemindersPlugin(PluginBase): - """ - Time-based reminders plugin for the launcher. - """ - - def __init__(self): - super().__init__() - self.display_name = "Reminders" - self.description = "Set time-based reminders with notifications" - self.reminders: Dict[int, Reminder] = {} - self.next_id = 1 - - # Regex patterns for time parsing - self.time_patterns = { - "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d - "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15 - "relative_with_unit": re.compile( - r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$", - re.IGNORECASE, - ), - } - - def initialize(self): - """Initialize the reminders plugin.""" - self.set_triggers(["remind"]) - - def cleanup(self): - """Cleanup the reminders plugin.""" - # Cancel all active reminders - for reminder in self.reminders.values(): - reminder.cancel() - self.reminders.clear() - - def _send_notification(self, title: str, message: str): - """Send a desktop notification using notify-send.""" - try: - subprocess.run( - ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message], - check=False, - ) - except Exception as e: - print(f"Failed to send notification: {e}") - - def _parse_time_input(self, time_str: str) -> Optional[datetime]: - """ - Parse various time input formats and return target datetime. - - Supported formats: - - 5m, 30s, 2h, 1d (relative time) - - 14:30, 9:15 (absolute time today) - - 5 minutes, 2 hours (relative with full unit names) - """ - time_str = time_str.strip().lower() - - # Try relative time format (5m, 30s, 2h, 1d) - match = self.time_patterns["relative_time"].match(time_str) - if match: - value, unit = match.groups() - value = int(value) - - if unit == "s": - delta = timedelta(seconds=value) - elif unit == "m": - delta = timedelta(minutes=value) - elif unit == "h": - delta = timedelta(hours=value) - elif unit == "d": - delta = timedelta(days=value) - else: - return None - - return datetime.now() + delta - - # Try absolute time format (14:30, 9:15) - match = self.time_patterns["absolute_time"].match(time_str) - if match: - hour, minute = map(int, match.groups()) - if 0 <= hour <= 23 and 0 <= minute <= 59: - target = datetime.now().replace( - hour=hour, minute=minute, second=0, microsecond=0 - ) - # If the time has already passed today, schedule for tomorrow - if target <= datetime.now(): - target += timedelta(days=1) - return target - - # Try relative time with full unit names - match = self.time_patterns["relative_with_unit"].match(time_str) - if match: - value, unit = match.groups() - value = int(value) - unit = unit.lower() - - if unit in ["sec", "seconds"]: - delta = timedelta(seconds=value) - elif unit in ["min", "mins", "minute", "minutes"]: - delta = timedelta(minutes=value) - elif unit in ["hour", "hours"]: - delta = timedelta(hours=value) - elif unit in ["day", "days"]: - delta = timedelta(days=value) - else: - return None - - return datetime.now() + delta - - return None - - def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]: - """Create a new reminder with the given time and message.""" - target_time = self._parse_time_input(time_str) - if not target_time: - return None - - # Calculate delay in seconds - delay = (target_time - datetime.now()).total_seconds() - if delay <= 0: - return None - - # Create timer that will trigger the notification - timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message]) - - # Create reminder object - reminder = Reminder(self.next_id, message, target_time, timer) - - # Store reminder and start timer - self.reminders[self.next_id] = reminder - timer.start() - - # Increment ID for next reminder - self.next_id += 1 - - return reminder - - def _trigger_reminder(self, reminder_id: int, message: str): - """Trigger a reminder notification and remove it from active reminders.""" - # Send notification - self._send_notification("⏰ Reminder", message) - - # Remove from active reminders - if reminder_id in self.reminders: - del self.reminders[reminder_id] - - def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int: - """Cancel a specific reminder or all reminders. Returns number of cancelled reminders.""" - if reminder_id is not None: - if reminder_id in self.reminders: - self.reminders[reminder_id].cancel() - del self.reminders[reminder_id] - return 1 - return 0 - else: - # Cancel all reminders - count = len(self.reminders) - for reminder in self.reminders.values(): - reminder.cancel() - self.reminders.clear() - return count - - def _format_time_remaining(self, total_seconds: float) -> str: - """Format time remaining in a human-readable way.""" - total_seconds = int(total_seconds) - - if total_seconds < 60: - return f"{total_seconds}s" - elif total_seconds < 3600: - minutes = total_seconds // 60 - seconds = total_seconds % 60 - return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" - else: - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" - - def _create_and_confirm_reminder(self, time_str: str, message: str): - """Actually create the reminder when the user presses Enter.""" - reminder = self._create_reminder(time_str, message) - if reminder: - time_remaining = reminder.get_time_remaining() - self._send_notification( - "✅ Reminder Created", f"Reminder set for {time_remaining}: {message}" - ) - else: - self._send_notification( - "❌ Failed to Create Reminder", "The specified time may be in the past" - ) - - def query(self, query_string: str) -> List[Result]: - """Process reminder queries.""" - results = [] - query = query_string.strip() - - if not query: - # Show help and active reminders count - active_count = len(self.reminders) - results.append( - Result( - title="Reminders Help", - subtitle=f"Active reminders: { - active_count - } | Usage: remind 5m Take a break", - icon_name="alarm-timer", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help"}, - ) - ) - - # Show quick examples - examples = [ - ("remind 5m Take a break", "Set 5 minute reminder"), - ("remind 14:30 Meeting", "Set reminder for 2:30 PM"), - ("remind list", "List active reminders"), - ("remind cancel", "Cancel all reminders"), - ] - - for example, desc in examples: - results.append( - Result( - title=example, - subtitle=desc, - icon_name="alarm-timer", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"type": "example"}, - ) - ) - - return results - - # Handle list command - if query.lower() in ["list", "ls", "show"]: - if not self.reminders: - results.append( - Result( - title="No Active Reminders", - subtitle="Use 'remind 5m message' to set a reminder", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty_list"}, - ) - ) - else: - for reminder in sorted( - self.reminders.values(), key=lambda r: r.target_time - ): - time_remaining = reminder.get_time_remaining() - target_time = reminder.get_target_time_str() - - results.append( - Result( - title=f"#{reminder.id}: {reminder.message}", - subtitle=f"In {time_remaining} (at {target_time})", - icon_name="alarm-timer", - action=lambda rid=reminder.id: self._cancel_reminder(rid), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "active_reminder", "id": reminder.id}, - ) - ) - - return results - - # Handle cancel command - if query.lower().startswith("cancel") or query.lower().startswith("stop"): - parts = query.split() - if len(parts) == 1: - # Cancel all reminders - count = self._cancel_reminder() - results.append( - Result( - title=f"Cancelled {count} Reminders", - subtitle="All active reminders have been cancelled", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "cancel_all"}, - ) - ) - else: - # Try to cancel specific reminder by ID - try: - reminder_id = int(parts[1]) - count = self._cancel_reminder(reminder_id) - if count > 0: - results.append( - Result( - title=f"Cancelled Reminder #{reminder_id}", - subtitle="Reminder has been cancelled", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "cancel_specific"}, - ) - ) - else: - results.append( - Result( - title="Reminder Not Found", - subtitle=f"No reminder with ID #{reminder_id}", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - except ValueError: - results.append( - Result( - title="Invalid Reminder ID", - subtitle="Please provide a valid reminder ID number", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - - return results - - # Handle setting new reminders - parts = query.split(None, 1) - if len(parts) >= 1: - time_str = parts[0] - message = parts[1] if len(parts) > 1 else "Reminder" - - # Try to parse the time (but don't create the reminder yet!) - target_time = self._parse_time_input(time_str) - if target_time: - # Calculate delay and check if it's valid - delay = (target_time - datetime.now()).total_seconds() - if delay > 0: - # Show what would happen, but don't create the reminder yet - time_remaining = self._format_time_remaining(delay) - target_time_str = target_time.strftime("%H:%M") - - results.append( - Result( - title=f"Set Reminder: {message}", - subtitle=f"Will remind in {time_remaining} (at { - target_time_str - })", - icon_name="alarm-timer", - action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder( - ts, msg - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "preview", - "time_str": time_str, - "message": message, - }, - ) - ) - else: - results.append( - Result( - title="Time is in the Past", - subtitle="Please specify a future time", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - else: - # Invalid time format - results.append( - Result( - title="Invalid Time Format", - subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - - return results diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py deleted file mode 100644 index 05253b6b..00000000 --- a/modules/launcher/plugins/screencapture.py +++ /dev/null @@ -1,687 +0,0 @@ -import subprocess -from typing import List - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class ScreencapturePlugin(PluginBase): - """ - Plugin for taking screenshots and screen recordings using screen-capture.sh script. - """ - - def __init__(self): - super().__init__() - self.display_name = "Screencapture" - self.description = "Take screenshots and screen recordings" - self.script_path = get_relative_path("../../../scripts/screen-capture.sh") - - def initialize(self): - """Initialize the screencapture plugin.""" - self.set_triggers(["sc"]) - - def cleanup(self): - """Cleanup the screencapture plugin.""" - pass - - def get_commands(self): - """Return available commands for this plugin.""" - return { - # Screenshot commands - "screenshot": "Take a screenshot of the main display", - "ss": "Take a screenshot of the main display", - "screenshot-region": "Take a screenshot of selected region", - "ss-region": "Take a screenshot of selected region", - "screenshot-both": "Take a screenshot of both displays", - "ss-both": "Take a screenshot of both displays", - "screenshot-hdmi": "Take a screenshot of HDMI display", - "ss-hdmi": "Take a screenshot of HDMI display", - # Recording commands (with audio) - "record": "Start recording main display with audio", - "rec": "Start recording main display with audio", - "record-region": "Start recording selected region with audio", - "rec-region": "Start recording selected region with audio", - "record-hdmi": "Start recording HDMI display with audio", - "rec-hdmi": "Start recording HDMI display with audio", - # Recording commands (no audio) - "record-noaudio": "Start recording main display without audio", - "rec-noaudio": "Start recording main display without audio", - "record-noaudio-region": "Start recording selected region without audio", - "rec-noaudio-region": "Start recording selected region without audio", - "record-noaudio-hdmi": "Start recording HDMI display without audio", - "rec-noaudio-hdmi": "Start recording HDMI display without audio", - # High-quality recording commands - "record-hq": "Start high-quality recording of main display", - "rec-hq": "Start high-quality recording of main display", - "record-hq-region": "Start high-quality recording of selected region", - "rec-hq-region": "Start high-quality recording of selected region", - "record-hq-hdmi": "Start high-quality recording of HDMI display", - "rec-hq-hdmi": "Start high-quality recording of HDMI display", - # GIF recording commands - "record-gif": "Start GIF recording of main display", - "rec-gif": "Start GIF recording of main display", - "record-gif-region": "Start GIF recording of selected region", - "rec-gif-region": "Start GIF recording of selected region", - # Control commands - "stop": "Stop current recording", - # Conversion commands - "convert-webm": "Convert latest MKV recording to WebM format", - "conv-webm": "Convert latest MKV recording to WebM format", - "convert-iphone": "Convert latest MKV recording for iPhone compatibility", - "conv-iphone": "Convert latest MKV recording for iPhone compatibility", - "convert-youtube": "Convert latest recording for YouTube upload", - "conv-youtube": "Convert latest recording for YouTube upload", - "convert-gif": "Convert latest recording to GIF format", - "conv-gif": "Convert latest recording to GIF format", - # Conversion commands with file input - "convert-webm-file": "Convert specific MKV file to WebM format", - "conv-webm-file": "Convert specific MKV file to WebM format", - "convert-iphone-file": "Convert specific MKV file for iPhone compatibility", - "conv-iphone-file": "Convert specific MKV file for iPhone compatibility", - "convert-youtube-file": "Convert specific video file for YouTube upload", - "conv-youtube-file": "Convert specific video file for YouTube upload", - "convert-gif-file": "Convert specific video file to GIF format", - "conv-gif-file": "Convert specific video file to GIF format", - } - - def _run_script(self, *args): - """Execute the screen-capture script with given arguments.""" - try: - subprocess.Popen([self.script_path] + list(args)) - except Exception as e: - print(f"Error running screen-capture script: {e}") - - def _run_script_with_file(self, format_type: str, file_path: str): - """Execute the screen-capture script with file parameter.""" - try: - subprocess.Popen([self.script_path, "convert", format_type, file_path]) - except Exception as e: - print(f"Error running screen-capture script with file: {e}") - - def _is_recording(self): - """Check if recording is currently active.""" - try: - result = subprocess.run( - [self.script_path, "status"], capture_output=True, text=True - ) - return result.stdout.strip() == "true" - except Exception: - return False - - def _get_command_result(self, command: str) -> Result: - """Get a Result object for a specific command.""" - # Import here to avoid circular imports - - command_info = { - # Screenshot commands - "screenshot": ( - "Take Screenshot (eDP-1)", - "Capture the main display", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "eDP-1"), - ), - "ss": ( - "Take Screenshot (eDP-1)", - "Capture the main display", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "eDP-1"), - ), - "screenshot-region": ( - "Take Region Screenshot", - "Capture a selected region", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "selection"), - ), - "ss-region": ( - "Take Region Screenshot", - "Capture a selected region", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "selection"), - ), - "screenshot-both": ( - "Take Screenshot (Both Displays)", - "Capture both displays combined", - "video-joined-displays-symbolic", - lambda: self._run_script("screenshot", "both"), - ), - "ss-both": ( - "Take Screenshot (Both Displays)", - "Capture both displays combined", - "video-joined-displays-symbolic", - lambda: self._run_script("screenshot", "both"), - ), - "screenshot-hdmi": ( - "Take Screenshot (HDMI-A-1)", - "Capture HDMI display", - "video-display-symbolic", - lambda: self._run_script("screenshot", "HDMI-A-1"), - ), - "ss-hdmi": ( - "Take Screenshot (HDMI-A-1)", - "Capture HDMI display", - "video-display-symbolic", - lambda: self._run_script("screenshot", "HDMI-A-1"), - ), - # Recording commands (with audio) - "record": ( - "Start Recording (eDP-1)", - "Record the main display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "eDP-1"), - ), - "rec": ( - "Start Recording (eDP-1)", - "Record the main display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "eDP-1"), - ), - "record-region": ( - "Start Region Recording", - "Record a selected region with audio", - "media-record-symbolic", - lambda: self._run_script("record", "selection"), - ), - "rec-region": ( - "Start Region Recording", - "Record a selected region with audio", - "media-record-symbolic", - lambda: self._run_script("record", "selection"), - ), - "record-hdmi": ( - "Start Recording (HDMI-A-1)", - "Record HDMI display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "HDMI-A-1"), - ), - "rec-hdmi": ( - "Start Recording (HDMI-A-1)", - "Record HDMI display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "HDMI-A-1"), - ), - # Recording commands (no audio) - "record-noaudio": ( - "Start Recording No Audio (eDP-1)", - "Record the main display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "eDP-1"), - ), - "rec-noaudio": ( - "Start Recording No Audio (eDP-1)", - "Record the main display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "eDP-1"), - ), - "record-noaudio-region": ( - "Start Region Recording No Audio", - "Record a selected region without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "selection"), - ), - "rec-noaudio-region": ( - "Start Region Recording No Audio", - "Record a selected region without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "selection"), - ), - "record-noaudio-hdmi": ( - "Start Recording No Audio (HDMI-A-1)", - "Record HDMI display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "HDMI-A-1"), - ), - "rec-noaudio-hdmi": ( - "Start Recording No Audio (HDMI-A-1)", - "Record HDMI display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "HDMI-A-1"), - ), - # High-quality recording commands - "record-hq": ( - "Start HQ Recording (eDP-1)", - "High-quality recording for YouTube", - "media-record-symbolic", - lambda: self._run_script("record-hq", "eDP-1"), - ), - "rec-hq": ( - "Start HQ Recording (eDP-1)", - "High-quality recording for YouTube", - "media-record-symbolic", - lambda: self._run_script("record-hq", "eDP-1"), - ), - "record-hq-region": ( - "Start HQ Region Recording", - "High-quality region recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "selection"), - ), - "rec-hq-region": ( - "Start HQ Region Recording", - "High-quality region recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "selection"), - ), - "record-hq-hdmi": ( - "Start HQ Recording (HDMI-A-1)", - "High-quality HDMI recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "HDMI-A-1"), - ), - "rec-hq-hdmi": ( - "Start HQ Recording (HDMI-A-1)", - "High-quality HDMI recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "HDMI-A-1"), - ), - # GIF recording commands - "record-gif": ( - "Start GIF Recording (eDP-1)", - "Record as optimized GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "eDP-1"), - ), - "rec-gif": ( - "Start GIF Recording (eDP-1)", - "Record as optimized GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "eDP-1"), - ), - "record-gif-region": ( - "Start GIF Region Recording", - "Record selected region as GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "selection"), - ), - "rec-gif-region": ( - "Start GIF Region Recording", - "Record selected region as GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "selection"), - ), - # Control commands - "stop": ( - "Stop Recording", - "Stop the current screen recording", - "media-playback-stop-symbolic", - lambda: self._run_script("record", "stop"), - ), - # Conversion commands - "convert-webm": ( - "Convert Latest to WebM", - "Convert latest MKV recording to WebM format", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "webm"), - ), - "conv-webm": ( - "Convert Latest to WebM", - "Convert latest MKV recording to WebM format", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "webm"), - ), - "convert-iphone": ( - "Convert Latest for iPhone", - "Convert latest MKV recording for iPhone compatibility", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "iphone"), - ), - "conv-iphone": ( - "Convert Latest for iPhone", - "Convert latest MKV recording for iPhone compatibility", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "iphone"), - ), - "convert-youtube": ( - "Convert Latest for YouTube", - "Convert latest recording for YouTube upload", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "youtube"), - ), - "conv-youtube": ( - "Convert Latest for YouTube", - "Convert latest recording for YouTube upload", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "youtube"), - ), - "convert-gif": ( - "Convert Latest to GIF", - "Convert latest recording to GIF format", - "image-x-generic-symbolic", - lambda: self._run_script("convert", "gif"), - ), - "conv-gif": ( - "Convert Latest to GIF", - "Convert latest recording to GIF format", - "image-x-generic-symbolic", - lambda: self._run_script("convert", "gif"), - ), - # File-based conversion commands (these will be handled specially) - "convert-webm-file": ( - "Convert File to WebM", - "Type filename after command (e.g., convert-webm-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-webm-file": ( - "Convert File to WebM", - "Type filename after command (e.g., conv-webm-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-iphone-file": ( - "Convert File for iPhone", - "Type filename after command (e.g., convert-iphone-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-iphone-file": ( - "Convert File for iPhone", - "Type filename after command (e.g., conv-iphone-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-youtube-file": ( - "Convert File for YouTube", - "Type filename after command (e.g., convert-youtube-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-youtube-file": ( - "Convert File for YouTube", - "Type filename after command (e.g., conv-youtube-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-gif-file": ( - "Convert File to GIF", - "Type filename after command (e.g., convert-gif-file video.mkv)", - "image-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-gif-file": ( - "Convert File to GIF", - "Type filename after command (e.g., conv-gif-file video.mkv)", - "image-x-generic-symbolic", - None, # Will be handled in query method - ), - } - - if command in command_info: - title, subtitle, icon, action = command_info[command] - if action is not None: # Regular command - return Result( - title=title, - subtitle=subtitle, - icon_name=icon, - action=action, - relevance=1.0, - plugin_name=self.display_name, - ) - else: # File-based command, show instruction - return Result( - title=title, - subtitle=subtitle, - icon_name=icon, - action=lambda: None, # No action for instruction - relevance=1.0, - plugin_name=self.display_name, - ) - - return None - - def query(self, query_string: str) -> List[Result]: - """Search for screencapture actions based on query.""" - # Import here to avoid circular imports - - # Clean the query string - query = query_string.strip().lower() - - results = [] - - # Check for file-based conversion commands with parameters - file_conversion_commands = { - "convert-webm-file": "webm", - "conv-webm-file": "webm", - "convert-iphone-file": "iphone", - "conv-iphone-file": "iphone", - "convert-youtube-file": "youtube", - "conv-youtube-file": "youtube", - "convert-gif-file": "gif", - "conv-gif-file": "gif", - } - - # Parse query for file-based commands - query_parts = query.split() - if len(query_parts) >= 2: - command = query_parts[0] - file_param = " ".join(query_parts[1:]) - - if command in file_conversion_commands: - format_type = file_conversion_commands[command] - return [ - Result( - title=f"Convert {file_param} to {format_type.upper()}", - subtitle=f"Convert specified file to {format_type} format", - icon_name=( - "video-x-generic-symbolic" - if format_type != "gif" - else "image-x-generic-symbolic" - ), - action=lambda fp=file_param, ft=format_type: self._run_script_with_file( - ft, fp - ), - relevance=1.0, - plugin_name=self.display_name, - ) - ] - - # Check if query matches a command and return it as a result - command_result = self._get_command_result(query) - if command_result: - return [command_result] - - # Check recording status - is_recording = self._is_recording() - - # If recording is active, show stop button first with highest relevance - if is_recording: - results.append( - Result( - title="Stop Recording", - subtitle="Stop the current screen recording", - icon_name="media-playback-stop-symbolic", - action=lambda: self._run_script("record", "stop"), - relevance=2.0, # Highest relevance to appear at top - plugin_name=self.display_name, - ) - ) - - # Screenshot actions - results.extend( - [ - Result( - title="Take Screenshot", - subtitle="Capture the entire screen (eDP-1)", - icon_name="camera-photo-symbolic", - action=lambda: self._run_script("screenshot", "eDP-1"), - relevance=1.0, - plugin_name=self.display_name, - ), - Result( - title="Take Region Screenshot", - subtitle="Capture a selected region", - icon_name="camera-photo-symbolic", - action=lambda: self._run_script("screenshot", "selection"), - relevance=0.9, - plugin_name=self.display_name, - ), - Result( - title="Take Screenshot (Both Displays)", - subtitle="Capture both displays combined", - icon_name="video-joined-displays-symbolic", - action=lambda: self._run_script("screenshot", "both"), - relevance=0.8, - plugin_name=self.display_name, - ), - Result( - title="Take Screenshot (HDMI-A-1)", - subtitle="Capture HDMI display", - icon_name="video-display-symbolic", - action=lambda: self._run_script("screenshot", "HDMI-A-1"), - relevance=0.7, - plugin_name=self.display_name, - ), - ] - ) - - # Standard recording actions - results.extend( - [ - Result( - title="Start Recording (eDP-1)", - subtitle="Record the main display with audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "eDP-1"), - relevance=0.7, - plugin_name=self.display_name, - ), - Result( - title="Start Region Recording", - subtitle="Record a selected region", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "selection"), - relevance=0.6, - plugin_name=self.display_name, - ), - Result( - title="Start Recording (HDMI-A-1)", - subtitle="Record HDMI display with audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "HDMI-A-1"), - relevance=0.5, - plugin_name=self.display_name, - ), - ] - ) - - # No-audio recording actions - results.extend( - [ - Result( - title="Start Recording No Audio (eDP-1)", - subtitle="Record the main display without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "eDP-1"), - relevance=0.65, - plugin_name=self.display_name, - ), - Result( - title="Start Region Recording No Audio", - subtitle="Record a selected region without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "selection"), - relevance=0.55, - plugin_name=self.display_name, - ), - Result( - title="Start Recording No Audio (HDMI-A-1)", - subtitle="Record HDMI display without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "HDMI-A-1"), - relevance=0.45, - plugin_name=self.display_name, - ), - ] - ) - - # High-quality recording actions - results.extend( - [ - Result( - title="Start HQ Recording (eDP-1)", - subtitle="High-quality recording for YouTube", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "eDP-1"), - relevance=0.4, - plugin_name=self.display_name, - ), - Result( - title="Start HQ Region Recording", - subtitle="High-quality region recording", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "selection"), - relevance=0.3, - plugin_name=self.display_name, - ), - Result( - title="Start HQ Recording (HDMI-A-1)", - subtitle="High-quality HDMI recording", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "HDMI-A-1"), - relevance=0.2, - plugin_name=self.display_name, - ), - ] - ) - - # GIF recording actions - results.extend( - [ - Result( - title="Start GIF Recording (eDP-1)", - subtitle="Record as optimized GIF", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-gif", "eDP-1"), - relevance=0.1, - plugin_name=self.display_name, - ), - Result( - title="Start GIF Region Recording", - subtitle="Record selected region as GIF", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-gif", "selection"), - relevance=0.05, - plugin_name=self.display_name, - ), - ] - ) - - # Conversion actions - results.extend( - [ - Result( - title="Convert Latest to WebM", - subtitle="Convert latest MKV recording to WebM format", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "webm"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest for iPhone", - subtitle="Convert latest MKV recording for iPhone compatibility", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "iphone"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest for YouTube", - subtitle="Convert latest recording for YouTube upload", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "youtube"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest to GIF", - subtitle="Convert latest recording to GIF format", - icon_name="image-x-generic-symbolic", - action=lambda: self._run_script("convert", "gif"), - relevance=0.01, - plugin_name=self.display_name, - ), - ] - ) - - return results diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py deleted file mode 100644 index bc7dba09..00000000 --- a/modules/launcher/plugins/system.py +++ /dev/null @@ -1,242 +0,0 @@ -import json -import os -import shlex -import threading -import time -from typing import List, Set, Union - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class SystemPlugin(PluginBase): - """ - Plugin for system commands and actions. - """ - - def __init__(self): - super().__init__() - self.display_name = "System" - self.description = "System commands and actions" - - # JSON cache file for system binaries - self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json") - - # In-memory cache for system binaries - self._bin_cache: Set[str] = set() - self._last_bin_update = 0 - self._bin_update_interval = 300 # 5 minutes - - # Background cache building - self._cache_building = False - self._cache_thread = None - - def initialize(self): - """Initialize the system plugin.""" - self.set_triggers(["bin"]) - self._load_bin_cache() - self._start_background_cache_update() - - def cleanup(self): - """Cleanup the system plugin.""" - self._bin_cache.clear() - if self._cache_thread and self._cache_thread.is_alive(): - # Note: We don't join the thread to avoid blocking cleanup - pass - - def _load_bin_cache(self): - """Load binary cache from JSON file.""" - try: - if os.path.exists(self.bin_cache_file): - with open(self.bin_cache_file, "r", encoding="utf-8") as f: - cache_data = json.load(f) - self._bin_cache = set(cache_data.get("binaries", [])) - self._last_bin_update = cache_data.get("last_update", 0) - else: - print( - "SystemPlugin: No cache file found, will build cache in background" - ) - except Exception as e: - print(f"SystemPlugin: Error loading binary cache: {e}") - self._bin_cache = set() - self._last_bin_update = 0 - - def _save_bin_cache(self): - """Save binary cache to JSON file.""" - try: - # Ensure the cache directory exists - os.makedirs(data.CACHE_DIR, exist_ok=True) - - cache_data = { - "binaries": sorted(list(self._bin_cache)), - "last_update": self._last_bin_update, - "cache_version": "1.0", - } - - with open(self.bin_cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - except Exception as e: - print(f"SystemPlugin: Error saving binary cache: {e}") - - def _start_background_cache_update(self): - """Start background thread to update binary cache.""" - current_time = time.time() - - # Check if cache needs updating - if ( - current_time - self._last_bin_update > self._bin_update_interval - or not self._bin_cache - ): - if not self._cache_building: - self._cache_building = True - self._cache_thread = threading.Thread( - target=self._build_bin_cache_background, daemon=True - ) - self._cache_thread.start() - - def _build_bin_cache_background(self): - """Build binary cache in background thread.""" - try: - new_cache = set() - processed_paths = set() # Avoid duplicate paths - - for path in os.environ["PATH"].split(":"): - # Skip empty paths and duplicates - if not path or path in processed_paths: - continue - processed_paths.add(path) - - if os.path.exists(path) and os.path.isdir(path): - try: - # Use os.scandir for better performance than os.listdir - with os.scandir(path) as entries: - for entry in entries: - if entry.is_file(follow_symlinks=False) and os.access( - entry.path, os.X_OK - ): - new_cache.add(entry.name) - except (PermissionError, FileNotFoundError, OSError): - continue - - # Update cache atomically - self._bin_cache = new_cache - self._last_bin_update = time.time() - - # Save to disk - self._save_bin_cache() - - except Exception as e: - print(f"SystemPlugin: Error building binary cache: {e}") - finally: - self._cache_building = False - - def query(self, query_string: str) -> List[Result]: - """Search for system commands matching the query.""" - query = query_string.strip() - - if not query: - return [] - - results = [] - - # Parse the query to extract binary name and arguments - query_parts = query.split() - if not query_parts: - return [] - - binary_query = query_parts[0].lower() - full_command = query # Keep the original case and spacing - - # Check system binaries - # Start background update if needed (non-blocking) - but only if cache is empty or very old - if not self._bin_cache or ( - time.time() - self._last_bin_update > self._bin_update_interval - ): - self._start_background_cache_update() - - # Optimize search with early termination and result limiting - exact_matches = [] - prefix_matches = [] - partial_matches = [] - max_results = 20 # Limit total results for performance - - for binary in self._bin_cache: - # Pre-compute lowercase once - binary_lower = binary.lower() - - # Skip if no match at all - if binary_query not in binary_lower: - continue - - # Categorize matches for better sorting - if binary_lower == binary_query: - # Exact match - highest priority - display_command = full_command - command_to_execute = full_command - relevance = 1.0 - exact_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - elif binary_lower.startswith(binary_query): - # Prefix match - high priority - display_command = binary - command_to_execute = binary - relevance = 0.9 - prefix_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - else: - # Partial match - lower priority - display_command = binary - command_to_execute = binary - relevance = 0.7 - partial_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - - # Early termination if we have enough good matches - if len(exact_matches) + len(prefix_matches) >= max_results: - break - - # Combine results in priority order - all_matches = exact_matches + prefix_matches + partial_matches - - # Convert to Result objects (limit to max_results) - for binary, display_command, command_to_execute, relevance in all_matches[ - :max_results - ]: - result = Result( - title=display_command, - subtitle=f"Execute: {display_command}", - icon_name="terminal", - action=self._create_action(command_to_execute), - relevance=relevance, - plugin_name=self.display_name, - data={"command": command_to_execute, "id": binary}, - ) - results.append(result) - - return results # Already sorted by priority - - def _create_action(self, command: Union[str, List[str]]): - """Create an action function for the given command.""" - - def action(): - self._execute_command(command) - - return action - - def _execute_command(self, command: Union[str, List[str]]): - """Execute a system command.""" - try: - if isinstance(command, str): - # Handle string commands with arguments - split into list for proper execution - command_list = shlex.split(command) - exec_shell_command_async(command_list) - else: - # Handle list commands (backward compatibility) - exec_shell_command_async(command) - except Exception as e: - print(f"SystemPlugin: Error executing command '{command}': {e}") diff --git a/modules/launcher/plugins/tmux.py b/modules/launcher/plugins/tmux.py deleted file mode 100644 index 5c93fadb..00000000 --- a/modules/launcher/plugins/tmux.py +++ /dev/null @@ -1,334 +0,0 @@ -import subprocess -import threading -import time -from typing import List - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class TmuxPlugin(PluginBase): - """ - Plugin for managing tmux sessions through the launcher. - """ - - def __init__(self): - super().__init__() - self.display_name = "Tmux Manager" - self.description = "Manage tmux sessions - create, attach, rename, and kill" - - # Cache for sessions to avoid repeated subprocess calls - self._sessions_cache = [] - self._last_cache_update = 0 - # Cache sessions for 10 seconds (increased from 2) - self._cache_ttl = 10 - - # Threading for auto-refresh - only when actively used - self.refresh_thread = None - self.stop_refresh = threading.Event() - self._last_query_time = 0 - # Stop refreshing after 30 seconds of inactivity - self._active_refresh_timeout = 30 - - def initialize(self): - """Initialize the tmux plugin.""" - self.set_triggers(["tmux"]) - # Don't start refresh thread immediately - start it when first used - - def cleanup(self): - """Cleanup the tmux plugin.""" - self.stop_refresh.set() - if self.refresh_thread: - self.refresh_thread.join(timeout=1) - self._sessions_cache.clear() - - def _start_refresh_thread(self): - """Start background thread to refresh session cache.""" - if not self.refresh_thread or not self.refresh_thread.is_alive(): - self.refresh_thread = threading.Thread( - target=self._refresh_sessions_background, daemon=True - ) - self.refresh_thread.start() - - def _refresh_sessions_background(self): - """Background thread to refresh sessions cache only when actively used.""" - while not self.stop_refresh.is_set(): - try: - current_time = time.time() - - # Stop refreshing if plugin hasn't been used recently - if current_time - self._last_query_time > self._active_refresh_timeout: - print("TmuxPlugin: Stopping background refresh due to inactivity") - break - - if current_time - self._last_cache_update > self._cache_ttl: - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = current_time - - self.stop_refresh.wait(5) - except Exception as e: - print(f"TmuxPlugin: Error in refresh thread: {e}") - self.stop_refresh.wait(10) # Wait longer on error - - def _get_tmux_sessions(self): - """Get list of tmux sessions.""" - try: - result = subprocess.run( - ["tmux", "list-sessions", "-F", "#{session_name}"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - return [ - s.strip() for s in result.stdout.strip().split("\n") if s.strip() - ] - return [] - except ( - subprocess.TimeoutExpired, - subprocess.CalledProcessError, - FileNotFoundError, - ) as e: - print(f"TmuxPlugin: Error getting tmux sessions: {e}") - return [] - - def query(self, query_string: str) -> List[Result]: - """Process tmux queries.""" - query = query_string.strip().lower() - results = [] - - # Track usage and start refresh thread if needed - current_time = time.time() - self._last_query_time = current_time - - # Start refresh thread if not running and plugin is being used - if not self.refresh_thread or not self.refresh_thread.is_alive(): - self._start_refresh_thread() - - # Get current sessions (use cache if recent) - if current_time - self._last_cache_update > self._cache_ttl: - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = current_time - - sessions = self._sessions_cache - - # Handle specific commands - if query.startswith("new ") or query.startswith("create "): - session_name = query.split(" ", 1)[1].strip() if " " in query else "" - results.append(self._create_new_session_result(session_name)) - - elif query.startswith("kill ") or query.startswith("delete "): - session_name = query.split(" ", 1)[1].strip() if " " in query else "" - if session_name: - matching_sessions = [ - s for s in sessions if session_name.lower() in s.lower() - ] - for session in matching_sessions: - results.append(self._create_kill_session_result(session)) - - elif query.startswith("rename "): - parts = query.split(" ", 2) - if len(parts) >= 3: - old_name, new_name = parts[1], parts[2] - if old_name in sessions: - results.append( - self._create_rename_session_result(old_name, new_name) - ) - - else: - # Show existing sessions for attachment - if sessions: - # Filter sessions based on query - if query: - filtered_sessions = [s for s in sessions if query in s.lower()] - else: - filtered_sessions = sessions - - for session in filtered_sessions: - results.append(self._create_attach_session_result(session)) - - # Always show option to create new session - if not query or "new" in query or "create" in query: - results.append( - self._create_new_session_result( - query - if query and not any(cmd in query for cmd in ["new", "create"]) - else "" - ) - ) - - return results - - def _create_attach_session_result(self, session_name: str) -> Result: - """Create result for attaching to a session.""" - return Result( - title=f"Attach to '{session_name}'", - subtitle=f"Connect to tmux session: {session_name}", - icon_name="terminal", - action=lambda: self._attach_to_session(session_name), - relevance=0.9, - data={"type": "attach", "session": session_name}, - ) - - def _create_new_session_result(self, session_name: str = "") -> Result: - """Create result for creating a new session.""" - display_name = session_name if session_name else "new session" - return Result( - title=f"Create '{display_name}'", - subtitle=f"Create new tmux session{ - f': {session_name}' if session_name else '' - }", - icon_name="plus", - action=lambda: self._create_session(session_name), - relevance=0.8, - data={"type": "create", "session": session_name}, - ) - - def _create_kill_session_result(self, session_name: str) -> Result: - """Create result for killing a session.""" - return Result( - title=f"Kill '{session_name}'", - subtitle=f"Terminate tmux session: {session_name}", - icon_name="trash", - action=lambda: self._kill_session(session_name), - relevance=0.7, - data={"type": "kill", "session": session_name}, - ) - - def _create_rename_session_result(self, old_name: str, new_name: str) -> Result: - """Create result for renaming a session.""" - return Result( - title=f"Rename '{old_name}' to '{new_name}'", - subtitle=f"Rename tmux session from {old_name} to {new_name}", - icon_name="config", - action=lambda: self._rename_session(old_name, new_name), - relevance=0.6, - data={"type": "rename", "old_session": old_name, "new_session": new_name}, - ) - - def _attach_to_session(self, session_name: str): - """Attach to an existing tmux session.""" - try: - terminal_cmd = self._get_terminal_command( - f"tmux attach-session -t '{session_name}'" - ) - exec_shell_command_async(terminal_cmd) - print(f"TmuxPlugin: Attaching to session '{session_name}'") - except Exception as e: - print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}") - - def _create_session(self, session_name: str = ""): - """Create a new tmux session.""" - try: - if not session_name: - # Generate a default name - existing_sessions = self._get_tmux_sessions() - counter = 0 - while str(counter) in existing_sessions: - counter += 1 - session_name = str(counter) - - # Clean the session name - clean_name = session_name.strip().replace(" ", "_") - - # Create session - subprocess.run( - ["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10 - ) - - # Launch terminal and attach - terminal_cmd = self._get_terminal_command( - f"tmux attach-session -t '{clean_name}'" - ) - exec_shell_command_async(terminal_cmd) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Created and attached to session '{clean_name}'") - except Exception as e: - print(f"TmuxPlugin: Error creating session '{session_name}': {e}") - - def _kill_session(self, session_name: str): - """Kill a tmux session.""" - try: - subprocess.run( - ["tmux", "kill-session", "-t", session_name], check=True, timeout=10 - ) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Killed session '{session_name}'") - except Exception as e: - print(f"TmuxPlugin: Error killing session '{session_name}': {e}") - - def _rename_session(self, old_name: str, new_name: str): - """Rename a tmux session.""" - try: - clean_name = new_name.strip().replace(" ", "_") - subprocess.run( - ["tmux", "rename-session", "-t", old_name, clean_name], - check=True, - timeout=10, - ) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'") - except Exception as e: - print( - f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}" - ) - - def _get_terminal_command(self, cmd: str) -> str: - """Get terminal command based on configured terminal or available terminals.""" - # First try to use the configured terminal command - if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND: - parts = data.TERMINAL_COMMAND.split() - terminal = parts[0] - - try: - # Check if the configured terminal is available - subprocess.run( - ["which", terminal], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return f"{data.TERMINAL_COMMAND} {cmd}" - except subprocess.CalledProcessError: - # If configured terminal is not available, fall back to defaults - pass - - # Fallback to checking available terminals - terminals = [ - ("kitty", f"kitty -e {cmd}"), - ("alacritty", f"alacritty -e {cmd}"), - ("foot", f"foot {cmd}"), - ("gnome-terminal", f"gnome-terminal -- {cmd}"), - ("konsole", f"konsole -e {cmd}"), - ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), - ] - - for term, term_cmd in terminals: - try: - # Check if terminal is available - subprocess.run( - ["which", term], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return term_cmd - except subprocess.CalledProcessError: - continue - - # Default fallback - return f"kitty -e {cmd}" diff --git a/modules/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py deleted file mode 100644 index 3d4aa28f..00000000 --- a/modules/launcher/plugins/wallpaper.py +++ /dev/null @@ -1,976 +0,0 @@ -import colorsys -import hashlib -import json -import os -import random -import re -import threading -import time -from typing import Dict, List, Optional -from loguru import logger - -from gi.repository import GdkPixbuf -from PIL import Image - -import config.data as data -from fabric.utils.helpers import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class WallpaperPlugin(PluginBase): - """ - Plugin for wallpaper management with search, random selection, and matugen integration. - """ - - def __init__(self): - super().__init__() - self.display_name = "Wallpaper" - self.wallpapers = [] - self.cache_dir = f"{data.CACHE_DIR}/thumbs" - self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {} - self.thumbnail_loading = set() # Track which thumbnails are being loaded - self.schemes = { - "scheme-tonal-spot": "Tonal Spot", - "scheme-content": "Content", - "scheme-expressive": "Expressive", - "scheme-fidelity": "Fidelity", - "scheme-fruit-salad": "Fruit Salad", - "scheme-monochrome": "Monochrome", - "scheme-neutral": "Neutral", - "scheme-rainbow": "Rainbow", - } - - def initialize(self): - """Initialize the wallpaper plugin.""" - self.set_triggers(["wall"]) - self._load_wallpapers() - os.makedirs(self.cache_dir, exist_ok=True) - # Start background thumbnail creation - self._start_background_thumbnail_creation() - - def cleanup(self): - """Cleanup the wallpaper plugin.""" - pass - - def _load_wallpapers(self): - """Load available wallpapers from the wallpapers directory.""" - try: - if os.path.exists(data.WALLPAPERS_DIR): - self.wallpapers = sorted( - [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] - ) - else: - logger.error(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}") - except Exception as e: - logger.error(f"Error loading wallpapers: {e}") - - def _is_image(self, filename: str) -> bool: - """Check if file is a supported image format.""" - return filename.lower().endswith( - (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - ) - - def _get_matugen_state(self) -> bool: - """Get current matugen state from config.json.""" - self.matugen_enabled = True # Default to True - try: - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - self.matugen_enabled = config.get("matugen_enabled", True) - except FileNotFoundError: - # File doesn't exist, keep default True - pass - except Exception as e: - logger.error(f"Error reading config file: {e}") - # Keep default True on error - - return self.matugen_enabled - - def _set_matugen_state(self, enabled: bool): - """Set matugen state and save to config.json.""" - self.matugen_enabled = enabled - - # Save the state to config.json - try: - # Read current config - config = {} - if os.path.exists(data.CONFIG_FILE): - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - - # Update matugen state - config["matugen_enabled"] = enabled - - # Write back to config file - with open(data.CONFIG_FILE, "w") as f: - json.dump(config, f, indent=4) - - except Exception as e: - logger.error(f"Error writing matugen state to config: {e}") - - # Clear the search query after toggling matugen - self._clear_launcher_query() - - # # Send notification - # status = "enabled" if enabled else "disabled" - # exec_shell_command_async( - # f"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{ - # data.APP_NAME_CAP - # }' -e" - # ) - - def _clear_launcher_query(self): - """Clear the launcher search query and reset to trigger.""" - try: - # Try to access the launcher through the fabric Application - from gi.repository import GLib - - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if launcher and hasattr(launcher, "search_entry"): - - def clear_and_refresh(): - # Clear the search entry to just the trigger - launcher.search_entry.set_text("wall ") - # Position cursor at the end - launcher.search_entry.set_position(-1) - # Trigger the search to show default wallpaper view - if hasattr(launcher, "_perform_search"): - launcher._perform_search("wall ") - return False - - # Use a small delay to ensure the action completes first - GLib.timeout_add(50, clear_and_refresh) - return - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher": - if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"): - - def clear_and_refresh(): - obj.search_entry.set_text("wall ") - obj.search_entry.set_position(-1) - obj._perform_search("wall ") - return False - - GLib.timeout_add(50, clear_and_refresh) - return - - except Exception as e: - logger.error(f"Could not clear launcher query: {e}") - - def _get_cache_path(self, filename: str) -> str: - """Get cache path for wallpaper thumbnail.""" - file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest() - return os.path.join(self.cache_dir, f"{file_hash}.png") - - def _create_thumbnail(self, filename: str) -> str: - """Create thumbnail for wallpaper if it doesn't exist.""" - full_path = os.path.join(data.WALLPAPERS_DIR, filename) - cache_path = self._get_cache_path(filename) - - if not os.path.exists(cache_path): - try: - with Image.open(full_path) as img: - # Use faster thumbnail creation with smaller size for better performance - img.thumbnail((32, 32), Image.Resampling.LANCZOS) - img.save(cache_path, "PNG", optimize=True) - except Exception as e: - logger.error(f"Error creating thumbnail for {filename}: {e}") - return None - - return cache_path - - def _start_background_thumbnail_creation(self): - """Start background thread to create thumbnails for all wallpapers.""" - - def create_thumbnails(): - for wallpaper in self.wallpapers: - if wallpaper not in self.thumbnail_loading: - self.thumbnail_loading.add(wallpaper) - try: - cache_path = self._create_thumbnail(wallpaper) - if cache_path and os.path.exists(cache_path): - # Load thumbnail into memory cache - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[wallpaper] = pixbuf - except Exception as e: - logger.error( - f"Error loading thumbnail for {wallpaper}: {e}" - ) - self.thumbnail_cache[wallpaper] = None - else: - self.thumbnail_cache[wallpaper] = None - except Exception as e: - logger.error(f"Error processing thumbnail for {wallpaper}: {e}") - self.thumbnail_cache[wallpaper] = None - finally: - self.thumbnail_loading.discard(wallpaper) - - # Small delay to prevent overwhelming the system - time.sleep(0.01) - - # Start background thread - thread = threading.Thread(target=create_thumbnails, daemon=True) - thread.start() - - def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]: - """Get thumbnail quickly from cache or return None if not ready.""" - # Return cached thumbnail if available - if filename in self.thumbnail_cache: - return self.thumbnail_cache[filename] - - # Check if thumbnail file exists and load it immediately - cache_path = self._get_cache_path(filename) - if os.path.exists(cache_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[filename] = pixbuf - return pixbuf - except Exception as e: - logger.error(f"Error loading thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - return None - - # If not in cache and file doesn't exist, trigger background creation - if filename not in self.thumbnail_loading: - self.thumbnail_loading.add(filename) - - def create_async(): - try: - cache_path = self._create_thumbnail(filename) - if cache_path and os.path.exists(cache_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[filename] = pixbuf - except Exception as e: - print(f"Error loading thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - else: - self.thumbnail_cache[filename] = None - except Exception as e: - print(f"Error creating thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - finally: - self.thumbnail_loading.discard(filename) - - thread = threading.Thread(target=create_async, daemon=True) - thread.start() - - return None - - def _set_wallpaper(self, filename: str, scheme: str = None): - """Set wallpaper and apply matugen color scheme if enabled.""" - full_path = os.path.join(data.WALLPAPERS_DIR, filename) - current_wall = os.path.expanduser("~/.current.wall") - - if scheme is None: - scheme = self._get_current_scheme() - - # Update current wallpaper symlink - if os.path.isfile(current_wall) or os.path.islink(current_wall): - os.remove(current_wall) - os.symlink(full_path, current_wall) - - # Always set the wallpaper image - exec_shell_command_async( - f'swww img "{ - full_path - }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' - ) - - # If Matugen is enabled, also apply the color scheme - matugen_enabled = self._get_matugen_state() - if matugen_enabled: - exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}') - - def _set_random_wallpaper(self): - """Set a random wallpaper.""" - if not self.wallpapers: - return - - filename = random.choice(self.wallpapers) - self._set_wallpaper(filename) - # Show notification for immediate feedback - exec_shell_command_async( - f"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - return filename - - def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: - """Convert HSL color value to RGB HEX string.""" - # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 - hue = h / 360.0 - r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S - r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) - return f"#{r_int:02X}{g_int:02X}{b_int:02X}" - - def _is_valid_hex_color(self, hex_color: str) -> bool: - """Check if string is a valid hex color.""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color)) - - def _get_current_scheme(self) -> str: - """Get current color scheme from config (default to tonal-spot).""" - try: - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - return config.get("current_scheme", "scheme-tonal-spot") - except FileNotFoundError: - # File doesn't exist, return default - return "scheme-tonal-spot" - except Exception as e: - print(f"Error reading current scheme from config: {e}") - return "scheme-tonal-spot" - - def _set_current_scheme(self, scheme: str): - """Set current color scheme and apply it.""" - scheme_name = self.schemes.get(scheme, scheme) - matugen_enabled = self._get_matugen_state() - - # Save the scheme to config - try: - # Read current config - config = {} - if os.path.exists(data.CONFIG_FILE): - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - - # Update current scheme - config["current_scheme"] = scheme - - # Write back to config file - with open(data.CONFIG_FILE, "w") as f: - json.dump(config, f, indent=4) - - except Exception as e: - print(f"Error saving current scheme to config: {e}") - return - - # Apply the scheme to current wallpaper if matugen is enabled - if matugen_enabled: - current_wall = os.path.expanduser("~/.current.wall") - if os.path.exists(current_wall) and os.path.islink(current_wall): - # Get the current wallpaper path - wallpaper_path = os.readlink(current_wall) - if os.path.exists(wallpaper_path): - # Apply the new scheme to current wallpaper - exec_shell_command_async( - f'matugen image "{wallpaper_path}" -t {scheme}' - ) - - # Send notification - exec_shell_command_async( - f"notify-send '🎨 Color Scheme' 'Applied { - scheme_name - } scheme' -a '{data.APP_NAME_CAP}' -e" - ) - else: - # No current wallpaper, just show scheme change notification - exec_shell_command_async( - f"notify-send '🎨 Color Scheme' 'Set to { - scheme_name - } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e" - ) - else: - # No current wallpaper, just show scheme change notification - exec_shell_command_async( - f"notify-send '🎨 Color Scheme' 'Set to { - scheme_name - } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e" - ) - else: - # Matugen is disabled, just save the setting - exec_shell_command_async( - f"notify-send '🎨 Color Scheme' 'Set to { - scheme_name - } (matugen disabled)' -a '{data.APP_NAME_CAP}' -e" - ) - - def _apply_hex_color(self, hex_color: str, scheme: str = None): - """Apply hex color using matugen. Assumes matugen is enabled.""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - - if scheme is None: - scheme = self._get_current_scheme() - - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') - - def _apply_hex_color_direct(self, hex_color: str, scheme: str = None): - """Apply hex color using matugen (following example_wallpapers.py pattern).""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - - if scheme is None: - scheme = self._get_current_scheme() - - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') - - # Send notification - exec_shell_command_async( - f"notify-send '🎨 Hex Color Applied' 'Applied color: {hex_color}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - - def _generate_random_hex_color(self) -> str: - """Generate a random hex color.""" - hue = random.randint(0, 360) - return self._hsl_to_rgb_hex(hue) - - def _get_status_indicators(self) -> tuple: - """Get current status indicators for display.""" - current_scheme = self._get_current_scheme() - matugen_enabled = self._get_matugen_state() - scheme_name = self.schemes.get(current_scheme, current_scheme) - - indicators = [] - if not matugen_enabled: - indicators.append("Matugen Off") - - indicator_text = " • " + " • ".join(indicators) if indicators else "" - status_text = f"Matugen: { - 'Enabled' if matugen_enabled else 'Disabled' - } • Scheme: {scheme_name}" - - return indicator_text, status_text, current_scheme, matugen_enabled - - def query(self, query_string: str) -> List[Result]: - """Search wallpapers and provide management options.""" - results = [] - query = query_string.lower().strip() - - # Get status indicators for consistent display - indicator_text, status_text, current_scheme, matugen_enabled = ( - self._get_status_indicators() - ) - - # Special commands - if query.strip() == "random": - # Show result for random wallpaper (execute on Enter) - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper • {status_text}", - icon_name="media-playlist-shuffle-symbolic", - action=lambda: self._set_random_wallpaper(), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.startswith("random") and query.strip() != "random": - # Show suggestion for random wallpaper (partial match) - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper • {status_text}", - icon_name="media-playlist-shuffle-symbolic", - action=lambda: self._set_random_wallpaper(), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "random_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Hex color commands - if "color" in query or "hex" in query or query.startswith("#"): - # Check for scheme specification in the query - scheme = self._get_current_scheme() - for scheme_id, scheme_name in self.schemes.items(): - if ( - scheme_name.lower() in query.lower() - or scheme_id.lower() in query.lower() - ): - scheme = scheme_id - break - - # Check for hex color patterns - hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query) - if hex_match: - hex_color = "#" + hex_match.group(1) - scheme_name = self.schemes.get(scheme, scheme) - - # Check if this is a complete hex color input (6 digits) - # Execute immediately when we have a complete 6-digit hex color - if len(hex_match.group(1)) == 6: - # Check if there's additional text after the hex color - hex_end_pos = hex_match.end() - remaining_text = query[hex_end_pos:].strip() - - # Only show result if no additional text after hex color (exact match) - if not remaining_text: - # Hex colors work when matugen is OFF (following example_wallpapers.py pattern) - if not matugen_enabled: - # Show result for hex color (execute on Enter) - results.append( - Result( - title=f"Apply Hex Color: {hex_color}{ - indicator_text - }", - subtitle=f"Apply with {scheme_name} scheme • { - status_text - }", - icon_name="color-picker-symbolic", - action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct( - c, s - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "hex_color", - "color": hex_color, - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show error result when matugen is enabled - results.append( - Result( - title=f"Cannot Apply Hex Color: {hex_color}{ - indicator_text - }", - subtitle="Matugen is enabled • Disable matugen to use hex colors", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "hex_color_failed", - "color": hex_color, - "bypass_max_results": True, - }, - ) - ) - else: - # Show suggestion for hex color with additional text (partial match) - results.append( - Result( - title=f"Apply Hex Color: {hex_color}{indicator_text}", - subtitle=f"Apply with {scheme_name} scheme • { - status_text - }", - icon_name="color-picker-symbolic", - action=lambda c=hex_color, s=scheme: ( - self._apply_hex_color_direct(c, s) - if not matugen_enabled - else None - ), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "hex_color_suggestion", - "color": hex_color, - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Incomplete hex color, show as suggestion - results.append( - Result( - title=f"Hex Color (incomplete): {hex_color}", - subtitle="Complete the 6-digit hex color to apply", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=0.7, - plugin_name=self.display_name, - data={ - "action": "hex_color_incomplete", - "color": hex_color, - "bypass_max_results": True, - }, - ) - ) - elif "random" in query and ("color" in query or "hex" in query): - # Check for exact matches for random color commands - if ( - query.strip() == "color random" - or query.strip() == "hex random" - or query.strip() == "random color" - or query.strip() == "random hex" - ): - # Random hex color - show result (execute on Enter) - scheme_name = self.schemes.get(scheme, scheme) - - # Check if matugen is disabled (hex colors work when matugen is OFF) - if not matugen_enabled: - results.append( - Result( - title=f"Random Hex Color{indicator_text}", - subtitle=f"Generate and apply random color with { - scheme_name - } scheme • {status_text}", - icon_name="color-picker-symbolic", - action=lambda s=scheme: self._apply_hex_color_direct( - self._generate_random_hex_color(), s - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random_hex", - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show error result when matugen is enabled - results.append( - Result( - title=f"Cannot Apply Random Color{indicator_text}", - subtitle="Matugen is enabled • Disable matugen to use hex colors", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random_hex_failed", - "bypass_max_results": True, - }, - ) - ) - else: - # Show suggestion for random hex color (partial match) - results.append( - Result( - title=f"Random Hex Color{indicator_text}", - subtitle=f"Generate and apply random color • {status_text}", - icon_name="color-picker-symbolic", - action=lambda s=scheme: ( - self._apply_hex_color_direct( - self._generate_random_hex_color(), s - ) - if not matugen_enabled - else None - ), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "random_hex_suggestion", - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show hex color help - results.append( - Result( - title="Hex Color Commands", - subtitle="Use: color #FF5733, hex #00FF00, color random", - icon_name="color-picker-symbolic", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"action": "hex_help", "bypass_max_results": True}, - ) - ) - - # Color scheme commands - if "scheme" in query: - current_scheme = self._get_current_scheme() - matugen_enabled = self._get_matugen_state() - - # Show all available schemes - for scheme_id, scheme_name in self.schemes.items(): - # Check if this scheme matches the query (for filtering) - if query.strip() == "scheme": - # Show all schemes when just "scheme" is typed - scheme_matches = True - else: - # Extract search terms from query (remove "scheme" keyword) - search_terms = query.replace("scheme", "").strip().split() - scheme_matches = False - - # Check if any search term matches the scheme name or ID - for term in search_terms: - if ( - term.lower() in scheme_name.lower() - or term.lower() in scheme_id.lower() - ): - scheme_matches = True - break - - if scheme_matches: - # Create indicators - indicators = [] - if scheme_id == current_scheme: - indicators.append("Current") - if not matugen_enabled: - indicators.append("Matugen Off") - - indicator_text = ( - " • " + " • ".join(indicators) if indicators else "" - ) - - results.append( - Result( - title=f"{scheme_name}{indicator_text}", - subtitle=f"Set color scheme to {scheme_name}" - + ( - " (requires matugen enabled)" - if not matugen_enabled - else "" - ), - icon_name="color-management-symbolic", - action=lambda s=scheme_id: self._set_current_scheme(s), - relevance=1.0 if scheme_id == current_scheme else 0.8, - plugin_name=self.display_name, - data={ - "action": "scheme_select", - "scheme": scheme_id, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Matugen controls - if "matugen" in query: - current_state = self._get_matugen_state() - - # Check for exact command matches - if query.strip() == "matugen on" or query.strip() == "matugen enable": - # Show result for enabling matugen (execute on Enter) - results.append( - Result( - title=f"Enable Matugen{indicator_text}", - subtitle=f"Turn on dynamic color generation • {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(True), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_on", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.strip() == "matugen off" or query.strip() == "matugen disable": - # Show result for disabling matugen (execute on Enter) - results.append( - Result( - title=f"Disable Matugen{indicator_text}", - subtitle=f"Turn off dynamic color generation • {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(False), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_off", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.strip() == "matugen toggle": - # Show result for toggling matugen (execute on Enter) - new_state = not current_state - results.append( - Result( - title=f"Toggle Matugen to { - 'Enabled' if new_state else 'Disabled' - }{indicator_text}", - subtitle=f"Switch matugen to { - 'enabled' if new_state else 'disabled' - } • {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(new_state), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_toggle", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif ("on" in query or "enable" in query) and not query.strip().endswith( - ("on", "enable") - ): - # Show suggestion for enabling (partial match) - results.append( - Result( - title=f"Enable Matugen{indicator_text}", - subtitle=f"Turn on dynamic color generation • {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(True), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_on_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif ("off" in query or "disable" in query) and not query.strip().endswith( - ("off", "disable") - ): - # Show suggestion for disabling (partial match) - results.append( - Result( - title=f"Disable Matugen{indicator_text}", - subtitle=f"Turn off dynamic color generation • {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(False), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_off_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif "toggle" in query and not query.strip().endswith("toggle"): - # Show suggestion for toggling (partial match) - results.append( - Result( - title=f"Toggle Matugen{indicator_text}", - subtitle=f"Switch matugen state • {status_text}", - icon_name="color-management-symbolic", - action=lambda: self._set_matugen_state(not current_state), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_toggle_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show current state with scheme info - results.append( - Result( - title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{ - indicator_text - }", - subtitle=f"Dynamic colors • {status_text}", - icon_name="color-management-symbolic", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"action": "matugen_status", "bypass_max_results": True}, - ) - ) - - # Status command - if query == "status" or query == "info": - results.append( - Result( - title=f"Wallpaper System Status{indicator_text}", - subtitle=status_text, - icon_name="color-management-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"action": "status", "bypass_max_results": True}, - ) - ) - - # Show quick actions - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper • {status_text}", - icon_name="media-playlist-shuffle-symbolic", - action=lambda: self._set_random_wallpaper(), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "random_quick", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py - if not query or ( - query - and "matugen" not in query - and "random" not in query - and "scheme" not in query - and "status" not in query - and "info" not in query - and "color" not in query - and "hex" not in query - ): - matching_wallpapers = [] - for wallpaper in self.wallpapers: - if not query or query in wallpaper.lower(): - # Calculate relevance - relevance = 1.0 if query == wallpaper.lower() else 0.7 - if query and query in wallpaper.lower(): - relevance = 0.8 - matching_wallpapers.append((wallpaper, relevance)) - - # Sort by relevance and show ALL wallpapers (like example_wallpapers.py) - matching_wallpapers.sort(key=lambda x: x[1], reverse=True) - - # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern) - for wallpaper, relevance in matching_wallpapers: - # Use fast thumbnail loading - icon = self._get_thumbnail_fast(wallpaper) - - results.append( - Result( - title=f"{wallpaper}{indicator_text if not query else ''}", - subtitle=f"Set as wallpaper{ - ' • ' + status_text if not query else '' - }", - icon=icon, - icon_name="image-x-generic-symbolic" if not icon else None, - action=lambda w=wallpaper: self._set_wallpaper(w), - relevance=relevance, - plugin_name=self.display_name, - data={ - "wallpaper": wallpaper, - "action": "set", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Sort by relevance - no limit since we use bypass_max_results - results.sort(key=lambda x: x.relevance, reverse=True) - return results diff --git a/modules/launcher/plugins/websearch.py b/modules/launcher/plugins/websearch.py deleted file mode 100644 index 80f992b8..00000000 --- a/modules/launcher/plugins/websearch.py +++ /dev/null @@ -1,259 +0,0 @@ -import subprocess -import urllib.parse -from typing import List - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class WebSearchPlugin(PluginBase): - """ - Web search plugin that supports multiple search engines. - """ - - def __init__(self): - super().__init__() - self.display_name = "Web Search" - self.description = "Search the web using various search engines" - self.current_trigger = "" # Track the current active trigger - - # Define search engines with their URLs and icons - self.search_engines = { - "google": { - "name": "Google", - "url": "https://www.google.com/search?q={}", - "icon": "google-symbolic", - "description": "Search with Google", - }, - "duckduckgo": { - "name": "DuckDuckGo", - "url": "https://duckduckgo.com/?q={}", - "icon": "preferences-desktop-search", - "description": "Search with DuckDuckGo (privacy-focused)", - }, - "youtube": { - "name": "YouTube", - "url": "https://www.youtube.com/results?search_query={}", - "icon": "youtube-symbolic", - "description": "Search videos on YouTube", - }, - "github": { - "name": "GitHub", - "url": "https://github.com/search?q={}", - "icon": "github-desktop-symbolic", - "description": "Search repositories on GitHub", - }, - "stackoverflow": { - "name": "Stack Overflow", - "url": "https://stackoverflow.com/search?q={}", - "icon": "stack", - "description": "Search programming questions on Stack Overflow", - }, - "wikipedia": { - "name": "Wikipedia", - "url": "https://en.wikipedia.org/wiki/Special:Search?search={}", - "icon": "search-menus-symbolic", - "description": "Search articles on Wikipedia", - }, - "reddit": { - "name": "Reddit", - "url": "https://www.reddit.com/search/?q={}", - "icon": "reddit-symbolic", - "description": "Search discussions on Reddit", - }, - "linkedin": { - "name": "LinkedIn", - "url": "https://www.linkedin.com/search/results/all/?keywords={}", - "icon": "link", - "description": "Search professionals and jobs on LinkedIn", - }, - } - - # Default search engine - self.default_engine = "google" - - def initialize(self): - """Initialize the web search plugin.""" - # Set up triggers for web search - only main triggers, not individual search engines - triggers = ["?"] - - # Don't add individual search engine triggers to avoid cluttering trigger keywords - # Search engines can still be used within web search context (e.g., "web google cats") - - self.set_triggers(triggers) - - def get_active_trigger(self, query_string: str) -> str: - """Override to track which trigger was activated.""" - trigger = super().get_active_trigger(query_string) - if trigger: - self.current_trigger = trigger.strip() - return trigger - - def cleanup(self): - """Cleanup the web search plugin.""" - pass - - def query(self, query_string: str) -> List[Result]: - """Process web search queries.""" - results = [] - - if not query_string.strip(): - # Show available search engines when no query - return self._get_search_engine_list() - - # Check if the query is a URL - if self._is_url(query_string): - results.append(self._create_url_result(query_string)) - return results - - # Parse query to check if it starts with a search engine name - engine_name, search_query = self._parse_engine_query(query_string) - - if engine_name and engine_name in self.search_engines: - # Specific search engine specified in query (e.g., "google cats") - if search_query: - # Search with specific engine - results.append(self._create_search_result(engine_name, search_query)) - else: - # Show specific engine info - results.append(self._create_engine_info_result(engine_name)) - else: - # General search - offer multiple engines - search_query = query_string.strip() - - # Add default search engine first - results.append( - self._create_search_result(self.default_engine, search_query) - ) - - # Add other popular search engines - popular_engines = ["duckduckgo", "youtube", "github"] - for engine in popular_engines: - if engine != self.default_engine: - results.append(self._create_search_result(engine, search_query)) - - return results - - def _parse_engine_query(self, query: str) -> tuple[str, str]: - """Parse query to extract search engine and search terms.""" - query = query.strip().lower() - - for engine in self.search_engines.keys(): - if query.startswith(f"{engine} "): - return engine, query[len(engine) :].strip() - elif query == engine: - return engine, "" - - return "", query - - def _is_url(self, text: str) -> bool: - """Check if the text is a URL.""" - text = text.strip().lower() - return text.startswith(("http://", "https://", "www.")) or ( - "." in text and " " not in text and len(text) > 3 - ) - - def _create_url_result(self, url: str) -> Result: - """Create a result for opening a URL directly.""" - # Add protocol if missing - if not url.startswith(("http://", "https://")): - if url.startswith("www."): - url = "https://" + url - else: - url = "https://" + url - - return Result( - title=f"Open {url}", - subtitle="Open this URL in your default browser", - icon_name="link", - action=lambda u=url: self._open_url(u), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "url", "url": url}, - ) - - def _open_url(self, url: str): - """Open a URL directly in the default browser.""" - try: - subprocess.Popen( - ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - print(f"Failed to open URL: {e}") - - def _get_search_engine_list(self) -> List[Result]: - """Get list of available search engines.""" - results = [] - - for engine_id, engine_info in self.search_engines.items(): - result = Result( - title=engine_info["name"], - subtitle=engine_info["description"], - icon_name=engine_info["icon"], - action=lambda e=engine_id: self._show_engine_help(e), - relevance=1.0 if engine_id == self.default_engine else 0.8, - plugin_name=self.display_name, - data={"type": "engine_info", "engine": engine_id}, - ) - results.append(result) - - return results - - def _create_search_result(self, engine_id: str, query: str) -> Result: - """Create a search result for a specific engine and query.""" - engine_info = self.search_engines[engine_id] - - return Result( - title=f"Search '{query}' on {engine_info['name']}", - subtitle=f"{engine_info['description']} - {query}", - icon_name=engine_info["icon"], - action=lambda e=engine_id, q=query: self._perform_search(e, q), - relevance=1.0 if engine_id == self.default_engine else 0.9, - plugin_name=self.display_name, - data={"type": "search", "engine": engine_id, "query": query}, - ) - - def _create_engine_info_result(self, engine_id: str) -> Result: - """Create an info result for a specific search engine.""" - engine_info = self.search_engines[engine_id] - - return Result( - title=f"{engine_info['name']} Search", - subtitle=f"{engine_info['description']} - Type your search query", - icon_name=engine_info["icon"], - action=lambda: None, # No action for info result - relevance=1.0, - plugin_name=self.display_name, - data={"type": "engine_ready", "engine": engine_id}, - ) - - def _perform_search(self, engine_id: str, query: str): - """Perform a web search using the specified engine.""" - if not query.strip(): - return - - engine_info = self.search_engines.get(engine_id) - if not engine_info: - return - - # URL encode the search query - encoded_query = urllib.parse.quote_plus(query) - search_url = engine_info["url"].format(encoded_query) - - try: - # Open the search URL in the default browser - subprocess.Popen( - ["xdg-open", search_url], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - print(f"Failed to open search URL: {e}") - - def _show_engine_help(self, engine_id: str): - """Show help for a specific search engine.""" - engine_info = self.search_engines.get(engine_id) - if engine_info: - print( - f"Search engine: {engine_info['name']} - {engine_info['description']}" - ) diff --git a/modules/launcher/result.py b/modules/launcher/result.py deleted file mode 100644 index fce35507..00000000 --- a/modules/launcher/result.py +++ /dev/null @@ -1,52 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Optional - -from gi.repository import GdkPixbuf, Gtk - - -@dataclass -class Result: - """ - Represents a search result that can be displayed and activated. - """ - - # Display information - title: str - subtitle: str = "" - subtitle_markup: Optional[str] = None # Pango markup for subtitle - description: str = "" - icon: Optional[GdkPixbuf.Pixbuf] = None - icon_name: Optional[str] = None - icon_markup: Optional[str] = None - # Behavior - action: Optional[Callable[[], Any]] = None - relevance: float = 0.0 - - # Custom widget support - custom_widget: Optional[Gtk.Widget] = None - - # Metadata - plugin_name: str = "" - data: Optional[dict] = None - - def activate(self): - """Activate this result (execute its action).""" - if self.action: - return self.action() - else: - raise NotImplementedError("No action defined for this result") - - def __post_init__(self): - """Post-initialization processing.""" - # Ensure relevance is within valid range - self.relevance = max(0.0, min(1.0, self.relevance)) - - # Set default data if None - if self.data is None: - self.data = {} - - def __str__(self): - return f"Result(title='{self.title}', relevance={self.relevance})" - - def __repr__(self): - return self.__str__() diff --git a/modules/launcher/result_item.py b/modules/launcher/result_item.py deleted file mode 100644 index baaaa43c..00000000 --- a/modules/launcher/result_item.py +++ /dev/null @@ -1,163 +0,0 @@ -import gi -from fabric.core.service import Signal -from fabric.widgets.box import Box -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from modules.launcher.result import Result - -gi.require_version("Gtk", "3.0") - - -class ResultItem(EventBox): - """ - Widget for displaying a single search result. - """ - - # Signals - @Signal - def clicked(self, index: int) -> None: - """Emitted when result is clicked.""" - pass - - @Signal - def hovered(self, index: int) -> None: - """Emitted when result is hovered.""" - pass - - def __init__( - self, result: Result, selected: bool = False, index: int = 0, **kwargs - ): - super().__init__(name="launcher-result-item", **kwargs) - - self.result = result - self._selected = selected - self.index = index - - # Setup UI - self._setup_ui() - - # Connect signals - self.connect("button-press-event", self._on_button_press) - self.connect("enter-notify-event", self._on_enter) - self.connect("leave-notify-event", self._on_leave) - - # Set initial selection state - self.set_selected(selected) - - def _setup_ui(self): - """Setup the result item UI.""" - # Main container - main_box = Box( - name="result-item-main", - orientation="h", - spacing=12, - h_align="fill", - v_align="center", - ) - self.add(main_box) - - # Icon - if self.result.icon: - icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon") - elif self.result.icon_name: - icon_widget = Image( - icon_name=self.result.icon_name, icon_size=48, name="result-item-icon" - ) - elif self.result.icon_markup: - icon_widget = Label( - markup=self.result.icon_markup, name="launcher-icon-label" - ) - else: - # Default icon - icon_widget = Image( - icon_name="application-default-icon", - icon_size=48, - name="result-item-icon", - ) - - main_box.add(icon_widget) - - # Text container - text_box = Box( - name="result-item-text", - orientation="v", - spacing=2, - h_expand=True, - v_align="center", - ) - main_box.add(text_box) - - # Title - title_label = Label( - label=self.result.title, - name="result-item-title", - h_align="start", - v_align="center", - ellipsize="end", - ) - text_box.add(title_label) - - # Subtitle (if present) - if self.result.subtitle or self.result.subtitle_markup: - if self.result.subtitle_markup: - # Use markup for subtitle (supports Pango markup) - subtitle_label = Label( - markup=self.result.subtitle_markup, - name="result-item-subtitle", - h_align="start", - v_align="center", - ellipsize="end", - ) - else: - # Use plain text for subtitle - subtitle_label = Label( - label=self.result.subtitle, - name="result-item-subtitle", - h_align="start", - v_align="center", - ellipsize="end", - ) - text_box.add(subtitle_label) - - # Plugin name (small text) - if self.result.plugin_name: - plugin_label = Label( - label=f"via {self.result.plugin_name}", - name="result-item-plugin", - h_align="start", - v_align="center", - ellipsize="end", - ) - text_box.add(plugin_label) - - def set_selected(self, selected: bool): - """Set the selection state of this result item.""" - self._selected = selected - - if selected: - self.add_style_class("selected") - else: - self.remove_style_class("selected") - - def get_selected(self) -> bool: - """Get the selection state of this result item.""" - return self._selected - - def _on_button_press(self, widget, event): - """Handle button press events.""" - if event.button == 1: # Left click - self.clicked.emit(self.index) - return True - return False - - def _on_enter(self, widget, event): - """Handle mouse enter events.""" - # Emit hover signal to update selection - self.hovered.emit(self.index) - return False - - def _on_leave(self, widget, event): - """Handle mouse leave events.""" - # Could be used for hover effects cleanup - return False diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py deleted file mode 100644 index 3a568c4d..00000000 --- a/modules/launcher/trigger_config.py +++ /dev/null @@ -1,57 +0,0 @@ -import json -import os -from typing import Any, Dict, List - -from fabric.utils import get_relative_path - - -class TriggerConfig: - def __init__(self, config_path: str = None): - if config_path is None: - config_path = get_relative_path("../../config/assets/launcher.json") - - self.config_path = config_path - - # Load configuration from JSON file - config = {"launcher_config": {}, "settings": {}} - if os.path.exists(config_path): - try: - with open(config_path, "r", encoding="utf-8") as f: - config = json.load(f) - except Exception as e: - print(f"Error loading trigger config: {e}") - - self.config = config - self.launcher_config = self.config.get("launcher_config", {}) - - # Initialize settings with defaults - default_settings = { - "max_examples_shown": 2, - "default_icon": "application-default-icon", - "fallback_example_template": "{trigger} <search>", - "config_version": "1.0", - } - self.settings = {**default_settings, **self.config.get("settings", {})} - - def get_trigger_examples(self, trigger: str) -> List[str]: - examples = self.launcher_config.get(trigger, {}).get("examples", []) - if not examples: - template = self.settings.get( - "fallback_example_template", "{trigger} <search>" - ) - examples = [template.format(trigger=trigger)] - return examples - - def get_trigger_icon(self, trigger: str) -> str: - icon = self.launcher_config.get(trigger, {}).get( - "icon", self.settings.get("default_icon", "application-default-icon") - ) - return icon - - def get_trigger_description(self, trigger: str) -> str: - return self.launcher_config.get(trigger, {}).get( - "description", f"{trigger} - No description available" - ) - - def get_all_triggers(self) -> Dict[str, Dict[str, Any]]: - return self.launcher_config.copy() diff --git a/modules/osd.py b/modules/osd.py deleted file mode 100644 index 6b27aaa7..00000000 --- a/modules/osd.py +++ /dev/null @@ -1,467 +0,0 @@ -import math -import time -from typing import ClassVar, Literal - -from gi.repository import GLib, GObject - -from fabric.audio import Audio -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.revealer import Revealer -from fabric.widgets.scale import Scale, ScaleMark -from fabric.widgets.svg import Svg -from services.brightness import Brightness -from utils.animator import Animator -from widgets.wayland import WaylandWindow as Window - - -class AnimatedScale(Scale): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.animator = None - - def animate_value(self, value: float): - if not self.animator: - self.animator = Animator( - bezier_curve=(0.34, 1.56, 0.64, 1.0), - duration=0.8, - min_value=self.min_value, - max_value=self.value, - tick_widget=self, - notify_value=lambda p, *_: self.set_value(p.value), - ) - self.animator.pause() - self.animator.min_value = self.value - self.animator.max_value = value - self.animator.play() - - -class BrightnessOSDContainer(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=3, name="osd") - self.brightness_service = Brightness.get_initial() - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(0, 101, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/brightness/brightness.svg"), - size=(100, 150), - name="osd-image", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - self.add(self.osd_window_image) - self.add(self.scale) - self.update_brightness() - - self.scale.connect("value-changed", lambda *_: self.update_brightness()) - self.brightness_service.connect("screen", self.on_brightness_changed) - - def update_brightness(self) -> None: - current_brightness = self.brightness_service.screen_brightness - normalized_brightness = self._normalize_brightness(current_brightness) - if current_brightness != 0: - self.scale.animate_value(normalized_brightness) - - def get_svg(self, value): - b_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return b_level - - def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: - normalized_brightness = self._normalize_brightness(value) - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/brightness/brightness-{ - self.get_svg(normalized_brightness) - }.svg" - ) - ) - self.scale.animate_value(normalized_brightness) - - def _normalize_brightness(self, brightness: float) -> float: - return (brightness / self.brightness_service.max_screen) * 100 - - -class AudioOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__( - **kwargs, - orientation="v", - name="osd", - ) - self.audio = Audio() - self.scale = AnimatedScale( - value=70, - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/volume/audio-volume.svg"), - size=(150, 150), - name="osd-image", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - self.previous_volume = None - self.previous_muted = None - - self.add(self.osd_window_image) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::speaker", self.on_audio_speaker_changed) - self.audio.connect("speaker-changed", self.on_speaker_changed) - - # Connect to current speaker if available - self._connect_speaker_signals() - - def _connect_speaker_signals(self): - """Connect to speaker's changed signal which should fire on both volume and mute changes""" - if self.audio.speaker: - # Connect to the main 'changed' signal from AudioStream - self.audio.speaker.connect("changed", self.on_speaker_stream_changed) - - def on_speaker_stream_changed(self, *_): - """This should be called whenever the speaker stream changes (volume OR mute)""" - if self.audio.speaker: - current_volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - current_muted = ( - self.audio.speaker.muted - if hasattr(self.audio.speaker, "muted") - else False - ) - - # Check if either volume or mute state changed - if ( - self.previous_volume != current_volume - or self.previous_muted != current_muted - ): - self.previous_volume = current_volume - self.previous_muted = current_muted - self.update_volume() - self.emit("volume-changed") - - def get_svg(self, value): - audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return audio_level - - def sync_with_audio(self): - if self.audio.speaker: - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - self.scale.set_value(volume) - self.previous_volume = volume - self.previous_muted = ( - self.audio.speaker.muted - if hasattr(self.audio.speaker, "muted") - else False - ) - - def on_volume_changed(self, *_): - if self.audio.speaker: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.speaker.set_volume(volume) - self.update_volume_display(volume) - self.emit("volume-changed") - - def update_volume_display(self, volume=None): - """Update the visual display based on current volume/mute state""" - if not self.audio.speaker: - return - - if volume is None: - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - - is_muted = ( - self.audio.speaker.muted if hasattr(self.audio.speaker, "muted") else False - ) - - if volume == 0 or is_muted: - self.scale.add_style_class("muted") - display_volume = 0 - else: - self.scale.remove_style_class("muted") - display_volume = volume - - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/volume/audio-volume-{ - self.get_svg(display_volume) - }.svg" - ) - ) - - def on_audio_speaker_changed(self, *_): - self._connect_speaker_signals() - self.update_volume() - - def on_speaker_changed(self, *_): - self._connect_speaker_signals() - self.update_volume() - - def update_volume(self, *_): - if self.audio.speaker and not self.is_hovered(): - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - self.scale.set_value(volume) - self.update_volume_display(volume) - - -class MicrophoneOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=13, name="osd") - self.audio = Audio() - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/mic/microphone.svg"), - name="osd-image", - size=(100, 150), - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - self.previous_volume = None - self.previous_muted = None - - self.add(self.osd_window_image) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::microphone", self.on_audio_microphone_changed) - self.audio.connect("microphone-changed", self.on_microphone_changed) - - # Connect to current microphone if available - self._connect_microphone_signals() - - def _connect_microphone_signals(self): - """Connect to microphone's changed signal which should fire on both volume and mute changes""" - if self.audio.microphone: - # Connect to the main 'changed' signal from AudioStream - self.audio.microphone.connect("changed", self.on_microphone_stream_changed) - - def on_microphone_stream_changed(self, *_): - """This should be called whenever the microphone stream changes (volume OR mute)""" - if self.audio.microphone: - current_volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - current_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - # Check if either volume or mute state changed - if ( - self.previous_volume != current_volume - or self.previous_muted != current_muted - ): - self.previous_volume = current_volume - self.previous_muted = current_muted - self.update_volume() - self.emit("mic-changed") - - def get_svg(self, value): - audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return audio_level - - def sync_with_audio(self): - if self.audio.microphone: - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - self.scale.set_value(volume) - self.previous_volume = volume - self.previous_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - def on_volume_changed(self, *_): - if self.audio.microphone: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.microphone.set_volume(volume) - self.update_volume_display(volume) - self.emit("mic-changed") - - def update_volume_display(self, volume=None): - """Update the visual display based on current volume/mute state""" - if not self.audio.microphone: - return - - if volume is None: - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - - is_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - if volume == 0 or is_muted: - self.scale.add_style_class("muted") - display_volume = 0 - else: - self.scale.remove_style_class("muted") - display_volume = volume - - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/mic/microphone-{ - self.get_svg(display_volume) - }.svg" - ) - ) - - def on_audio_microphone_changed(self, *_): - self._connect_microphone_signals() - self.update_volume() - - def on_microphone_changed(self, *_): - self._connect_microphone_signals() - self.update_volume() - - def update_volume(self, *_): - if self.audio.microphone and not self.is_hovered(): - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - self.scale.set_value(volume) - self.update_volume_display(volume) - - -class OSD(Window): - def __init__(self, **kwargs): - self.audio_container = AudioOSDContainer() - self.brightness_container = BrightnessOSDContainer() - self.microphone_container = MicrophoneOSDContainer() - - self.timeout = 1000 - - self.revealer = Revealer( - transition_type="slide-up", - transition_duration=100, - child_revealed=False, - ) - - self.main_box = Box( - orientation="v", - h_expand=True, - children=[self.revealer], - ) - - super().__init__( - layer="overlay", - anchor="bottom", - title="modus", - child=self.main_box, - visible=False, - pass_through=True, - keyboard_mode="on-demand", - **kwargs, - ) - - self.last_activity_time = time.time() - - # Connect to the containers' signals - self.audio_container.connect("volume-changed", self.show_audio) - self.brightness_container.brightness_service.connect( - "screen", self.show_brightness - ) - self.microphone_container.connect("mic-changed", self.show_microphone) - - GLib.timeout_add(100, self.check_inactivity) - - def show_audio(self, *_): - self.show_box(box_to_show="audio") - self.reset_inactivity_timer() - - def show_brightness(self, *_): - self.show_box(box_to_show="brightness") - self.reset_inactivity_timer() - - def show_microphone(self, *_): - self.show_box(box_to_show="microphone") - self.reset_inactivity_timer() - - def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]): - self.set_visible(True) - if box_to_show == "audio": - self.revealer.children = self.audio_container - elif box_to_show == "brightness": - self.revealer.children = self.brightness_container - elif box_to_show == "microphone": - self.revealer.children = self.microphone_container - self.revealer.set_reveal_child(True) - self.reset_inactivity_timer() - - def start_hide_timer(self): - self.set_visible(False) - - def reset_inactivity_timer(self): - self.last_activity_time = time.time() - - def check_inactivity(self): - if time.time() - self.last_activity_time >= (self.timeout / 1000): - self.start_hide_timer() - return True diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py deleted file mode 100644 index ac7e63b1..00000000 --- a/modules/panel/components/enhanced_system_tray.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Enhanced System Tray Icon Handling - -This module provides enhanced icon loading capabilities for system tray items, -including fallback mechanisms for file paths and common icon locations. -""" - -import os - -from gi.repository import GdkPixbuf, Gtk - -from fabric.system_tray.widgets import SystemTrayItem - -# FIX: the tooltip should show application names instead of unknown - - -def patched_do_update_properties(self, *_): - # Try default GTK theme first - icon_name = self._item.icon_name - attention_icon_name = self._item.attention_icon_name - - if self._item.status == "NeedsAttention" and attention_icon_name: - preferred_icon_name = attention_icon_name - else: - preferred_icon_name = icon_name - - # Try to load from default GTK theme - if preferred_icon_name: - try: - default_theme = Gtk.IconTheme.get_default() - if default_theme.has_icon(preferred_icon_name): - pixbuf = default_theme.load_icon( - preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - # Set tooltip - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - return - except: - pass - - # Enhanced fallback handling for file paths - if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name): - return - - # Fallback to original implementation - original_do_update_properties(self, *_) - - -def _try_load_icon_from_path(self, icon_path): - try: - # Check if it's a file path and handle it directly - if os.path.isabs(icon_path) or "/" in icon_path: - # Try to load as SVG from the original path if it exists - if os.path.exists(icon_path): - if icon_path.lower().endswith(".svg"): - # Load SVG directly - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - else: - # Load other image formats - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it's a file path, try to extract just the filename for theme lookup - filename = os.path.basename(icon_path) - if filename: - # Remove extension for theme lookup - name_without_ext = os.path.splitext(filename)[0] - default_theme = Gtk.IconTheme.get_default() - - # Try filename without extension - if default_theme.has_icon(name_without_ext): - pixbuf = default_theme.load_icon( - name_without_ext, - self._icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # Try full filename - if default_theme.has_icon(filename): - pixbuf = default_theme.load_icon( - filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it looks like a file path but doesn't exist, try common icon locations - if os.path.isabs(icon_path): - common_icon_dirs = [ - "/usr/share/icons", - "/usr/share/pixmaps", - "/usr/local/share/icons", - "/usr/local/share/pixmaps", - os.path.expanduser("~/.local/share/icons"), - os.path.expanduser("~/.icons"), - ] - - filename = os.path.basename(icon_path) - for icon_dir in common_icon_dirs: - potential_path = os.path.join(icon_dir, filename) - if os.path.exists(potential_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - potential_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - except: - continue - - except Exception: - pass - - return False - - -def _set_tooltip(self): - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - - -def apply_enhanced_system_tray(): - # Store original method - global original_do_update_properties - original_do_update_properties = SystemTrayItem.do_update_properties - - # Attach helper methods to SystemTrayItem class - SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path - SystemTrayItem._set_tooltip = _set_tooltip - - # Replace the do_update_properties method - SystemTrayItem.do_update_properties = patched_do_update_properties - - -# Store reference to original method -original_do_update_properties = None diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py deleted file mode 100644 index 7e8e277f..00000000 --- a/modules/panel/components/indicators.py +++ /dev/null @@ -1,477 +0,0 @@ -from fabric.bluetooth import BluetoothClient -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.svg import Svg - -from modules.controlcenter.battery import BatteryControl -from modules.controlcenter.bluetooth import BluetoothConnections -from modules.controlcenter.wifi import WifiConnections -from services.battery import Battery -from services.network import NetworkClient -from utils.roam import modus_service -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -from widgets.mousecapture import DropDownMouseCapture -from widgets.wayland import WaylandWindow as Window - - -class BluetoothIndicator(Box): - def __init__(self, show_window=True, **kwargs): - super().__init__(name="bluetooth-indicator", orientation="h", **kwargs) - self.show_window = show_window - - self.bluetooth = BluetoothClient() - self.bt_icon = Svg( - name="bt-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ), - ) - - self.bt_button = Button( - name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked - ) - - self.add(self.bt_button) - - # Create Bluetooth control center widget only if show_window is True - if self.show_window: - self.bluetooth_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="bluetooth-control-window", - visible=False, - ) - - self.bluetooth_widget = BluetoothConnections(self, show_back_button=False) - self.bluetooth_window.children = [self.bluetooth_widget] - - # Create mouse capture for Bluetooth widget - self.bluetooth_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.bluetooth_window - ) - else: - self.bluetooth_window = None - self.bluetooth_widget = None - self.bluetooth_mousecapture = None - - modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) - self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) - self.bluetooth.connect("device-added", self.on_device_added) - self.bluetooth.connect("device-removed", self.on_device_removed) - - self.update_modus_service_bluetooth_state() - self.update_state() - - def update_state(self): - if not self.bluetooth.enabled: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-off-clear.svg" - ) - ) - tooltip = "Bluetooth disabled" - else: - connected_devices = self.bluetooth.connected_devices - if connected_devices: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) - if len(connected_devices) >= 1: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-paired.svg" - ) - ) - device = connected_devices[0] - tooltip = f"Connected to {device.alias}" - if device.battery_percentage > 0: - tooltip += f" ({device.battery_percentage:.0f}%)" - else: - tooltip = f"Connected to {len(connected_devices)} devices" - else: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) - tooltip = "No devices connected" - - self.bt_button.set_tooltip_text(tooltip) - - def on_bluetooth_changed(self, service, new_bluetooth_state): - self.update_state() - - def on_bluetooth_direct_changed(self, *args): - self.update_modus_service_bluetooth_state() - self.update_state() - - def on_device_added(self, _, address): - self.update_modus_service_bluetooth_state() - self.update_state() - - def on_device_removed(self, _, address): - self.update_modus_service_bluetooth_state() - self.update_state() - - def update_modus_service_bluetooth_state(self): - if not self.bluetooth.enabled: - bluetooth_state = "disabled" - else: - connected_devices = self.bluetooth.connected_devices - if connected_devices: - if len(connected_devices) == 1: - device = connected_devices[0] - bluetooth_state = f"connected:{device.alias}" - if ( - hasattr(device, "battery_percentage") - and device.battery_percentage > 0 - ): - bluetooth_state += f":{device.battery_percentage:.0f}%" - else: - bluetooth_state = f"connected:{len(connected_devices)}_devices" - else: - bluetooth_state = "enabled" - - modus_service.bluetooth = bluetooth_state - - def on_bluetooth_clicked(self, *args): - """Handle Bluetooth indicator click""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.toggle_mousecapture() - - def close_bluetooth(self, *args): - """Close Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() - - def hide_controlcenter(self, *args): - """Hide Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() - - -class NetworkIndicator(Box): - def __init__(self, show_window=True, **kwargs): - super().__init__(name="network-indicator", orientation="h", **kwargs) - self.show_window = show_window - - self.network_service = NetworkClient() - - self.network_icon = Svg( - name="network-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/wifi-clear.svg" - ), - ) - - self.network_button = Button( - name="network-button", - child=self.network_icon, - on_clicked=self.on_wifi_clicked, - ) - - self.add(self.network_button) - - # Create WiFi control center widget only if show_window is True - if self.show_window: - self.wifi_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="wifi-control-window", - visible=False, - ) - - self.wifi_widget = WifiConnections(self, show_back_button=False) - self.wifi_window.children = [self.wifi_widget] - - # Create mouse capture for WiFi widget - self.wifi_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.wifi_window - ) - else: - self.wifi_window = None - self.wifi_widget = None - self.wifi_mousecapture = None - - modus_service.connect("wlan-changed", self.on_wlan_changed) - self.network_service.connect("wifi-device-added", self.on_wifi_device_added) - self.network_service.connect( - "ethernet-device-added", self.on_ethernet_device_added - ) - self.network_service.connect("changed", self.on_network_changed) - - self.update_modus_service_wlan_state() - self.update_state() - - def on_wlan_changed(self, service, new_wlan_state): - self.update_state() - - def on_wifi_device_added(self, *args): - """Called when WiFi device is added""" - if self.network_service.wifi_device: - self.network_service.wifi_device.connect( - "changed", self.on_network_direct_changed - ) - self.update_modus_service_wlan_state() - self.update_state() - - def on_ethernet_device_added(self, *args): - """Called when Ethernet device is added""" - if self.network_service.ethernet_device: - self.network_service.ethernet_device.connect( - "changed", self.on_network_direct_changed - ) - self.update_modus_service_wlan_state() - self.update_state() - - def on_network_direct_changed(self, *args): - self.update_modus_service_wlan_state() - self.update_state() - - def on_network_changed(self, *args): - self.update_modus_service_wlan_state() - self.update_state() - - def update_modus_service_wlan_state(self): - wlan_state = "disconnected" - - # Check WiFi first (prioritize WiFi over Ethernet) - if self.network_service.wifi_device: - wifi = self.network_service.wifi_device - if not wifi.wireless_enabled: - wlan_state = "disabled" - elif wifi.active_access_point: - ap = wifi.active_access_point - wlan_state = f"connected:{ap.ssid}" - if ap.strength >= 0: - wlan_state += f":{ap.strength}%" - else: - wlan_state = "enabled" - - # Check Ethernet if WiFi is not connected - elif self.network_service.ethernet_device: - ethernet = self.network_service.ethernet_device - if ethernet.internet == "activated": - wlan_state = "ethernet:connected" - if hasattr(ethernet, "speed") and ethernet.speed: - wlan_state += f":{ethernet.speed}" - elif ethernet.internet == "activating": - wlan_state = "ethernet:connecting" - else: - wlan_state = "ethernet:disconnected" - - modus_service.wlan = wlan_state - - def update_state(self): - tooltip = "No network connection" - icon_file = "wifi-off-clear.svg" - - # Check WiFi first (prioritize WiFi over Ethernet) - if self.network_service.wifi_device: - wifi = self.network_service.wifi_device - if not wifi.wireless_enabled: - icon_file = "wifi-off-clear.svg" - tooltip = "WiFi disabled" - elif wifi.active_access_point: - ap = wifi.active_access_point - # Use dynamic WiFi icon based on signal strength - wifi_icon_path = get_wifi_icon_for_strength(ap.strength) - self.network_icon.set_from_file(wifi_icon_path) - tooltip = f"Connected to {ap.ssid}" - if ap.strength >= 0: - tooltip += f" ({ap.strength}%)" - self.network_button.set_tooltip_text(tooltip) - return # Early return to avoid setting icon again - else: - icon_file = "wifi-off-clear.svg" - tooltip = "WiFi disconnected" - - # Check Ethernet if WiFi is not connected - elif self.network_service.ethernet_device: - ethernet = self.network_service.ethernet_device - if ethernet.internet == "activated": - icon_file = "network-wired.svg" - tooltip = "Ethernet connected" - if hasattr(ethernet, "speed") and ethernet.speed: - tooltip += f" ({ethernet.speed})" - elif ethernet.internet == "activating": - icon_file = "network-wired.svg" - tooltip = "Ethernet connecting..." - else: - icon_file = "network-wired-offline.svg" - tooltip = "Ethernet disconnected" - - self.network_icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/applets/{icon_file}") - ) - self.network_button.set_tooltip_text(tooltip) - - def on_wifi_clicked(self, *args): - """Handle WiFi indicator click""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.toggle_mousecapture() - - def close_wifi(self, *args): - """Close WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() - - def hide_controlcenter(self, *args): - """Hide WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() - - -class BatteryIndicator(Box): - def __init__(self, show_window=True, **kwargs): - super().__init__(name="battery-indicator", orientation="h", **kwargs) - self.show_window = show_window - - self.battery_service = Battery() - - self.battery_icon = Svg( - name="battery-icon", - size=23, - svg_file=get_relative_path( - "../../../config/assets/icons/battery/battery-100.svg" - ), - ) - - self.battery_button = Button( - name="battery-button", - child=self.battery_icon, - on_clicked=self.on_battery_clicked, - ) - - self.battery_label = Label(name="battery-label", label="--- %") - - self.add(self.battery_label) - self.add(self.battery_button) - - # Create Battery control center widget only if show_window is True - if self.show_window: - self.battery_window = Window( - layer="top", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="battery-control-window", - visible=False, - ) - - self.battery_widget = BatteryControl(self, show_back_button=False) - self.battery_window.children = [self.battery_widget] - - # Create mouse capture for Battery widget - self.battery_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.battery_window - ) - else: - self.battery_window = None - self.battery_widget = None - self.battery_mousecapture = None - - modus_service.connect("battery-changed", self.on_battery_changed) - self.battery_service.connect("changed", self.on_battery_direct_changed) - - self.update_modus_service_battery_state() - self.update_state() - - def on_battery_changed(self, service, new_battery_state): - self.update_state() - - def on_battery_direct_changed(self, *args): - self.update_modus_service_battery_state() - self.update_state() - - def update_modus_service_battery_state(self): - if not self.battery_service.is_present: - battery_state = "not_present" - else: - percentage = self.battery_service.percentage - state = self.battery_service.state.lower() - - battery_state = f"{state}:{percentage}%" - - if state == "discharging": - time_to_empty = self.battery_service.time_to_empty - if time_to_empty != "N/A": - battery_state += f":{time_to_empty}" - elif state == "charging": - time_to_full = self.battery_service.time_to_full - if time_to_full != "N/A": - battery_state += f":{time_to_full}" - - modus_service.battery = battery_state - - def get_battery_tooltip(self, percentage, state): - tooltip = f"Battery: {percentage}%" - - if state == "CHARGING": - tooltip += " (Charging)" - time_to_full = self.battery_service.time_to_full - if time_to_full != "N/A": - tooltip += f" - {time_to_full} until full" - elif state == "DISCHARGING": - time_to_empty = self.battery_service.time_to_empty - if time_to_empty != "N/A": - tooltip += f" - {time_to_empty} remaining" - elif state == "FULLY_CHARGED": - tooltip += " (Fully charged)" - - return tooltip - - def update_state(self): - if not self.battery_service.is_present: - # Hide the entire battery component when no battery is present - self.set_visible(False) - return - else: - # Show the battery component when battery is present - self.set_visible(True) - - percentage = self.battery_service.percentage - state = self.battery_service.state - is_charging = state in ["CHARGING", "FULLY_CHARGED"] - - icon_file = Battery.get_battery_icon_file( - percentage, is_charging, base_path="../../../config/assets/icons/" - ) - tooltip = self.get_battery_tooltip(percentage, state) - percentage_text = f"{percentage}%" - - # Update icon, tooltip, and percentage label - self.battery_icon.set_from_file(get_relative_path(icon_file)) - self.battery_button.set_tooltip_text(tooltip) - self.battery_label.set_label(percentage_text) - - def on_battery_clicked(self, *args): - """Handle Battery indicator click""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.toggle_mousecapture() - - def close_battery(self, *args): - """Close Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() - - def hide_controlcenter(self, *args): - """Hide Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py deleted file mode 100644 index 61e77351..00000000 --- a/modules/panel/components/menubar.py +++ /dev/null @@ -1,411 +0,0 @@ -import json -import os -import subprocess - -from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow -from fabric.utils import FormattedString -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label - -from modules.about import About, AboutApp -from utils.roam import modus_service -from widgets.dropdown import ModusDropdown, dropdown_divider -from widgets.mousecapture import DropDownMouseCapture -from utils.app_name_resolver import format_window - - -def show_about_app(): - """Show about dialog for current active application""" - try: - # Use modus_service's Hyprland connection to get current window info - wmclass = "" - title = "" - - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Don't show about dialog if there's no active window (Finder state) - if not wmclass and not title: - return - - app_name = modus_service.current_active_app_name or "Finder" - # Don't show about dialog for Finder - if app_name == "Finder": - return - - about_window = AboutApp(app_name=app_name, wmclass=wmclass) - about_window.toggle(None) - except Exception: - # Fallback: only show if we have a real app name - app_name = modus_service.current_active_app_name or "" - if app_name and app_name != "Finder": - about_window = AboutApp(app_name=app_name, wmclass="") - about_window.toggle(None) - - -def dropdown_option( - label: str = "", - keybind: str = "", - on_click='echo "ModusPanelDropdown Action"', - on_clicked=None, -): - def on_click_subthread(button): - # Execute the action first - if on_clicked: - on_clicked(button) - else: - subprocess.Popen( - f"nohup {on_click} &", - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Hide dropdown by finding the current visible dropdown and calling its hide method - from widgets.dropdown import dropdowns - - for dropdown in dropdowns: - if dropdown.is_visible() and hasattr(dropdown, "hide_via_mousecapture"): - dropdown.hide_via_mousecapture() - break - - return Button( - child=CenterBox( - start_children=[ - Label(label=label, h_align="start", name="dropdown-option-label"), - ], - end_children=[ - Label(label=keybind, h_align="end", name="dropdown-option-keybind") - ], - orientation="horizontal", - h_align="fill", - h_expand=True, - v_expand=True, - ), - name="dropdown-option", - h_align="fill", - on_clicked=on_click_subthread, - h_expand=True, - v_expand=True, - ) - - -class SystemDropdown(ModusDropdown): - def __init__(self, parent, **kwargs): - super().__init__( - dropdown_id="os-menu", - parent=parent, - dropdown_children=[ - dropdown_option( - "About this PC", on_clicked=lambda _: About().toggle(_) - ), - dropdown_divider("---------------------"), - dropdown_option( - "System Settings...", - # TODO: Open Modus own setting - # on_click="xdg-open settings", - ), - dropdown_divider("---------------------"), - dropdown_option("Force Quit", "", "hyprctl kill"), - dropdown_divider("---------------------"), - dropdown_option("Sleep", "", "systemctl suspend"), - dropdown_option("Restart...", "", "systemctl reboot"), - dropdown_option("Shut Down...", "", "shutdown now"), - dropdown_divider("---------------------"), - dropdown_option("Lock Screen", "󰘳 L", "hyprlock"), - ], - **kwargs, - ) - - -class MenuBarDropdowns: - def __init__(self, parent): - self.parent = parent - - # System dropdown - self.system_dropdown = SystemDropdown(parent=parent) - self.menu_button_dropdown = DropDownMouseCapture( - layer="bottom", child_window=self.system_dropdown - ) - self.menu_button = Button( - label="Modus", - name="menu-button", - style_classes="button", - on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), - ) - self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) - - # Global menu dropdowns - self.global_title_menu_about = dropdown_option( - f"About {modus_service.current_active_app_name}", - on_clicked=lambda _: show_about_app(), - ) - self.global_menu_title = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-title", - parent=parent, - dropdown_children=[self.global_title_menu_about], - ), - ) - - self.global_menu_file = None - self.global_menu_edit = None - self.global_menu_view = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-view", - parent=parent, - dropdown_children=[ - dropdown_option( - "Enter Full Screen", - on_click="hyprctl dispatch fullscreen", - ), - ], - ), - ) - self.global_menu_go = None - self.global_menu_window = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-window", - parent=parent, - dropdown_children=[ - dropdown_option( - "Zoom In", - "󰍉 +", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", - ), - dropdown_option( - "Zoom Out", - "󰍉 -", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Move Window to Left", - on_click="hyprctl dispatch movewindow l", - ), - dropdown_option( - "Move Window to Right", - on_click="hyprctl dispatch movewindow r", - ), - dropdown_option( - "Cycle Through Windows", - on_click="hyprctl dispatch cyclenext", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Float", on_click="hyprctl dispatch togglefloating" - ), - dropdown_option("Quit", on_click="hyprctl dispatch killactive"), - dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), - dropdown_option( - "Toggle Split", on_click="hyprctl dispatch togglesplit" - ), - dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), - dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), - dropdown_option( - "Pin", - on_clicked=lambda _: subprocess.run( - "bash ~/.config/scripts/winpin.sh", shell=True - ), - ), - ], - ), - ) - - self.global_menu_help = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-help", - parent=parent, - dropdown_children=[ - dropdown_option( - "Modus", - on_click="xdg-open https://github.com/S4NKALP/Modus/issues", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" - ), - ], - ), - ) - - # Create menu buttons - modus_service.connect( - "current-active-app-name-changed", - lambda _, value: self.global_title_menu_about.set_property( - "label", f"About {value}" - ), - ) - - # Connect to active app name changes to update the title button - modus_service.connect("current-active-app-name-changed", self._on_active_app_changed) - - self.global_menu_button_title = Button( - child=ActiveWindow( - formatter=FormattedString( - "{ format_window(win_title, win_class) }", - format_window=format_window, - ) - ), - name="global-title-button", - style_classes="button", - on_clicked=self._on_title_button_clicked, - ) - - self.global_menu_title.child_window.set_pointing_to( - self.global_menu_button_title - ) - - self.global_menu_button_file = Button( - label="File", name="global-menu-button-file", style_classes="button" - ) - self.global_menu_button_edit = Button( - label="Edit", name="global-menu-button-edit", style_classes="button" - ) - self.global_menu_button_view = Button( - label="View", - name="global-menu-button-view", - style_classes="button", - on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(), - ) - self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) - self.global_menu_button_go = Button( - label="Go", name="global-menu-button-go", style_classes="button" - ) - self.global_menu_button_window = Button( - label="Window", - name="global-menu-button-window", - style_classes="button", - on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(), - ) - self.global_menu_window.child_window.set_pointing_to( - self.global_menu_button_window - ) - self.global_menu_button_help = Button( - label="Help", - name="global-menu-button-help", - style_classes="button", - on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(), - ) - self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) - - modus_service.connect("current-dropdown-changed", self.changed_dropdown) - modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) - - def _on_title_button_clicked(self, _): - """Handle title button click - only show dropdown if there's an active window""" - try: - # Use modus_service's Hyprland connection to get current window info - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Only show dropdown if there's an active window (not Finder) - if wmclass or title: - self.global_menu_title.toggle_mousecapture() - return - - # Fallback: check if current_active_app_name is not "Finder" - if ( - modus_service.current_active_app_name - and modus_service.current_active_app_name != "Finder" - ): - self.global_menu_title.toggle_mousecapture() - except Exception: - # If we can't get window info, don't show dropdown - pass - - def _on_active_app_changed(self, _, value): - """Handle active app name changes""" - # Update the "About" menu item label - self.global_title_menu_about.set_property("label", f"About {value}") - - def hide_dropdowns(self, *_): - self.menu_button.remove_style_class("active") - self.global_menu_button_edit.remove_style_class("active") - self.global_menu_button_file.remove_style_class("active") - self.global_menu_button_go.remove_style_class("active") - self.global_menu_button_help.remove_style_class("active") - self.global_menu_button_title.remove_style_class("active") - self.global_menu_button_view.remove_style_class("active") - self.global_menu_button_window.remove_style_class("active") - - def changed_dropdown(self, _, dropdown_id): - self.hide_dropdowns(_, True) - match dropdown_id: - case "os-menu": - self.menu_button.add_style_class("active") - case "global-menu-edit": - self.global_menu_button_edit.add_style_class("active") - case "global-menu-file": - self.global_menu_button_file.add_style_class("active") - case "global-menu-go": - self.global_menu_button_go.add_style_class("active") - case "global-menu-help": - self.global_menu_button_help.add_style_class("active") - case "global-menu-title": - self.global_menu_button_title.add_style_class("active") - case "global-menu-view": - self.global_menu_button_view.add_style_class("active") - case "global-menu-window": - self.global_menu_button_window.add_style_class("active") - case _: - pass - - -class MenuBar(Box): - """Main MenuBar widget that contains all menu buttons""" - - def __init__(self, parent_window=None, **kwargs): - # Extract parent_window from kwargs if not provided as parameter - if parent_window is None: - parent_window = kwargs.pop("parent_window", None) - - super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs) - - # Create the dropdown system - self.dropdown_system = MenuBarDropdowns(parent=parent_window) - - # Add all the menu buttons to the menubar - self.children = [ - self.dropdown_system.global_menu_button_title, - self.dropdown_system.global_menu_button_file, - self.dropdown_system.global_menu_button_edit, - self.dropdown_system.global_menu_button_view, - self.dropdown_system.global_menu_button_go, - self.dropdown_system.global_menu_button_window, - self.dropdown_system.global_menu_button_help, - ] - - def show_system_dropdown(self, imac_button): - self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( - imac_button - ) - mouse_capture = self.dropdown_system.menu_button_dropdown - if mouse_capture.is_visible(): - mouse_capture.set_child_window_visible(False) - else: - mouse_capture.set_child_window_visible(True) diff --git a/modules/panel/components/recording_indicator.py b/modules/panel/components/recording_indicator.py deleted file mode 100644 index ab318e36..00000000 --- a/modules/panel/components/recording_indicator.py +++ /dev/null @@ -1,236 +0,0 @@ -import os -import subprocess -import time - -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.svg import Svg -from gi.repository import GLib - - -class RecordingIndicator(Button): - def __init__(self, **kwargs): - super().__init__(name="panel-button", visible=True, **kwargs) - - self.script_path = get_relative_path("../../../scripts/screen-capture.sh") - self.recording_start_time = None - self.last_process_check = 0 - self.process_check_interval = 1.0 - self.timer_update_interval = 1000 - self.status_check_interval = 2000 - - self.timer_timeout_id = None - self.status_timeout_id = None - - self.recording_icon = Svg( - name="indicators-icon", - size=24, - svg_file=get_relative_path( - "../../../config/assets/icons/misc/media-record.svg" - ), - ) - self.time_label = Label( - name="recording-time-label", - markup="00:00", - max_width_chars=5, - ellipsize="none", - ) - - self.recording_box = Box( - orientation="h", - spacing=2, - children=[self.recording_icon, self.time_label], - size=(80, -1), - ) - - self.add(self.recording_box) - - self.connect("clicked", self.on_stop_recording) - self.connect("button-press-event", self.on_button_press) - self.hide() - - GLib.timeout_add(100, self._delayed_init) - - def on_button_press(self, *args): - GLib.timeout_add(100, lambda: self.remove_style_class("pressed") or False) - return False - - def is_recorder_running(self): - # add more process names if needed - recorder_processes = ["wf-recorder", "gpu-screen-recorder"] - - try: - for proc in recorder_processes: - result = subprocess.run( - ["pgrep", "-x", proc], - capture_output=True, - text=True, - timeout=1, - ) - if result.returncode == 0: - return True # Found a running recorder process - return False # None found running - except Exception: - return False - - def check_recording_status(self): - current_time = time.time() - self.last_process_check = current_time - - try: - is_recording = self.is_recorder_running() - - if is_recording: - if not self.get_visible(): - self.set_visible(True) - if self.timer_timeout_id is None: - self.timer_timeout_id = GLib.timeout_add( - self.timer_update_interval, self.update_timer_display - ) - - if self.recording_start_time is None: - self.recording_start_time = self.get_recording_start_time() - - self.update_timer_display() - else: - if self.get_visible(): - self.set_visible(False) - self.cleanup_recording_state() - - except Exception as e: - print(f"[DEBUG] Error checking recording status: {e}") - if self.get_visible(): - self.set_visible(False) - self.cleanup_recording_state() - - return True - - def update_timer_display(self): - if not self.get_visible() or self.recording_start_time is None: - return False - - try: - elapsed_seconds = int(time.time() - self.recording_start_time) - minutes = elapsed_seconds // 60 - seconds = elapsed_seconds % 60 - time_text = f"{minutes:02d}:{seconds:02d}" - - self.time_label.set_markup(time_text) - self.set_tooltip_text( - f"Recording in progress ({time_text}) - Click to stop" - ) - - return True - except Exception as e: - print(f"[DEBUG] Error updating timer display: {e}") - return False - - def cleanup_recording_state(self): - self.recording_start_time = None - - if self.timer_timeout_id: - GLib.source_remove(self.timer_timeout_id) - self.timer_timeout_id = None - - def get_recording_start_time(self): - wf_file = "/tmp/recording_start_time.txt" - gpu_file = "/tmp/gpu_recording_start_time.txt" - - def read_timestamp(path): - try: - with open(path, "r") as f: - content = f.read().strip() - if content: - t = float(content) - if abs(t - time.time()) <= 3600: - return t - return os.path.getmtime(path) - except (OSError, ValueError): - return None - - if os.path.exists(wf_file): - t = read_timestamp(wf_file) - if t: - print("[DEBUG] Using wf-recorder start time") - return t - - if os.path.exists(gpu_file): - t = read_timestamp(gpu_file) - if t: - print("[DEBUG] Using gpu-screen-recorder start time") - return t - - print("[DEBUG] No start time file found, using current time") - return time.time() - - def on_stop_recording(self, *args): - try: - self.set_visible(False) - self.cleanup_recording_state() - - def send_stop_command(): - try: - subprocess.Popen( - [self.script_path, "record", "stop"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - print(f"[DEBUG] Error sending stop command: {e}") - - GLib.idle_add(send_stop_command) - GLib.timeout_add(500, self._verify_recording_stopped) - GLib.timeout_add(1500, self._verify_recording_stopped) - GLib.timeout_add(3000, self._verify_recording_stopped) - - except Exception: - self.set_visible(False) - self.cleanup_recording_state() - - def _verify_recording_stopped(self): - try: - if self.is_recorder_running(): - if self.recording_start_time is None: - self.recording_start_time = self.get_recording_start_time() - - if self.recording_start_time: - self.set_visible(True) - self.update_timer_display() - - if self.timer_timeout_id is None: - self.timer_timeout_id = GLib.timeout_add( - self.timer_update_interval, self.update_timer_display - ) - else: - self.set_visible(False) - self.cleanup_recording_state() - - except Exception: - self.set_visible(False) - self.cleanup_recording_state() - - return False - - def _delayed_init(self): - try: - self.check_recording_status() - self.status_timeout_id = GLib.timeout_add( - self.status_check_interval, self.check_recording_status - ) - - except Exception as e: - print(f"[DEBUG] Error in delayed recording indicator init: {e}") - return False - - def destroy(self): - if self.timer_timeout_id: - GLib.source_remove(self.timer_timeout_id) - self.timer_timeout_id = None - - if self.status_timeout_id: - GLib.source_remove(self.status_timeout_id) - self.status_timeout_id = None - - super().destroy() diff --git a/modules/panel/main.py b/modules/panel/main.py deleted file mode 100644 index 9008264e..00000000 --- a/modules/panel/main.py +++ /dev/null @@ -1,250 +0,0 @@ -from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton -from fabric.system_tray.widgets import SystemTray -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.datetime import DateTime -from fabric.widgets.revealer import Revealer -from fabric.widgets.svg import Svg - -import config.data as data -from modules.controlcenter.main import ModusControlCenter -from modules.notification.notification_center import NotificationCenter -from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray -from modules.panel.components.indicators import ( - BatteryIndicator, - BluetoothIndicator, - NetworkIndicator, -) -from modules.panel.components.menubar import MenuBar -from modules.panel.components.recording_indicator import RecordingIndicator -from modules.todo.todo_widget import TodoListCapture -from services.modus import notification_service -from utils.functions import is_special_workspace_id -from utils.roam import modus_service -from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window - -# Apply enhanced system tray icon handling -apply_enhanced_system_tray() - - -class Panel(Window): - def __init__(self, **kwargs): - super().__init__( - name="bar", - title="modus", - layer="top", - anchor="left top right", - exclusivity="auto", - visible=False, - ) - - self.launcher = kwargs.get("launcher", None) - self.menubar = MenuBar(parent_window=self) - - self.workspace_indicator = HyprlandWorkspaces( - name="workspaces", - spacing=4, - buttons_factory=lambda ws_id: ( - None - if data.HIDE_SPECIAL_WORKSPACE and is_special_workspace_id(ws_id) - else WorkspaceButton(id=ws_id, label=str(ws_id)) - ), - ) - - self.imac = Button( - name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"), - ), - on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), - ) - - self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20) - self.tray_revealer = Revealer( - name="tray-revealer", - child=self.tray, - child_revealed=False, - transition_type="slide-left", - transition_duration=300, - ) - - self.chevron_button = Button( - name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path( - "../../config/assets/icons/misc/chevron-right.svg" - ), - ), - on_clicked=self.toggle_tray, - ) - - self.indicators = Box( - name="indicators", - orientation="h", - spacing=4, - children=[ - BatteryIndicator(), - NetworkIndicator(), - BluetoothIndicator(), - ], - ) - - self.search = Button( - name="panel-button", - on_clicked=lambda *_: self.search_apps(), - child=Svg( - size=22, - svg_file=get_relative_path("../../config/assets/icons/misc/search.svg"), - ), - ) - - self.control_center = MouseCapture( - layer="top", child_window=ModusControlCenter() - ) - - self.control_center_button = Button( - name="control-center-button", - style_classes="button", - on_clicked=self.control_center.toggle_mousecapture, - child=Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/misc/control-center.svg" - ), - ), - ) - - # Notification Center with MouseCapture - self.notification_center = MouseCapture( - layer="overlay", child_window=NotificationCenter() - ) - - # Todo List with MouseCapture - self.todo_list = TodoListCapture() - - # Notification Center Icon - self.notification_icon = Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/notifications/notification-inactive.svg" - ), - ) - - self.notification_center_icon_button = Button( - name="notification-center-icon-button", - child=self.notification_icon, - on_clicked=self.on_notification_icon_clicked, - ) - - # Clickable DateTime for todo list - self.datetime_button = Button( - name="datetime-button", - child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), - on_clicked=self.on_datetime_clicked, - ) - - self.recording_indicator = RecordingIndicator() - - self.children = CenterBox( - name="panel", - start_children=Box( - name="modules-left", - children=[ - self.imac, - self.menubar, - ], - ), - center_children=Box( - name="modules-center", - children=self.recording_indicator, - ), - end_children=Box( - name="modules-right", - spacing=4, - orientation="h", - children=[ - self.workspace_indicator, - self.tray_revealer, - self.chevron_button, - self.indicators, - self.search, - self.control_center_button, - self.datetime_button, - self.notification_center_icon_button, - ], - ), - ) - - # Connect to DND state changes for notification icon - modus_service.connect("dont-disturb-changed", self.on_dnd_changed) - - # Connect to notification service for icon state updates - notification_service.connect( - "notify::count", self.on_notification_count_changed - ) - - # Set initial notification icon state - self.update_notification_icon() - - return self.show_all() - - def search_apps(self): - self.launcher.show_launcher() - - def toggle_tray(self, *_): - current_state = self.tray_revealer.child_revealed - self.tray_revealer.child_revealed = not current_state - - if self.tray_revealer.child_revealed: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-left.svg") - ) - else: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-right.svg") - ) - - def on_dnd_changed(self, _, dnd_state): - """Handle DND state changes from the service.""" - self.update_notification_icon() # Update notification icon when DND changes - - def on_notification_count_changed(self, service, *args): - """Handle notification count changes from the service.""" - self.update_notification_icon() - - def on_notification_icon_clicked(self, *args): - """Handle notification icon clicks - only open center if there are notifications.""" - count = notification_service.count - if count > 0: - # Only open notification center if there are notifications - self.notification_center.toggle_mousecapture() - # Do nothing if no notifications - - def on_datetime_clicked(self, *args): - """Handle datetime button clicks - open todo list.""" - self.todo_list.toggle_mousecapture() - - def update_notification_icon(self): - """Update the notification icon based on count and DND state.""" - count = notification_service.count - dnd_enabled = modus_service.dont_disturb - - if dnd_enabled: - # DND is enabled - show disabled icon - icon_file = "notification-disabled.svg" - elif count > 0: - # Has notifications - show active icon - icon_file = "notification-active.svg" - else: - # No notifications - show inactive icon - icon_file = "notification-inactive.svg" - - icon_path = get_relative_path( - f"../../config/assets/icons/notifications/{icon_file}" - ) - self.notification_icon.set_from_file(icon_path) diff --git a/modules/switcher.py b/modules/switcher.py deleted file mode 100644 index 564c54a7..00000000 --- a/modules/switcher.py +++ /dev/null @@ -1,405 +0,0 @@ -import json - -import gi -from gi.repository import Gdk, Glace - -import config.data as data -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.widgets.box import Box -from fabric.widgets.eventbox import EventBox -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from utils.icon_resolver import IconResolver -from utils.occlusion import get_screen_dimensions -from utils.functions import is_special_workspace -from widgets.wayland import WaylandWindow as Window - -gi.require_version("Glace", "0.1") - - -class ApplicationSwitcher(Window): - def __init__(self, **kwargs): - super().__init__( - name="application-switcher", - title="modus-switcher", - layer="top", - anchor="center", - exclusivity="auto", - keyboard_mode="exclusive", - visible=False, # Start hidden until explicitly shown - **kwargs, - ) - - self.conn = get_hyprland_connection() - self.icon_resolver = IconResolver() - self.windows = [] - self.current_index = 0 - self.tab_pressed = False - self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW - self.icon_size = 64 - - # Initialize Glace manager for window previews - self._manager = Glace.Manager() - - # Calculate preview size based on screen ratio - # Formula: screen_ratio = a:b, width = x, height = (x*b)/a - screen_width, screen_height = get_screen_dimensions() - preview_width = 150 # Base width - preview_height = int((preview_width * screen_height) / screen_width) - self.preview_size = [preview_width, preview_height] - - self.glace_clients = {} # Map window addresses to Glace clients - self.window_previews = {} # Map window addresses to preview images - - container = Box( - name="application-switcher-container", - orientation="v", - h_align="center", - v_align="center", - expand=True, - ) - self.add(container) - - self.view = Box( - name="application-switcher-view", - orientation="v", - spacing=12, - h_align="center", - v_align="center", - ) - container.add(self.view) - self.connect("key-press-event", self.on_key_press) - self.connect("key-release-event", self.on_key_release) - - # Connect to Glace manager signals to track clients - self._manager.connect("client-added", self._on_glace_client_added) - self._manager.connect("client-removed", self._on_glace_client_removed) - - self.show_all() - self.hide() - - def show_switcher(self) -> None: - self.update_windows() - if not self.windows: - return - - self.show() - self.grab_keyboard() - self.tab_pressed = False - - def hide_switcher(self) -> None: - self.hide() - self.ungrab_keyboard() - - def _on_glace_client_added(self, _, client): - """Handle when a Glace client is added""" - try: - # Map the client by its window address for later lookup - # We'll need to match this with Hyprland window data - client_id = client.get_id() - self.glace_clients[client_id] = client - except Exception as e: - print(f"Error adding Glace client: {e}") - - def _on_glace_client_removed(self, _, client): - """Handle when a Glace client is removed""" - try: - client_id = client.get_id() - if client_id in self.glace_clients: - del self.glace_clients[client_id] - except Exception as e: - print(f"Error removing Glace client: {e}") - - def _find_glace_client_for_window(self, window): - """Find the corresponding Glace client for a Hyprland window""" - try: - window_class = window.get("class", "").lower() - window_title = window.get("title", "") - - # Try to match by app_id/class and title - for _, client in self.glace_clients.items(): - try: - client_app_id = client.get_app_id() - client_title = client.get_title() - - if ( - client_app_id - and client_app_id.lower() == window_class - and client_title - and client_title == window_title - ): - return client - except Exception: - continue - - # Fallback: try to match by class only - for _, client in self.glace_clients.items(): - try: - client_app_id = client.get_app_id() - if client_app_id and client_app_id.lower() == window_class: - return client - except Exception: - continue - - except Exception as e: - print(f"Error finding Glace client: {e}") - - return None - - def create_preview_for_window(self, window): - """Create a preview image for a specific window""" - glace_client = self._find_glace_client_for_window(window) - - # Create a placeholder image first - preview_image = Image() - - if glace_client: - - def capture_callback(pbuf, _): - try: - scaled_pixbuf = pbuf.scale_simple( - self.preview_size[0], - self.preview_size[1], - 2, # GdkPixbuf.InterpType.BILINEAR - ) - preview_image.set_from_pixbuf(scaled_pixbuf) - except Exception as e: - print(f"Error setting preview image: {e}") - - try: - self._manager.capture_client( - client=glace_client, - overlay_cursor=False, - callback=capture_callback, - user_data=None, - ) - except Exception as e: - print(f"Error capturing client preview: {e}") - # Fallback to icon if preview fails - self._set_fallback_icon(preview_image, window) - else: - # Use icon as fallback if no Glace client found - self._set_fallback_icon(preview_image, window) - - return preview_image - - def _set_fallback_icon(self, image_widget, window): - """Set a fallback icon when preview is not available""" - class_name = window.get("class", "").lower() - icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size) - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "application-x-executable-symbolic", self.icon_size - ) - image_widget.set_from_pixbuf(icon_img) - - def _is_special_workspace(self, client): - return is_special_workspace(client) - - def update_windows(self) -> None: - for child in self.view.get_children(): - self.view.remove(child) - - try: - clients_data = self.conn.send_command("j/clients").reply - if not clients_data: - return - clients = json.loads(clients_data.decode("utf-8")) - - # Filter out hidden windows and optionally special workspace windows - filtered_windows = [] - for c in clients: - if c.get("hidden", False): - continue - # Skip clients in special workspaces if the setting is enabled - if ( - data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS - and self._is_special_workspace(c) - ): - continue - filtered_windows.append(c) - - self.windows = filtered_windows - - active_data = self.conn.send_command("j/activewindow").reply - active_window = ( - json.loads(active_data.decode("utf-8")) if active_data else None - ) - - self.current_index = 0 - if active_window: - for i, window in enumerate(self.windows): - if window.get("address") == active_window.get("address"): - self.current_index = i - break - - current_row = Box( - name="window-row", - orientation="h", - spacing=12, - h_align="center", - v_align="center", - ) - self.view.add(current_row) - - for i, window in enumerate(self.windows): - title = window.get("title", "") - - # Create preview image for this window - preview_image = self.create_preview_for_window(window) - - button_content = Box( - name="switcher-button", - orientation="v", - spacing=4, - h_align="center", - v_align="center", - children=[ - Box( - name="switcher-preview-box", - style_classes=["window-basic", "sleek-border"], - children=[preview_image], - h_align="center", - v_align="center", - ), - Label( - label=title[:15] + "..." if len(title) > 15 else title, - h_align="center", - v_align="center", - max_width_chars=15, - ellipsize="end", - ), - ], - ) - - event_box = EventBox( - name="window-button", - style_classes=["active"] if i == self.current_index else None, - child=button_content, - ) - current_row.add(event_box) - - if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows): - current_row = Box( - name="window-row", - orientation="h", - spacing=12, - h_align="center", - v_align="center", - ) - self.view.add(current_row) - - self.view.show_all() - self.update_selection() - except Exception as e: - print(f"Failed to update windows: {e}") - - def on_key_press(self, _, event): - keyval = event.keyval - state = event.state - alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK) - - if not self.windows: - return False - - if keyval == Gdk.KEY_Escape: - self.hide_switcher() - return True - - if keyval == Gdk.KEY_Tab: - if not self.tab_pressed or alt_pressed: - self.current_index = (self.current_index + 1) % len(self.windows) - self.update_selection() - self.tab_pressed = True - return True - - if keyval == Gdk.KEY_ISO_Left_Tab or ( - keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK) - ): - self.current_index = (self.current_index - 1) % len(self.windows) - self.update_selection() - return True - - if keyval == Gdk.KEY_Return: - self.activate_selected() - self.hide_switcher() - return True - - if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l: - self.current_index = (self.current_index + 1) % len(self.windows) - self.update_selection() - return True - - if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h: - self.current_index = (self.current_index - 1) % len(self.windows) - self.update_selection() - return True - - if keyval == Gdk.KEY_Down: - next_index = self.current_index + self.items_per_row - if next_index < len(self.windows): - self.current_index = next_index - self.update_selection() - return True - - if keyval == Gdk.KEY_Up: - next_index = self.current_index - self.items_per_row - if next_index >= 0: - self.current_index = next_index - self.update_selection() - return True - - return False - - def on_key_release(self, _, event): - keyval = event.keyval - - if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R): - self.activate_selected() - self.hide_switcher() - return True - - if keyval == Gdk.KEY_Tab: - self.tab_pressed = False - return True - - return False - - def update_selection(self): - for row in self.view.get_children(): - for i, child in enumerate(row.get_children()): - index = self.view.get_children().index(row) * self.items_per_row + i - if index == self.current_index: - child.add_style_class("active") - else: - child.remove_style_class("active") - - def activate_selected(self): - if not self.windows or self.current_index >= len(self.windows): - return - - window = self.windows[self.current_index] - address = window.get("address") - if address: - try: - command = f"/dispatch focuswindow address:{address}" - self.conn.send_command(command) - except Exception as e: - print(f"Failed to focus window: {e}") - - def grab_keyboard(self): - try: - display = Gdk.Display.get_default() - seat = display.get_default_seat() - window = self.get_window() - seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None) - except Exception as e: - print(f"Failed to grab keyboard: {e}") - - def ungrab_keyboard(self): - try: - display = Gdk.Display.get_default() - seat = display.get_default_seat() - seat.ungrab() - except Exception as e: - print(f"Failed to ungrab keyboard: {e}") diff --git a/modules/todo/__init__.py b/modules/todo/__init__.py deleted file mode 100644 index 30afe866..00000000 --- a/modules/todo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Todo module \ No newline at end of file diff --git a/modules/widget.py b/modules/widget.py deleted file mode 100644 index 21ce2876..00000000 --- a/modules/widget.py +++ /dev/null @@ -1,887 +0,0 @@ -# Standard library imports -import psutil -import requests -import urllib.parse -import datetime -import time -import subprocess -import calendar -from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Tuple, List, Dict, Any - -# Fabric imports -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.datetime import DateTime -from fabric.widgets.circularprogressbar import CircularProgressBar -from widgets.wayland import WaylandWindow as Window -from fabric.utils import invoke_repeater -from gi.repository import GLib - -# Local imports -from config.data import load_config - -# Module-level constants -WEATHER_UPDATE_INTERVAL = 600 # 10 minutes -WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes -SYSTEM_UPDATE_INTERVAL = 1000 # 1 second -CALENDAR_UPDATE_INTERVAL = int( - ( - ( - datetime.datetime.combine( - datetime.date.today() + datetime.timedelta(days=1), datetime.time.min - ) - - datetime.datetime.now() - ).total_seconds() - ) - * 1000 -) # Calculate time till midnight -LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h) - -# Thread pool for async operations -executor = ThreadPoolExecutor(max_workers=4) - -# Weather condition to CSS class mapping (iOS-style gradients) -WEATHER_GRADIENT_MAP = { - # Clear/Sunny conditions - bright blue to lighter blue - 0: "weather-clear", # Clear sky - 1: "weather-mostly-clear", # Mainly clear - # Cloudy conditions - grey gradients - 2: "weather-partly-cloudy", # Partly cloudy - 3: "weather-overcast", # Overcast - # Fog conditions - muted grey/blue - 45: "weather-fog", # Fog - 48: "weather-fog", # Depositing rime fog - # Light rain/drizzle - blue-grey gradients - 51: "weather-light-rain", # Light drizzle - 53: "weather-rain", # Moderate drizzle - 55: "weather-rain", # Dense drizzle - 61: "weather-light-rain", # Slight rain - 80: "weather-light-rain", # Slight rain showers - # Heavy rain - darker blue-grey - 63: "weather-heavy-rain", # Moderate rain - 65: "weather-heavy-rain", # Heavy rain - 81: "weather-heavy-rain", # Moderate rain showers - 82: "weather-storm", # Violent rain showers - # Snow conditions - blue-white gradients - 56: "weather-snow", # Light freezing drizzle - 57: "weather-snow", # Dense freezing drizzle - 66: "weather-snow", # Light freezing rain - 67: "weather-snow", # Heavy freezing rain - 71: "weather-snow", # Slight snow fall - 73: "weather-heavy-snow", # Moderate snow fall - 75: "weather-heavy-snow", # Heavy snow fall - 77: "weather-snow", # Snow grains - 85: "weather-snow", # Slight snow showers - 86: "weather-heavy-snow", # Heavy snow showers - # Storm conditions - dark dramatic gradients - 95: "weather-storm", # Thunderstorm - 96: "weather-storm", # Thunderstorm with slight hail - 99: "weather-storm", # Thunderstorm with heavy hail -} - -# Weather condition to emoji mapping -WEATHER_EMOJI_MAP = { - 0: "☀️", # Clear sky - 1: "🌤️", # Mainly clear - 2: "⛅", # Partly cloudy - 3: "☁️", # Overcast - 45: "🌫️", # Fog - 48: "🌫️", # Depositing rime fog - 51: "🌦️", # Light drizzle - 53: "🌧️", # Moderate drizzle - 55: "🌧️", # Dense drizzle - 56: "🌨️", # Light freezing drizzle - 57: "🌨️", # Dense freezing drizzle - 61: "🌦️", # Slight rain - 63: "🌧️", # Moderate rain - 65: "🌧️", # Heavy rain - 66: "🌨️", # Light freezing rain - 67: "🌨️", # Heavy freezing rain - 71: "🌨️", # Slight snow fall - 73: "❄️", # Moderate snow fall - 75: "❄️", # Heavy snow fall - 77: "🌨️", # Snow grains - 80: "🌦️", # Slight rain showers - 81: "🌧️", # Moderate rain showers - 82: "⛈️", # Violent rain showers - 85: "🌨️", # Slight snow showers - 86: "❄️", # Heavy snow showers - 95: "⛈️", # Thunderstorm - 96: "⛈️", # Thunderstorm with slight hail - 99: "⛈️", # Thunderstorm with heavy hail -} - -# Weather condition descriptions -WEATHER_DESC_MAP = { - 0: "Clear sky", - 1: "Mainly clear", - 2: "Partly cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Depositing rime fog", - 51: "Light drizzle", - 53: "Moderate drizzle", - 55: "Dense drizzle", - 56: "Light freezing drizzle", - 57: "Dense freezing drizzle", - 61: "Slight rain", - 63: "Moderate rain", - 65: "Heavy rain", - 66: "Light freezing rain", - 67: "Heavy freezing rain", - 71: "Slight snow", - 73: "Moderate snow", - 75: "Heavy snow", - 77: "Snow grains", - 80: "Light rain showers", - 81: "Moderate rain showers", - 82: "Violent rain showers", - 85: "Slight snow showers", - 86: "Heavy snow showers", - 95: "Thunderstorm", - 96: "Thunderstorm with hail", - 99: "Thunderstorm with heavy hail", -} - -# Location APIs in order of preference (fastest first) -LOCATION_APIS = [ - "https://ipapi.co/json/", # Fastest, 200ms average - "http://ip-api.com/json/", # Fast fallback, 150ms average - "https://ipinfo.io/json", # Original fallback -] - -# Global cache for weather data -_weather_cache: Dict[str, Tuple[Any, float]] = {} -_location_cache: Dict[str, Tuple[float, float, float]] = {} - - -def get_location() -> str: - """Get current location using multiple IP geolocation APIs with fallback.""" - for api_url in LOCATION_APIS: - try: - response = requests.get(api_url, timeout=2) - if response.status_code == 200: - data = response.json() - # Handle different API response formats - city = data.get("city", "") - if city: - return city.replace(" ", "") - except requests.RequestException as e: - print(f"Location API {api_url} failed: {e}") - continue - - print("All location APIs failed") - return "" - - -def get_coordinates(city: str) -> Optional[Tuple[float, float]]: - """Get coordinates for a city using Nominatim geocoding API.""" - cache_key = city.lower() - current_time = time.time() - - # Check cache first (cache for 7 days) - if cache_key in _location_cache: - lat, lon, timestamp = _location_cache[cache_key] - if current_time - timestamp < LOCATION_CACHE_TIMEOUT: - return lat, lon - - try: - encoded_city = urllib.parse.quote(city) - url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1" - response = requests.get( - url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"} - ) - - if response.status_code == 200: - data = response.json() - if data: - lat = float(data[0]["lat"]) - lon = float(data[0]["lon"]) - _location_cache[cache_key] = (lat, lon, current_time) - return lat, lon - except (requests.RequestException, ValueError, KeyError) as e: - print(f"Error geocoding {city}: {e}") - - return None - - -def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]: - """Fetch weather data from Open-Meteo API.""" - try: - url = ( - f"https://api.open-meteo.com/v1/forecast?" - f"latitude={lat}&longitude={lon}" - f"¤t_weather=true" - f"&daily=temperature_2m_max,temperature_2m_min" - f"&timezone=auto" - f"&forecast_days=1" - ) - - response = requests.get(url, timeout=3) - if response.status_code == 200: - return response.json() - except requests.RequestException as e: - print(f"Error fetching weather data: {e}") - - return None - - -def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]: - """Format weather data into the expected format.""" - try: - current = weather_data["current_weather"] - daily = weather_data["daily"] - - # Get weather code and map to emoji and description - weather_code = current["weathercode"] - emoji = WEATHER_EMOJI_MAP.get(weather_code, "🌤️") - condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") - gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear") - - # Temperature - temp = f"{round(current['temperature'])}°" - - # Daily min/max temperatures - max_temp = f"{round(daily['temperature_2m_max'][0])}°" - min_temp = f"{round(daily['temperature_2m_min'][0])}°" - - return [emoji, temp, condition, city, max_temp, min_temp, gradient_class] - - except (KeyError, IndexError, TypeError) as e: - print(f"Error formatting weather data: {e}") - return None - - -def get_weather(callback): - """Fetch weather data asynchronously using Open-Meteo API.""" - - def fetch_weather(): - # Get location - location = get_location() - if not location: - return GLib.idle_add(callback, None) - - # Check cache first - cache_key = location.lower() - current_time = time.time() - - if cache_key in _weather_cache: - cached_data, timestamp = _weather_cache[cache_key] - if current_time - timestamp < WEATHER_CACHE_TIMEOUT: - return GLib.idle_add(callback, cached_data) - - # Get coordinates for the location - coords = get_coordinates(location) - if not coords: - return GLib.idle_add(callback, None) - - lat, lon = coords - - # Fetch weather data - weather_data = get_weather_data(lat, lon) - if not weather_data: - return GLib.idle_add(callback, None) - - # Format data - formatted_data = format_weather_data(weather_data, location) - if formatted_data: - # Cache the result - _weather_cache[cache_key] = (formatted_data, current_time) - GLib.idle_add(callback, formatted_data) - else: - GLib.idle_add(callback, None) - - executor.submit(fetch_weather) - - -def update_weather(widget): - """Update weather widget with new data.""" - - def fetch_and_update(): - get_weather(lambda weather_info: update_widget(widget, weather_info)) - return True - - GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) - fetch_and_update() - - -def update_widget(widget, weather_info): - """Update widget labels with weather information.""" - if weather_info: - widget.weatherinfo = weather_info - widget.update_labels(weather_info) - - -class Weather(Box): - """Weather widget displaying current conditions and forecast.""" - - def __init__(self, parent, **kwargs): - super().__init__( - name="weather-widget", - h_expand=True, - v_expand=True, - justification="right", - orientation="v", - all_visible=False, - **kwargs, - ) - - self.parent = parent - self.weatherinfo = None - - # Create labels with better organization - self._create_labels() - self._layout_labels() - - # Start weather updates - update_weather(self) - - def _create_labels(self): - """Create all weather labels.""" - self.city = Label( - name="city", - label="Loading...", - justification="right", - h_align="start", - max_chars_width=12, - ellipsization="end", - ) - self.temperature = Label(name="temperature", label="--°", h_align="start") - self.condition_em = Label(name="condition-emoji", label="🌤️", h_align="start") - self.condition = Label( - name="condition", - label="Loading...", - max_chars_width=18, - ellipsization="end", - h_align="start", - ) - self.feels_like = Label(name="feels-like", label="H:-- L:--", h_align="start") - - def _layout_labels(self): - """Add labels to the widget in proper order.""" - labels = [ - self.city, - self.temperature, - self.condition_em, - self.condition, - self.feels_like, - ] - for label in labels: - self.add(label) - - def update_labels(self, weather_info: List[str]): - """Update weather labels with new data.""" - if not weather_info or len(weather_info) != 7: - return - - emoji, temp, condition, location, maxtemp, mintemp, gradient_class = ( - weather_info - ) - maxmin = f"H:{maxtemp} L:{mintemp}" - - # Batch update labels for better performance - label_updates = [ - (self.city, location), - (self.temperature, temp), - (self.condition_em, emoji), - (self.condition, condition), - (self.feels_like, maxmin), - ] - - for label, text in label_updates: - label.set_label(text) - - # Apply gradient background based on weather condition - self.parent.set_visible(True) - - -class WeatherContainer(Box): - """Container for weather widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="weather-container", - v_expand=True, - v_align="center", - size=(170, 170), - visible=True, - h_align="center", - children=[Weather(self)], - **kwargs, - ) - - -class Date(Box): - """Date widget displaying day, month, and date.""" - - def __init__(self, **kwargs): - super().__init__( - name="date-widget", - h_expand=True, - v_expand=True, - justification="center", - h_align="center", - v_align="start", - orientation="v", - **kwargs, - ) - - # Create date components - self.top = Box(orientation="h", name="date-top", h_expand=True) - - # Use consistent interval for all date components - date_interval = 10000 # 10 seconds - self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") - self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") - self.datethree = DateTime( - formatters=["%-d"], interval=date_interval, name="date" - ) - - # Layout components - self.top.add(self.dateone) - self.top.add(self.datetwo) - self.add(self.top) - self.add(self.datethree) - - -class DateContainer(Box): - """Container for date widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="date-container", - v_expand=True, - size=(170, 170), - v_align="center", - h_align="center", - children=[Date()], - **kwargs, - ) - - -class Calendar(Box): - """Calendar widget displaying current month.""" - - def __init__(self, **kwargs): - # Set Sunday as first day of week - calendar.setfirstweekday(6) # 6 = Sunday - super().__init__( - name="calendar-widget", - h_expand=True, - v_expand=True, - orientation="v", - **kwargs, - ) - - # Cache current date for efficiency - self._update_current_date() - - # Create calendar components - self._create_header() - self._create_days_header() - self._create_calendar_grid() - - # Layout components - self.add(self.month_label) - self.add(self.days_header) - self.add(self.calendar_grid) - - # Schedule updates - invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed) - - def _update_current_date(self): - """Update cached current date values.""" - now = datetime.datetime.now() - self.current_month = now.month - self.current_year = now.year - self.current_day = now.day - - def _create_header(self): - """Create month header label.""" - self.month_label = Label( - name="calendar-month", - label=calendar.month_name[self.current_month], - h_align="start", - justification="left", - ) - - def _create_days_header(self): - """Create day abbreviations header.""" - self.days_header = Box( - name="calendar-days-header", orientation="h", h_expand=True, spacing=2 - ) - - day_names = ["S", "M", "T", "W", "T", "F", "S"] - for i, day_name in enumerate(day_names): - is_weekend = i in (0, 6) # Sunday or Saturday - day_label = Label( - name=( - "calendar-day-header-weekend" - if is_weekend - else "calendar-day-header" - ), - label=day_name, - h_align="center", - h_expand=True, - ) - self.days_header.add(day_label) - - def _create_calendar_grid(self): - """Create calendar grid container.""" - self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) - self.update_calendar() - - def update_calendar_if_needed(self) -> bool: - """Check if date changed and update calendar if needed.""" - now = datetime.datetime.now() - if ( - now.month != self.current_month - or now.year != self.current_year - or now.day != self.current_day - ): - - self._update_current_date() - self.update_calendar() - return True - - def update_calendar(self): - """Update the calendar grid with current month.""" - # Clear existing calendar efficiently - children = self.calendar_grid.get_children() - for child in children: - self.calendar_grid.remove(child) - - # Update month label - self.month_label.set_label(calendar.month_name[self.current_month]) - - # Generate calendar - cal = calendar.monthcalendar(self.current_year, self.current_month) - current_date = datetime.datetime.now() - - for week in cal: - week_box = Box(orientation="h", spacing=2, h_expand=True) - - for day_index, day in enumerate(week): - if day == 0: - # Empty day slot - day_label = Label( - name="calendar-day-empty", - label="", - h_align="center", - h_expand=True, - ) - else: - # Regular day - is_today = ( - day == self.current_day - and self.current_month == current_date.month - and self.current_year == current_date.year - ) - is_weekend = day_index in (0, 6) # Sunday or Saturday - - if is_today: - name = "calendar-day-today" - elif is_weekend: - name = "calendar-day-weekend" - else: - name = "calendar-day" - - day_label = Label( - name=name, label=str(day), h_align="center", h_expand=True - ) - - week_box.add(day_label) - self.calendar_grid.add(week_box) - - -class CalendarContainer(Box): - """Container for calendar widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="calendar-box-widget", - v_expand=True, - size=(170, 170), - v_align="center", - h_align="center", - children=[Calendar()], - **kwargs, - ) - - -class SystemInfoBase(Box): - """Base class for system information widgets.""" - - @staticmethod - def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): - """Create a standardized circular progress bar.""" - return CircularProgressBar( - name=name, - start_angle=270, - end_angle=630, - min_value=0, - max_value=100, - size=size, - **kwargs, - ) - - def __init__(self, name: str, **kwargs): - super().__init__( - layer="bottom", - title="sysinfo", - name=name, - visible=True, - size=(170, 170), - h_expand=True, - v_expand=True, - all_visible=True, - **kwargs, - ) - - # Create progress bar and labels - self.progress = self.create_progress_bar(name="progress") - self.main_label = Label( - label="0%\nLoading", justification="center", name="progress-label" - ) - - # Create info container - self.info_container = Box( - name="info-container", - orientation="v", - spacing=2, - h_align="center", - ) - - # Create main layout - self.progress_container = Box( - name="progress-bar-container", - h_expand=True, - v_expand=True, - orientation="v", - spacing=12, - h_align="center", - v_align="center", - children=[ - Box( - children=[ - Overlay( - child=self.progress, - tooltip_text="", - overlays=self.main_label, - ) - ] - ), - Box( - h_align="center", - justification="centre", - orientation="v", - children=[self.info_container], - ), - ], - ) - - self.add(self.progress_container) - - # Don't start updates here - let subclasses call start_updates() when ready - - def start_updates(self): - """Start the update timer - call this after subclass initialization is complete.""" - invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) - - def create_info_line( - self, indicator_name: str, info_text: str, value_text: str - ) -> Box: - """Create an information line with indicator, label, and value.""" - indicator = Label(label="■", name=indicator_name) - info_label = Label(label=info_text, name="info-text") - value_label = Label(label=value_text, name="info-value") - - line = Box( - orientation="h", - spacing=4, - h_align="start", - children=[indicator, info_label, value_label], - ) - - # Store references for easy updates - line.indicator = indicator - line.info_label = info_label - line.value_label = value_label - - return line - - def update(self) -> bool: - """Override in subclasses.""" - raise NotImplementedError - - -class RamInfo(SystemInfoBase): - """RAM usage information widget.""" - - def __init__(self, **kwargs): - super().__init__("info-box-widget", **kwargs) - - # Create info lines and store references - self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB") - self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB") - - # Add to info container - self.info_container.add(self.used_line) - self.info_container.add(self.free_line) - - # Now that everything is set up, start updates - self.start_updates() - - def update(self) -> bool: - """Update RAM information.""" - try: - mem = psutil.virtual_memory() - - # Update main label - self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") - - # Calculate values - used_gb = mem.used / (1024**3) - free_gb = mem.available / (1024**3) - - # Update info labels using stored references - self.used_line.value_label.set_label(f"{round(used_gb, 1)}GB") - self.free_line.value_label.set_label(f"{round(free_gb, 1)}GB") - - # Update progress bar (use GLib.idle_add for thread safety) - GLib.idle_add(self.progress.set_value, mem.percent) - - except Exception as e: - print(f"Error updating RAM info: {e}") - - return True - - -class CpuInfo(SystemInfoBase): - """CPU usage and temperature information widget.""" - - def __init__(self, **kwargs): - super().__init__("info-box-widget", **kwargs) - - # Create temperature info components - self.temp_info = Label(label="Temp", name="info-text") - self.temp_value = Label(label="0°C", name="info-value") - - # Create temperature info line (no indicator) - self.temp_line = Box( - orientation="h", - spacing=4, - h_align="start", - children=[self.temp_info, self.temp_value], - ) - - # Add to info container - self.info_container.add(self.temp_line) - - # Now that everything is set up, start updates - self.start_updates() - - def get_cpu_temp(self) -> Optional[float]: - """Get CPU temperature from system sensors.""" - try: - temps = psutil.sensors_temperatures() - if not temps: - return None - - # Search for CPU temperature sensors - cpu_sensor_names = ["coretemp", "k10temp", "cpu"] - cpu_label_patterns = ["package id 0", "core 0", ""] - - for name, entries in temps.items(): - if any(sensor in name.lower() for sensor in cpu_sensor_names): - for entry in entries: - entry_label = (entry.label or "").lower() - if any( - pattern in entry_label for pattern in cpu_label_patterns - ): - return round(entry.current, 1) - except Exception as e: - print(f"Error reading CPU temperature: {e}") - - return None - - def update(self) -> bool: - """Update CPU information.""" - try: - # Get CPU usage - cpu = psutil.cpu_percent() - - # Update main label - self.main_label.set_label(f" {round(cpu):<2} %\nCPU") - - # Update temperature using stored reference - temp = self.get_cpu_temp() - temp_text = f"{temp}°C" if temp is not None else "N/A" - self.temp_value.set_label(temp_text) - - # Update progress bar (use GLib.idle_add for thread safety) - GLib.idle_add(self.progress.set_value, cpu) - - except Exception as e: - print(f"Error updating CPU info: {e}") - - return True - - -class Deskwidgets(Window): - """Desktop widgets manager - handles all desktop widgets.""" - - config = load_config() - - def __init__(self, **kwargs): - # Create the main invisible window that manages the widgets - super().__init__( - name="desktop-widget-manager", - layer="bottom", - title="modus-desktop-widget-manager", - visible=False, # This window is invisible - just manages the others - size=(1, 1), # Minimal size - anchor="top left", - **kwargs, - ) - - # Create separate independent windows as attributes - self.top_left = Window( - anchor="top left", - title="modus-widgets-topleft", - orientation="h", - layer="bottom", - visible=False, # Start hidden until content ready - child=Box( - name="desktop-widgets-container", - children=[ - DateContainer(), - WeatherContainer(), - CalendarContainer(), - ], - ), - ) - - self.bottom_left = Window( - anchor="bottom right", - title="modus-widgets-bottomright", - orientation="h", - layer="bottom", - visible=False, # Start hidden until content ready - child=Box( - name="desktop-widgets-container", - children=[ - CpuInfo(), - RamInfo(), - ], - ), - ) - - # Show widgets after initialization is complete - self.top_left.set_visible(True) - self.bottom_left.set_visible(True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..aec3b3b0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,109 @@ +[project] +name = "modus" +version = "2.0.0" +description = "A hackable shell for Hyprland, built with Fabric" +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.13" +authors = [ + { name = "SANKALP", email = "sankalp@modus.dev" }, +] +keywords = ["hyprland", "wayland", "shell", "fabric", "desktop"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.13", + "Topic :: Desktop Environment", +] +dependencies = [ + "fabric", + "httpx>=0.28.1", + "pam>=0.2.0", + "psutil>=7.2.2", + "six>=1.17.0", + "tomlkit>=0.15.0", +] + +[project.urls] +Homepage = "https://github.com/S4NKALP/Modus" +Documentation = "https://github.com/S4NKALP/Modus/tree/main/docs" +Repository = "https://github.com/S4NKALP/Modus" +Issues = "https://github.com/S4NKALP/Modus/issues" +Discord = "https://discord.gg/tRFxkbQ3Zq" + +[dependency-groups] +dev = [ + "pre-commit>=4.6.0", + "ruff>=0.15.2", +] + +[project.scripts] +start = "main:main" +lock = "window.lock:main" + +[tool.ruff] +target-version = "py313" +line-length = 88 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "C4", # flake8-comprehensions + "RET", # flake8-return + "RUF", # ruff-specific rules + "PIE", # flake8-pie + "T20", # flake8-print + "PT", # flake8-pytest-style + "TCH", # flake8-type-checking + "FLY", # flynt (f-string conversion) +] +ignore = [ + "T201", # allow print() (used for CLI output) + "RET504", # allow unnecessary assignment before return + "SIM108", # allow ternary operator (readability) + "B008", # allow function calls in default args (common in GTK) + "UP007", # allow X | Y union syntax (keep Optional for compatibility) + "UP045", # allow Optional[X] annotation style + "UP006", # allow typing.List etc (keep for readability) + "UP035", # allow deprecated typing imports + "RUF012", # allow mutable class attributes (common in GTK widgets) + "TC001", # allow runtime imports that happen to be type-only + "E501", # line length handled by formatter + "RET505", # allow superfluous else after return + "B027", # allow empty method bodies (common in ABCs) + "B007", # allow unused loop variables + "SIM102", # allow collapsible if + "SIM105", # allow suppressible exception (contextlib.suppress) + "B023", # allow function uses loop variable (closures) + "RUF002", # allow ambiguous unicode in docstrings + "RUF003", # allow ambiguous unicode in comments + "SIM115", # allow open without context handler + "SIM116", # allow if-else instead of dict lookup + "B018", # allow useless expression (standalone identifiers) + "TC004", # allow imports in TYPE_CHECKING block (circular import prevention) +] + +[tool.ruff.lint.isort] +known-first-party = ["services", "utils", "shared", "window"] + +[tool.ruff.lint.per-file-ignores] +"src/main.py" = ["E402"] +"src/window/switcher/main.py" = ["E402"] +"examples/**" = ["INP001", "D101", "D102", "D103"] + +[tool.uv.sources] +fabric = { git = "https://github.com/Fabric-Development/fabric.git" } diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4da8a516..00000000 --- a/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -certifi==2025.8.3 -charset-normalizer==3.4.2 -click==8.2.1 -idna==3.10 -loguru==0.7.3 -pillow==12.2.0 -psutil==7.0.0 -pycairo==1.28.0 -pydbus==0.6.0 -PyGObject==3.52.3 -pyotp==2.9.0 -pyzbar==0.1.9 -RapidFuzz==3.13.0 -requests==2.33.0 -setproctitle==1.3.6 -thefuzz==0.22.1 -urllib3==2.6.3 diff --git a/scripts/gamemode.sh b/scripts/gamemode.sh deleted file mode 100755 index 3652ccdc..00000000 --- a/scripts/gamemode.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env sh - -# Check if animations are disabled (game mode is active) -check_gamemode() { - HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}') - if [ "$HYPRGAMEMODE" = 0 ]; then - echo "t" - return 0 - else - echo "f" - return 1 - fi -} - -# Toggle game mode state -toggle_gamemode() { - HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}') - if [ "$HYPRGAMEMODE" = 1 ]; then - 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" - exit - fi - hyprctl reload -} - -# Main script logic -case "$1" in -check) - check_gamemode - ;; -*) - toggle_gamemode - ;; -esac diff --git a/scripts/hyprpicker.sh b/scripts/hyprpicker.sh deleted file mode 100755 index 703c7df7..00000000 --- a/scripts/hyprpicker.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash - -pick_rgb() { - - # Execute hyprpicker with RGB format and save the output to a variable - hyprpicker -a -n -f rgb && sleep 0.1 - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"rgb($(wl-paste))" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "RGB color picked" "rgb($(wl-paste))" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -pick_hex() { - - # Execute hyprpicker and save the output to a variable - hyprpicker -a -n -f hex && sleep 0.1 - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"$(wl-paste)" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "HEX color picked" "$(wl-paste)" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -pick_hsv() { - - # Copy the color to the clipboard - echo -n "$(hyprpicker -n -f hsv)" | wl-copy -n - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"hsv($(wl-paste))" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "HSV color picked" "hsv($(wl-paste))" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -case "$1" in --rgb) - pick_rgb - ;; --hsv) - pick_hsv - ;; --hex) - pick_hex - ;; - -*) - echo "Usage: $0 [-rgb|-hex|-hsv]" - exit 1 - ;; -esac diff --git a/scripts/screen-capture.sh b/scripts/screen-capture.sh deleted file mode 100755 index a1fd8997..00000000 --- a/scripts/screen-capture.sh +++ /dev/null @@ -1,704 +0,0 @@ -#!/bin/env bash - -# Script name -SCRIPT_NAME=$(basename "$0") - -# Function to display usage -usage() { - cat <<EOF -Usage: $SCRIPT_NAME <command> [options] - -Commands: - screenshot <target> Take a screenshot - Targets: - selection - Screenshot selected area - eDP-1 - Screenshot eDP-1 display - HDMI-A-1 - Screenshot HDMI-A-1 display - both - Screenshot both displays - - record <target> Start/stop recording (with audio) - Targets: - selection - Record selected area - eDP-1 - Record eDP-1 display - HDMI-A-1 - Record HDMI-A-1 display - stop - Stop current recording - - record-noaudio <target> Start/stop recording (no audio) - Targets: - selection - Record selected area without audio - eDP-1 - Record eDP-1 display without audio - HDMI-A-1 - Record HDMI-A-1 display without audio - stop - Stop current recording - - record-hq <target> Start/stop high-quality recording (for YouTube) - Targets: - selection - Record selected area in high quality - eDP-1 - Record eDP-1 display in high quality - HDMI-A-1 - Record HDMI-A-1 display in high quality - stop - Stop current recording - - record-gif <target> Start/stop GIF recording - Targets: - selection - Record selected area as GIF - eDP-1 - Record eDP-1 display as GIF - HDMI-A-1 - Record HDMI-A-1 display as GIF - stop - Stop current recording - - status Check if recording is active (exit 0 if recording, 1 if not) - - convert <format> [file] Convert recordings - Formats: - webm - Convert latest MKV to WebM (or specify file) - iphone - Convert latest MKV to iPhone format (or specify file) - youtube - Convert latest video to YouTube format (or specify file) - gif - Convert latest video to GIF (or specify file) - - Optional [file] parameter: - - If not provided, converts the latest recorded video - - If provided, converts the specified file (full path or filename in Recordings folder) - -Examples: - $SCRIPT_NAME screenshot selection - $SCRIPT_NAME record eDP-1 - $SCRIPT_NAME record-noaudio selection - $SCRIPT_NAME record-hq eDP-1 - $SCRIPT_NAME record-gif selection - $SCRIPT_NAME record stop - $SCRIPT_NAME convert gif # Convert latest video to GIF - $SCRIPT_NAME convert youtube # Convert latest video for YouTube - $SCRIPT_NAME convert webm /path/to/video.mkv # Convert specific file to WebM - -EOF - exit 1 -} - -# Check if no arguments provided -if [ $# -eq 0 ]; then - usage -fi - -# Function to send screenshot notification with action buttons -send_screenshot_notification() { - local full_path="$1" - local save_dir=$(dirname "$full_path") - - ACTION=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \ - -A "view=View" -A "edit=Edit" -A "open=Open Folder") - - case "$ACTION" in - view) xdg-open "$full_path" ;; - edit) swappy -f "$full_path" ;; - open) xdg-open "$save_dir" ;; - esac -} - -# Function to send recording notification with action buttons -send_recording_notification() { - local full_path="$1" - local save_dir=$(dirname "$full_path") - - ACTION=$(notify-send -a "Modus" -i "camera-video-symbolic" "Recording saved" "in $full_path" \ - -A "view=View" -A "open=Open Folder") - - case "$ACTION" in - view) xdg-open "$full_path" ;; - open) xdg-open "$save_dir" ;; - esac -} - -wf-recorder_check() { - if pgrep -x "wf-recorder" >/dev/null; then - pkill -INT -x wf-recorder - # Get the recording file path and send notification with actions - if [ -f /tmp/recording.txt ]; then - recording_file=$(cat /tmp/recording.txt) - wl-copy <"$recording_file" - send_recording_notification "$recording_file" - else - notify-send "Recording stopped" "wf-recorder process terminated" - fi - # Clean up recording start time file - rm -f /tmp/recording_start_time.txt - exit 0 - fi -} - -# Function to record with standard settings -record_video() { - local output_file="$1" - shift - - wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" -} - -# Function to record without audio -record_video_noaudio() { - local output_file="$1" - shift - - wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" --no-audio -} - -record_high_quality() { - local output_file="$1" - shift - - # High quality settings for YouTube uploads - # - h264_vaapi for hardware encoding (if available) or libx264 for software - # - yuv420p pixel format for maximum compatibility - # - High bitrate (8000k) for quality - # - GOP size of 30 for better seeking - # - Preset 'slow' for better compression efficiency - # - CRF 18 for high quality (lower = better quality, 0-51 scale) - # - Audio at 192k bitrate - # - 60 FPS for smooth motion - # - No color filters to maintain original colors - - # Check if VAAPI hardware encoding is available - if vainfo &>/dev/null && wf-recorder --help | grep -q "h264_vaapi"; then - # Use hardware encoding for better performance - wf-recorder "$@" -f "$output_file" \ - -c h264_vaapi \ - -p "preset=slow" \ - -p "crf=18" \ - -r 60 \ - -b 8000000 \ - -B 192000 \ - --pixel-format yuv420p \ - -g 30 - else - # Fallback to software encoding - wf-recorder "$@" -f "$output_file" \ - -c libx264 \ - -p "preset=slow" \ - -p "crf=18" \ - -r 60 \ - -b 8000000 \ - -B 192000 \ - --pixel-format yuv420p \ - -g 30 - fi -} - -record_gif() { - local output_file="$1" - shift - - # Record temporary video first (MKV format for better quality) - local temp_video="/tmp/gif_recording_$(date +%s).mkv" - echo "$temp_video" >/tmp/gif_temp_video.txt - - # GIF-optimized recording settings: - # - Lower framerate (15 fps) for smaller file size - # - No audio recording - # - Standard codec for compatibility - wf-recorder "$@" -f "$temp_video" \ - -c libvpx-vp9 \ - -r 15 \ - --pixel-format yuv420p \ - --no-audio - - # After recording stops, convert to GIF - if [ -f "$temp_video" ]; then - notify-send "Converting to GIF" "Processing recording..." - - # Create high-quality GIF with optimized palette - # Using ffmpeg with palette generation for better colors - ffmpeg -i "$temp_video" \ - -vf "fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ - -loop 0 \ - "$output_file" 2>/tmp/gif_conversion.log - - if [ $? -eq 0 ]; then - # Clean up temp file - rm -f "$temp_video" - rm -f /tmp/gif_temp_video.txt - - # Copy to clipboard and send notification with actions - wl-copy <"$output_file" - send_recording_notification "$output_file" - else - error=$(cat /tmp/gif_conversion.log | tail -n 5) - notify-send "GIF Conversion Failed" "Error: $error" - rm -f "$temp_video" - rm -f /tmp/gif_temp_video.txt - fi - fi -} - -# Function to find the latest video file for conversion -find_latest_video() { - local format="$1" - local recording_dir="${HOME}/Videos/Recordings" - - case "$format" in - "webm"|"iphone") - # For webm and iphone, only look for MKV files - find "$recording_dir" -name "*.mkv" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- - ;; - "youtube"|"gif") - # For youtube and gif, look for both MKV and MP4 files - find "$recording_dir" \( -name "*.mkv" -o -name "*.mp4" \) -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- - ;; - *) - echo "" - ;; - esac -} - -# Function to resolve video file path -resolve_video_file() { - local format="$1" - local file_param="$2" - local recording_dir="${HOME}/Videos/Recordings" - - if [ -n "$file_param" ]; then - # File parameter provided - if [ -f "$file_param" ]; then - # Full path provided and exists - echo "$file_param" - elif [ -f "$recording_dir/$file_param" ]; then - # Filename provided, exists in recordings folder - echo "$recording_dir/$file_param" - else - echo "" - fi - else - # No file parameter, find latest - find_latest_video "$format" - fi -} - -# Parse command -COMMAND="$1" -TARGET="$2" -FILE_PARAM="$3" # Optional file parameter for convert command - -# Set up file paths -IMG="${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png" -VID="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv" - -case "$COMMAND" in -"screenshot") - case "$TARGET" in - "selection") - grim -g "$(slurp)" "$IMG" - wl-copy <"$IMG" - send_screenshot_notification "$IMG" - ;; - "eDP-1") - grim -c -o eDP-1 "$IMG" - wl-copy <"$IMG" - send_screenshot_notification "$IMG" - ;; - "HDMI-A-1") - grim -c -o HDMI-A-1 "$IMG" - wl-copy <"$IMG" - send_screenshot_notification "$IMG" - ;; - "both") - grim -c -o eDP-1 "${IMG//.png/-eDP-1.png}" - grim -c -o HDMI-A-1 "${IMG//.png/-HDMI-A-1.png}" - montage "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" -tile 2x1 -geometry +0+0 "$IMG" - wl-copy <"$IMG" - rm "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" - send_screenshot_notification "$IMG" - ;; - *) - echo "Error: Invalid screenshot target '$TARGET'" - usage - ;; - esac - ;; - -"record") - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video "$VID" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video "$VID" -a -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video "$VID" -a -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record target '$TARGET'" - usage - ;; - esac - ;; - -"record-noaudio") - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video_noaudio "$VID" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video_noaudio "$VID" -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$VID" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - record_video_noaudio "$VID" -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record-noaudio target '$TARGET'" - usage - ;; - esac - ;; - -"record-hq") - # Change file extension to mp4 for high quality recordings - VID_HQ="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4" - - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$VID_HQ" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "High Quality Recording" "Starting YouTube-quality recording..." - record_high_quality "$VID_HQ" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$VID_HQ" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "High Quality Recording" "Starting YouTube-quality recording on eDP-1..." - record_high_quality "$VID_HQ" -a -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$VID_HQ" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "High Quality Recording" "Starting YouTube-quality recording on HDMI-A-1..." - record_high_quality "$VID_HQ" -a -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record-hq target '$TARGET'" - usage - ;; - esac - ;; - -"record-gif") - # GIF files go to a specific location - GIF="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif" - - case "$TARGET" in - "stop") - wf-recorder_check - ;; - "selection") - wf-recorder_check - echo "$GIF" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "GIF Recording" "Starting GIF recording (15 FPS)..." - record_gif "$GIF" -g "$(slurp)" - ;; - "eDP-1") - wf-recorder_check - echo "$GIF" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "GIF Recording" "Starting GIF recording on eDP-1..." - record_gif "$GIF" -o eDP-1 - ;; - "HDMI-A-1") - wf-recorder_check - echo "$GIF" >/tmp/recording.txt - date +%s >/tmp/recording_start_time.txt - notify-send "GIF Recording" "Starting GIF recording on HDMI-A-1..." - record_gif "$GIF" -o HDMI-A-1 - ;; - *) - echo "Error: Invalid record-gif target '$TARGET'" - usage - ;; - esac - ;; - -"status") - # Check if wf-recorder is running - if pgrep -x "wf-recorder" >/dev/null; then - echo "true" - exit 0 - else - echo "false" - exit 0 - fi - ;; - -"convert") - case "$TARGET" in - "webm") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "webm" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "WebM Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "WebM Conversion Error" "No MKV files found in Recordings folder" - fi - exit 1 - fi - - # Ensure it's an MKV file - if [[ "$video_file" != *.mkv ]]; then - notify-send "WebM Conversion Error" "Only MKV files can be converted to WebM. Found: $(basename "$video_file")" - exit 1 - fi - - webm_file="${video_file%.mkv}.webm" - - # Check if webm version doesn't already exist - if [ -f "$webm_file" ]; then - notify-send "WebM Conversion Skipped" "WebM version already exists: $(basename "$webm_file")" - exit 0 - fi - - notify-send "Converting to WebM" "Processing: $(basename "$video_file")" - - # Convert the file - ffmpeg -y -i "$video_file" -c:v libvpx -b:v 1M -c:a libvorbis "$webm_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$webm_file" | cut -f1) - notify-send "WebM Conversion Success" "$(basename "$video_file") → $(basename "$webm_file") ($file_size)" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "WebM Conversion Failed" "Error: $error" - fi - ;; - - "iphone") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "iphone" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "iPhone Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "iPhone Conversion Error" "No MKV files found in Recordings folder" - fi - exit 1 - fi - - # Ensure it's an MKV file - if [[ "$video_file" != *.mkv ]]; then - notify-send "iPhone Conversion Error" "Only MKV files can be converted for iPhone. Found: $(basename "$video_file")" - exit 1 - fi - - base_filename=$(basename "$video_file") - - # Skip files with "iphone" in the filename - if [[ $base_filename == *"iphone"* ]]; then - notify-send "iPhone Conversion Skipped" "File already appears to be iPhone format: $(basename "$video_file")" - exit 0 - fi - - iphone_file="${video_file%.mkv}-iphone.mp4" - - # Check if iPhone version doesn't already exist - if [ -f "$iphone_file" ]; then - notify-send "iPhone Conversion Skipped" "iPhone version already exists: $(basename "$iphone_file")" - exit 0 - fi - - notify-send "Converting for iPhone" "Processing: $(basename "$video_file")" - - # Convert the file - ffmpeg -y -i "$video_file" -vcodec h264 -acodec aac "$iphone_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$iphone_file" | cut -f1) - notify-send "iPhone Conversion Success" "$(basename "$video_file") → $(basename "$iphone_file") ($file_size)" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "iPhone Conversion Failed" "Error: $error" - fi - ;; - - "youtube") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "youtube" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "YouTube Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "YouTube Conversion Error" "No video files found in Recordings folder" - fi - exit 1 - fi - - base_filename=$(basename "$video_file") - - # Skip files already marked as YouTube uploads - if [[ $base_filename == *"youtube"* ]]; then - notify-send "YouTube Conversion Skipped" "File already appears to be YouTube format: $(basename "$video_file")" - exit 0 - fi - - # Create YouTube optimized filename - youtube_file="${video_file%.*}-youtube.mp4" - - # Check if YouTube version doesn't already exist - if [ -f "$youtube_file" ]; then - notify-send "YouTube Conversion Skipped" "YouTube version already exists: $(basename "$youtube_file")" - exit 0 - fi - - notify-send "Converting for YouTube" "Processing: $(basename "$video_file")" - - # YouTube recommended settings: - # - H.264 codec with High profile - # - 1080p or source resolution - # - 60fps or source framerate - # - High bitrate for quality (8-12 Mbps for 1080p60) - # - AAC audio at 384kbps - # - yuv420p pixel format for compatibility - # - Keyframe interval of 2 seconds (GOP) - # - No filters to preserve original colors - - ffmpeg -y -i "$video_file" \ - -c:v libx264 \ - -profile:v high \ - -preset slow \ - -crf 18 \ - -pix_fmt yuv420p \ - -c:a aac \ - -b:a 384k \ - -movflags +faststart \ - "$youtube_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$youtube_file" | cut -f1) - notify-send "YouTube Conversion Success" "$(basename "$video_file") → $(basename "$youtube_file") ($file_size)" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "YouTube Conversion Failed" "Error converting $(basename "$video_file"): $error" - fi - ;; - - "gif") - # Check if ffmpeg is installed - if ! command -v ffmpeg >/dev/null 2>&1; then - notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." - exit 1 - fi - - # Resolve the video file to convert - video_file=$(resolve_video_file "gif" "$FILE_PARAM") - - if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then - if [ -n "$FILE_PARAM" ]; then - notify-send "GIF Conversion Error" "File not found: $FILE_PARAM" - else - notify-send "GIF Conversion Error" "No video files found in Recordings folder" - fi - exit 1 - fi - - base_filename=$(basename "$video_file") - - # Skip files already GIFs - if [[ $base_filename == *.gif ]]; then - notify-send "GIF Conversion Skipped" "File is already a GIF: $(basename "$video_file")" - exit 0 - fi - - # Create GIF filename - gif_file="${video_file%.*}.gif" - - # Check if GIF version doesn't already exist - if [ -f "$gif_file" ]; then - notify-send "GIF Conversion Skipped" "GIF version already exists: $(basename "$gif_file")" - exit 0 - fi - - notify-send "Converting to GIF" "Processing: $(basename "$video_file")" - - # Get video dimensions for scaling - width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 "$video_file") - - # Scale down if wider than 800px to keep file size reasonable - if [ "$width" -gt 800 ]; then - scale_filter="scale=800:-1:flags=lanczos," - else - scale_filter="" - fi - - # Create high-quality GIF with optimized palette - ffmpeg -i "$video_file" \ - -vf "${scale_filter}fps=15,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ - -loop 0 \ - "$gif_file" 2>/tmp/ffmpeg_error.log - - if [ $? -eq 0 ]; then - file_size=$(du -h "$gif_file" | cut -f1) - notify-send "GIF Conversion Success" "$(basename "$video_file") → $(basename "$gif_file") ($file_size)" - send_recording_notification "$gif_file" - else - error=$(cat /tmp/ffmpeg_error.log | tail -n 5) - notify-send "GIF Conversion Failed" "Error converting $(basename "$video_file"): $error" - fi - ;; - - *) - echo "Error: Invalid convert format '$TARGET'" - usage - ;; - esac - ;; - -*) - echo "Error: Invalid command '$COMMAND'" - usage - ;; -esac diff --git a/services/__init__.py b/services/__init__.py deleted file mode 100644 index 3f03f120..00000000 --- a/services/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Modus services package. -Contains background services and utilities for the shell. -""" diff --git a/services/auth.py b/services/auth.py deleted file mode 100644 index 3a7b0708..00000000 --- a/services/auth.py +++ /dev/null @@ -1,348 +0,0 @@ -import json -import os -import subprocess -import time -from datetime import datetime -from pathlib import Path -from urllib.parse import parse_qs, urlparse - -import pyotp -from PIL import Image -from pyzbar.pyzbar import decode - -import config.data as data - - -def get_otp_file_path(): - """Returns the path to the OTP file inside the cache directory.""" - cache_dir = Path(data.CACHE_DIR) / "otp" - # Create the directory if it doesn't exist - cache_dir.mkdir(parents=True, exist_ok=True) - - file_path = cache_dir / "otp_codes.json" - - # Create the file if it doesn't exist - if not file_path.exists(): - with open(file_path, "w") as f: - json.dump([], f) - print(f"File {file_path} created successfully!") - - return file_path - - -def capture_selected_area(filename="/tmp/screenshot.png"): - """ - Uses slurp to let the user select an area of the screen, - then captures that area using grim. - """ - try: - # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400) - result = subprocess.run(["slurp"], check=True, capture_output=True, text=True) - geometry = result.stdout.strip() - if not geometry: - print("No area selected with slurp.") - return None - except subprocess.CalledProcessError as e: - print("Error selecting area with slurp:", e) - return None - - try: - # grim uses -g to capture a specific region - subprocess.run(["grim", "-g", geometry, filename], check=True) - except subprocess.CalledProcessError as e: - print("Error capturing screenshot with grim:", e) - return None - - return filename - - -def read_and_save_to_json(): - # Get the full path to the JSON file - json_file = get_otp_file_path() - - screenshot = capture_selected_area() - if screenshot is None: - print("Failed to capture the selected area.") - return False - - # Short delay to ensure the file is written - time.sleep(1) - - # Open the captured image - try: - img = Image.open(screenshot) - except Exception as e: - print("Error opening the image:", e) - return False - - # Decode QR Code(s) from the image - decoded_objects = decode(img) - if not decoded_objects: - print("No QR Code detected in the selected area.") - return False - - results = [] - - for obj in decoded_objects: - data = obj.data.decode("utf-8") - print("QR Code detected:", data) - - result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data} - - if data.startswith("otpauth://"): - parsed = urlparse(data) - query = parse_qs(parsed.query) - - # Extract the secret properly - secret = query.get("secret", [None])[0] - issuer_from_query = query.get("issuer", [None])[0] - - # Extract the label (path), which may contain issuer and account - label = parsed.path.lstrip("/") if parsed.path else "" - account_name = label - issuer_from_path = None - - if ":" in label: - parts = label.split(":", 1) - issuer_from_path = parts[0] - account_name = parts[1] - - # Prefer issuer from path if available, otherwise use from query - issuer = issuer_from_path or issuer_from_query - - # Extract the period (default is 30 seconds) - period = int(query.get("period", ["30"])[0]) - - # Create TOTP object with the correct interval - totp = pyotp.TOTP(secret, interval=period) - current_otp = totp.now() - print(f"Generated OTP: {current_otp} (valid for {period} seconds)") - - result_entry.update( - { - "type": "otp", - "secret": secret, - "issuer": issuer, - "account_name": account_name, - } - ) - else: - result_entry["type"] = "unknown" - print("Unrecognized format. Expected a URI like otpauth://") - return False - - results.append(result_entry) - - # Load existing data if the file exists - existing_data = [] - if os.path.exists(json_file): - try: - with open(json_file, "r") as f: - existing_data = json.load(f) - except json.JSONDecodeError: - print(f"Error reading the file {json_file}. Creating a new one.") - - # Append new results - existing_data.extend(results) - - # Save to JSON file - with open(json_file, "w") as f: - json.dump(existing_data, f, indent=4) - - print(f"OTP data saved to {json_file}") - return True - - -def CodeOTP(uri): - parsed = urlparse(uri) - query = parse_qs(parsed.query) - secret = query.get("secret", [None])[0] - - if secret is None: - return None - else: - totp = pyotp.TOTP(secret) - return totp.now() - - -# TOTP/OTP utility functions -def generate_totp(secret: str) -> str: - """Generate TOTP code from secret.""" - try: - return pyotp.TOTP(secret).now() - except Exception as e: - print(f"Error generating TOTP: {e}") - return None - - -def get_time_remaining() -> int: - """Get seconds remaining until next token refresh.""" - return 30 - (int(time.time()) % 30) - - -def get_time_remaining_with_blink() -> str: - """Get time remaining with blinking effect.""" - time_remaining = get_time_remaining() - current_second = int(time.time()) - should_blink = current_second % 2 == 0 - - if should_blink: - return f"<span alpha='30%'>{time_remaining}s</span>" - else: - return f"{time_remaining}s" - - -def validate_base32_secret(secret: str) -> dict: - """Validate and clean Base32 secret.""" - import base64 - import re - - try: - # Clean up the secret - remove spaces, dashes, and convert to uppercase - clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper() - - # Remove any non-base32 characters - clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret) - - # Add padding if needed (Base32 requires padding to multiple of 8) - while len(clean_secret) % 8 != 0: - clean_secret += "=" - - # Validate Base32 format - try: - base64.b32decode(clean_secret) - except Exception as e: - return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"} - - # Test if the secret can generate a valid TOTP - try: - test_totp = pyotp.TOTP(clean_secret) - test_code = test_totp.now() - if not test_code or len(test_code) != 6: - raise ValueError("Generated invalid TOTP code") - except Exception as e: - return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"} - - return {"success": True, "secret": clean_secret} - except Exception as e: - return {"success": False, "error": f"Unexpected error: {str(e)}"} - - -def parse_otpauth_uri(uri: str, account_name: str = "") -> dict: - """Parse otpauth URI and extract account information.""" - try: - parsed = urlparse(uri) - if parsed.scheme != "otpauth" or parsed.netloc != "totp": - return { - "success": False, - "error": "Only otpauth://totp/ URIs are supported", - } - - if not account_name: - account_path = parsed.path.lstrip("/") - if ":" in account_path: - issuer, extracted_name = account_path.split(":", 1) - account_name = extracted_name - else: - account_name = account_path - - params = parse_qs(parsed.query) - secret = params.get("secret", [""])[0] - issuer = params.get("issuer", [""])[0] - algorithm = params.get("algorithm", ["SHA1"])[0] - digits = int(params.get("digits", ["6"])[0]) - period = int(params.get("period", ["30"])[0]) - - if not secret: - return {"success": False, "error": "No secret found in URI"} - - return { - "success": True, - "account_name": account_name, - "secret": secret, - "issuer": issuer, - "algorithm": algorithm, - "digits": digits, - "period": period, - } - except Exception as e: - return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"} - - -def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict: - """Scan QR code and add OTP account to secrets file.""" - try: - # Capture QR code from screen - screenshot_path = capture_selected_area() - if not screenshot_path: - return {"success": False, "error": "QR scan cancelled or failed"} - - # Decode QR code - try: - img = Image.open(screenshot_path) - decoded_objects = decode(img) - - if not decoded_objects: - return { - "success": False, - "error": "No QR code detected in selected area", - } - - # Process the first QR code found - qr_data = decoded_objects[0].data.decode("utf-8") - print(f"QR Code detected: {qr_data}") - - if qr_data.startswith("otpauth://"): - # Parse otpauth URI - result = parse_otpauth_uri(qr_data, account_name) - if not result["success"]: - return result - - # Load existing secrets - secrets = {} - if os.path.exists(secrets_file_path): - try: - with open(secrets_file_path, "r", encoding="utf-8") as f: - secrets = json.load(f) - except Exception as e: - print(f"Error loading secrets: {e}") - - # Add new account - secrets[result["account_name"]] = { - "secret": result["secret"], - "issuer": result["issuer"], - "algorithm": result["algorithm"], - "digits": result["digits"], - "period": result["period"], - } - - # Save secrets - try: - os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True) - with open(secrets_file_path, "w", encoding="utf-8") as f: - json.dump(secrets, f, indent=2) - except Exception as e: - return { - "success": False, - "error": f"Error saving secrets: {str(e)}", - } - - display_name = ( - f"{result['issuer']} - {result['account_name']}" - if result["issuer"] - else result["account_name"] - ) - return { - "success": True, - "account_name": result["account_name"], - "display_name": display_name, - "message": f"Successfully added OTP account: {display_name}", - } - else: - return {"success": False, "error": "QR code is not an otpauth URI"} - - except Exception as e: - return {"success": False, "error": f"Error processing QR code: {str(e)}"} - - except Exception as e: - return {"success": False, "error": f"Error during QR scan: {str(e)}"} diff --git a/services/battery.py b/services/battery.py deleted file mode 100644 index a28cceb8..00000000 --- a/services/battery.py +++ /dev/null @@ -1,251 +0,0 @@ -import psutil -from gi.repository import GLib -from pydbus import SystemBus - -from fabric.core import Property, Service, Signal - -DeviceState = { - 0: "UNKNOWN", - 1: "CHARGING", - 2: "DISCHARGING", - 3: "EMPTY", - 4: "FULLY_CHARGED", - 5: "PENDING_CHARGE", - 6: "PENDING_DISCHARGE", -} - - -class Battery(Service): - @staticmethod - def seconds_to_hours_minutes(seconds): - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - return f"{hours}h {minutes}m" if hours else f"{minutes}m" - - @staticmethod - def get_battery_icon_level(percentage): - """Get battery icon level based on percentage""" - if percentage >= 90: - return "100" - elif percentage >= 80: - return "090" - elif percentage >= 70: - return "080" - elif percentage >= 60: - return "070" - elif percentage >= 50: - return "060" - elif percentage >= 40: - return "050" - elif percentage >= 30: - return "040" - elif percentage >= 20: - return "030" - elif percentage >= 10: - return "020" - else: - return "010" - - @staticmethod - def get_battery_icon_file(percentage, is_charging, base_path=""): - """Get battery icon file path""" - level = Battery.get_battery_icon_level(percentage) - suffix = "-charging" if is_charging else "" - return f"{base_path}battery/battery-{level}{suffix}.svg" - - @staticmethod - def get_profile_display_name(profile: str) -> str: - """Get user-friendly display name for power profile""" - profile_names = { - "power-saver": "Power Saver", - "powersave": "Power Saver", - "power_saver": "Power Saver", - "balanced": "Balanced", - "balance": "Balanced", - "performance": "Performance", - "performance-mode": "Performance", - } - return profile_names.get(profile, profile.title()) - - @Signal - def changed(self) -> None: ... - - @Signal - def profile_changed(self, value: str) -> None: ... - - @Property(int, "readable") - def percentage(self): - if self._use_psutil_fallback: - if self._psutil_battery: - return int(self._psutil_battery.percent) - return 0 - return int(self._battery.Percentage) - - @Property(str, "readable") - def temperature(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide temperature - return ( - f"{self._battery.Temperature}°C" - if hasattr(self._battery, "Temperature") - else "N/A" - ) - - @Property(str, "readable") - def time_to_empty(self): - if self._use_psutil_fallback: - if self._psutil_battery and hasattr(self._psutil_battery, "secsleft"): - return self.seconds_to_hours_minutes(self._psutil_battery.secsleft) - return "N/A" - return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0)) - - @Property(str, "readable") - def time_to_full(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide time to full - return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0)) - - @Property(str, "readable") - def icon_name(self): - if self._use_psutil_fallback: - return "battery" # Generic icon name for psutil fallback - return self._battery.IconName - - @Property(str, "readable") - def state(self): - if self._use_psutil_fallback: - if self._psutil_battery: - # psutil returns power_plugged boolean, convert to state - if self._psutil_battery.power_plugged: - if self._psutil_battery.percent >= 100: - return "FULLY_CHARGED" - else: - return "CHARGING" - else: - return "DISCHARGING" - return "UNKNOWN" - return DeviceState.get(self._battery.State, "UNKNOWN") - - @Property(str, "readable") - def capacity(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide capacity info - return f"{int(self._battery.Capacity)}%" - - @Property(bool, "readable", default_value=False) - def is_present(self): - if self._use_psutil_fallback: - return self._psutil_battery is not None - return self._battery.IsPresent - - @Property(str, "readable") - def power_profile(self): - if hasattr(self, "_profile_proxy") and self._profile_proxy: - try: - return self._profile_proxy.ActiveProfile - except Exception: - return None - return None - - @Property(list, "readable") - def available_profiles(self): - if hasattr(self, "_profile_proxy") and self._profile_proxy: - try: - profiles = [] - for p in self._profile_proxy.Profiles: - if hasattr(p, "Profile"): - profiles.append(p.Profile) - elif isinstance(p, dict) and "Profile" in p: - profiles.append(p["Profile"]) - elif isinstance(p, str): - profiles.append(p) - return profiles - except Exception: - return [] - return [] - - def change_power_profile(self, profile: str) -> bool: - if not hasattr(self, "_profile_proxy") or not self._profile_proxy: - return False - - # Get available profiles using the same logic as available_profiles property - available_profiles = [] - try: - for p in self._profile_proxy.Profiles: - if hasattr(p, "Profile"): - available_profiles.append(p.Profile) - elif isinstance(p, dict) and "Profile" in p: - available_profiles.append(p["Profile"]) - elif isinstance(p, str): - available_profiles.append(p) - except Exception: - return False - - if profile not in available_profiles: - return False - - try: - self._profile_proxy.ActiveProfile = profile - self.profile_changed.emit(profile) - self.changed.emit() - return True - except Exception: - return False - - def __init__(self): - super().__init__() - self._bus = SystemBus() - self._use_psutil_fallback = False - self._psutil_battery = None - self._profile_proxy = None # Initialize to None first - - # Battery device - try: - self._battery = self._bus.get( - "org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0" - ) - self._battery.onPropertiesChanged = self.handle_battery_change - except Exception: - # Fallback to psutil if UPower is not available - self._use_psutil_fallback = True - try: - self._psutil_battery = psutil.sensors_battery() - if self._psutil_battery is None: - return # No battery found - # Start periodic updates for psutil fallback - increased interval - GLib.timeout_add_seconds(10, self._update_psutil_battery) - except Exception: - return # psutil battery not available either - - # PowerProfiles - Initialize after other attributes - try: - self._profile_proxy = self._bus.get( - "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles" - ) - # Use onPropertiesChanged for consistency with battery device - self._profile_proxy.onPropertiesChanged = ( - lambda _, changed, __: self._handle_profile_props_changed(changed) - ) - except Exception: - self._profile_proxy = None - - self.changed.emit() - - def _update_psutil_battery(self): - """Update psutil battery data periodically""" - try: - self._psutil_battery = psutil.sensors_battery() - self.changed.emit() - except Exception: - pass # Continue trying - return True # Keep the timeout active - - def _handle_profile_props_changed(self, changed): - """Internal handler for property changes that processes only the changed properties""" - if "ActiveProfile" in changed: - new_profile = changed["ActiveProfile"] - self.profile_changed.emit(new_profile) - self.changed.emit() - - def handle_battery_change(self, iface, changed, invalidated): - self.changed.emit() diff --git a/services/brightness.py b/services/brightness.py deleted file mode 100644 index 10314441..00000000 --- a/services/brightness.py +++ /dev/null @@ -1,96 +0,0 @@ -import os - -from gi.repository import GLib -from loguru import logger - -from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command_async, monitor_file - - -def exec_brightnessctl_async(args: str): - exec_shell_command_async(f"brightnessctl {args}", lambda _: None) - - -# Discover screen backlight device -try: - screen_device = os.listdir("/sys/class/backlight") - screen_device = screen_device[0] if screen_device else "" -except FileNotFoundError: - logger.error("No backlight devices found, brightness control disabled") - screen_device = "" - - -class Brightness(Service): - """Service to manage screen brightness levels.""" - - instance = None - - @staticmethod - def get_initial(): - if Brightness.instance is None: - Brightness.instance = Brightness() - - return Brightness.instance - - @Signal - def screen(self, value: int) -> None: - """Signal emitted when screen brightness changes.""" - # Implement as needed for your application - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # Path for screen backlight control - self.screen_backlight_path = f"/sys/class/backlight/{screen_device}" - - # Initialize maximum brightness level - self.max_screen = self.do_read_max_brightness(self.screen_backlight_path) - - if screen_device == "": - return - - # Monitor screen brightness file - self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness") - - self.screen_monitor.connect( - "changed", - lambda _, file, *args: self.emit( - "screen", - round(int(file.load_bytes()[0].get_data())), - ), - ) - - # Log the initialization of the service - logger.info(f"Brightness service initialized for device: {screen_device}") - - def do_read_max_brightness(self, path: str) -> int: - # Reads the maximum brightness value from the specified path. - max_brightness_path = os.path.join(path, "max_brightness") - if os.path.exists(max_brightness_path): - with open(max_brightness_path) as f: - return int(f.readline()) - return -1 # Return -1 if file doesn't exist, indicating an error. - - @Property(int, "read-write") - def screen_brightness(self) -> int: - # Property to get or set the screen brightness. - brightness_path = os.path.join(self.screen_backlight_path, "brightness") - if os.path.exists(brightness_path): - with open(brightness_path) as f: - return int(f.readline()) - logger.warning(f"Brightness file does not exist: {brightness_path}") - return -1 # Return -1 if file doesn't exist, indicating error. - - @screen_brightness.setter - def screen_brightness(self, value: int): - # Setter for screen brightness property. - if not (0 <= value <= self.max_screen): - value = max(0, min(value, self.max_screen)) - - try: - exec_brightnessctl_async(f"--device '{screen_device}' set {value}") - self.emit("screen", int((value / self.max_screen) * 100)) - except GLib.Error as e: - logger.error(f"Error setting screen brightness: {e.message}") - except Exception as e: - logger.exception(f"Unexpected error setting screen brightness: {e}") diff --git a/services/modus.py b/services/modus.py deleted file mode 100644 index f9e94443..00000000 --- a/services/modus.py +++ /dev/null @@ -1,359 +0,0 @@ -import json - -from fabric.core.service import Property, Service, Signal -from fabric.hyprland.service import Hyprland -from loguru import logger - -from services.custom_notification import CachedNotifications - -notification_service = CachedNotifications() - - -class ModusService(Service): - @Signal - def bluetooth_changed(self, new_bluetooth: str) -> None: ... - - @Signal - def volume_changed(self, new_volume: int) -> None: ... - - @Signal - def wlan_changed(self, new_wlan: str) -> None: ... - - @Signal - def battery_changed(self, new_battery: str) -> None: ... - - @Signal - def dock_apps_changed(self, new_dock_apps: str) -> None: ... - - @Signal - def dont_disturb_changed(self, value: bool) -> None: ... - - @Signal - def current_active_app_name_changed(self, value: str) -> None: ... - - @Signal - def current_workspace_changed(self, value: str) -> None: ... - - @Signal - def music_changed(self, value: str) -> None: ... - - @Signal - def current_dropdown_changed(self, value: str) -> None: ... - - @Signal - def dropdowns_hide_changed(self, value: bool) -> None: ... - - @Signal - def dock_width_changed(self, value: int) -> None: ... - - @Signal - def dock_height_changed(self, value: int) -> None: ... - - @Signal - def dock_hidden_changed(self, value: bool) -> None: ... - - @Signal - def show_notificationcenter_changed(self, value: bool) -> None: ... - - @Signal - def notification_count_changed(self, value: int) -> None: ... - - @Property(str, flags="read-write") - def current_active_app_name(self) -> str: - return self._current_active_app_name - - @Property(str, flags="read-write") - def current_workspace(self) -> str: - return self._current_workspace - - @Property(str, flags="read-write") - def bluetooth(self) -> str: - return self._bluetooth - - @Property(str, flags="read-write") - def wlan(self) -> str: - return self._wlan - - @Property(str, flags="read-write") - def battery(self) -> str: - return self._battery - - @Property(int, flags="read-write") - def volume(self) -> int: - return self._volume - - @Property(str, flags="read-write") - def dock_apps(self) -> str: - return self._dock_apps - - @Property(bool, flags="read-write", default_value=False) - def dont_disturb(self) -> bool: - return self._dont_disturb - - @Property(str, flags="read-write") - def music(self) -> str: - return self._music - - @Property(str, flags="read-write") - def current_dropdown(self) -> str: - return self._current_dropdown - - @Property(bool, flags="read-write", default_value=False) - def dropdowns_hide(self) -> bool: - return self._dropdowns_hide - - @Property(int, flags="read-write") - def dock_width(self) -> int: - return self._dock_width - - @Property(int, flags="read-write") - def dock_height(self) -> int: - return self._dock_height - - @Property(bool, flags="read-write", default_value=False) - def dock_hidden(self) -> bool: - return self._dock_hidden - - @Property(bool, flags="read-write", default_value=False) - def show_notificationcenter(self) -> bool: - return self._show_notificationcenter - - @current_active_app_name.setter - def current_active_app_name(self, value: str): - if value != self._current_active_app_name: - self._current_active_app_name = value - self.current_active_app_name_changed(value) - - @current_workspace.setter - def current_workspace(self, value: str): - if value != self._current_workspace: - self._current_workspace = value - self.current_workspace_changed(value) - - @volume.setter - def volume(self, value: int): - if value != self._volume: - self._name = value - self.volume_changed(value) - - @wlan.setter - def wlan(self, value: str): - if value != self._wlan: - self._wlan = value - self.wlan_changed(value) - - @battery.setter - def battery(self, value: str): - if value != self._battery: - self._battery = value - self.battery_changed(value) - - @bluetooth.setter - def bluetooth(self, value: str): - if value != self._bluetooth: - self._bluetooth = value - self.bluetooth_changed(value) - - @dock_apps.setter - def dock_apps(self, value: str): - if value != self._dock_apps: - self._dock_apps = value - self.dock_apps_changed(value) - - @dont_disturb.setter - def dont_disturb(self, value: bool): - if value != self._dont_disturb: - self._dont_disturb = value - self.dont_disturb_changed(value) - - @music.setter - def music(self, value: str): - if value != self._music: - self._music = value - self.music_changed(value) - - @current_dropdown.setter - def current_dropdown(self, value: str): - if value != self._current_dropdown: - self._current_dropdown = value - self.current_dropdown_changed(value) - - @dropdowns_hide.setter - def dropdowns_hide(self, value: bool): - if value != self._dropdowns_hide: - self._dropdowns_hide = value - self.dropdowns_hide_changed(value) - - @dock_width.setter - def dock_width(self, value: int): - if value != self._dock_width: - self._dock_width = value - self.dock_width_changed(value) - - @dock_height.setter - def dock_height(self, value: int): - if value != self._dock_height: - self._dock_height = value - self.dock_height_changed(value) - - @dock_hidden.setter - def dock_hidden(self, value: bool): - if value != self._dock_hidden: - self._dock_hidden = value - self.dock_hidden_changed(value) - - @show_notificationcenter.setter - def show_notificationcenter(self, value: bool): - if value != self._show_notificationcenter: - self._show_notificationcenter = value - self.show_notificationcenter_changed(value) - - def sc(self, signal_name: str, callback: callable, def_value="..."): - self.connect(signal_name, callback) - # Return current property value instead of default - if signal_name == "bluetooth-changed": - return self.bluetooth if self.bluetooth else "Off" - elif signal_name == "wlan-changed": - return self.wlan if self.wlan else "No Connection" - elif signal_name == "battery-changed": - return self.battery if self.battery else "Unknown" - elif signal_name == "music-changed": - return self.music if self.music else "" - else: - return def_value - - def __init__(self): - super().__init__() - self._volume = 0 - self._wlan = "" - self._battery = "" - self._bluetooth = "" - self._dock_apps = "" - self._dont_disturb = False - self._current_active_app_name = "Finder" # Changed from "Hyprland" to "Finder" - self._current_workspace = "1" - self._music = "" - self._current_dropdown = None - self._dropdowns_hide = False - self._dock_hidden = False - self._show_notificationcenter = False - - self._dock_width = 0 - self._dock_height = 0 - - # Initialize Hyprland connection for workspace and window monitoring - self._setup_workspace_monitoring() - self._setup_active_window_monitoring() - - def _setup_workspace_monitoring(self): - """Setup Hyprland connection and workspace monitoring""" - try: - self._hyprland_connection = Hyprland() - - # Get initial workspace - workspace_data = self._hyprland_connection.send_command( - "j/activeworkspace" - ).reply - active_workspace = json.loads(workspace_data.decode("utf-8"))["name"] - self._current_workspace = str(active_workspace) - - # Connect to workspace change events - self._hyprland_connection.connect( - "event::workspace", self._on_workspace_changed - ) - - except Exception as e: - logger.error(f"[ModusService] Failed to setup workspace monitoring: {e}") - self._current_workspace = "1" - - def _setup_active_window_monitoring(self): - """Setup active window monitoring""" - try: - if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection: - return - - # Get initial active window - self._update_active_window() - - # Note: The HyprlandActiveWindow widget from Fabric library - # should handle active window updates automatically. - # We just need to ensure the initial state is correct. - - except Exception as e: - logger.error(f"[ModusService] Failed to setup active window monitoring: {e}") - - def _update_active_window(self): - """Update the current active app name based on active window""" - try: - if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection: - return - - window_data = self._hyprland_connection.send_command("j/activewindow").reply - if not window_data: - self.current_active_app_name = "Finder" - return - - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Handle the case when there's no active window - if not title and not wmclass: - self.current_active_app_name = "Finder" - return - - # Simple app name formatting without circular import - name = wmclass if wmclass else title - if name: - # Basic formatting: capitalize first letter and remove file extensions - name = str(name).title() - if "." in name: - name = name.split(".")[-1] - else: - name = "Finder" - - self.current_active_app_name = name - - except Exception as e: - logger.error(f"[ModusService] Error updating active window: {e}") - self.current_active_app_name = "Finder" - - def _on_workspace_changed(self, obj, signal): - """Handle workspace change events from Hyprland""" - try: - workspace_name = json.loads(signal.data[0]) - self.current_workspace = str(workspace_name) - except Exception as e: - logger.error(f"[ModusService] Error processing workspace change: {e}") - - def remove_notification(self, id: int): - notification_service.remove_cached_notification(id) - self.notification_count_changed(self.notification_count) - - def clear_all_notifications(self): - notification_service.clear_all_cached_notifications() - self.notification_count_changed(self.notification_count) - - def get_cached_notifications(self): - return notification_service.cached_notifications - - def get_deserialized_with_ids(self): - return [ - (notif._notification, notif.cache_id) - for notif in notification_service.cached_notifications - ] - - @property - def notification_count(self) -> int: - return notification_service.count - - def toggle_dnd(self): - notification_service.toggle_dnd() - self.dont_disturb_changed(notification_service.dont_disturb) - - -global modus_service -try: - modus_service = ModusService() -except Exception as e: - logger.error("[Main] Failed to create ModusShellService:", e) diff --git a/services/mpris.py b/services/mpris.py deleted file mode 100644 index 539a7274..00000000 --- a/services/mpris.py +++ /dev/null @@ -1,303 +0,0 @@ -# Standard library imports -import contextlib - -import gi - -# Fabric imports -from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect -from gi.repository import GLib -from loguru import logger - - -class PlayerctlImportError(ImportError): - def __init__(self, *args): - super().__init__( - "Playerctl is not installed, please install it first", - *args, - ) - - -try: - gi.require_version("Playerctl", "2.0") - from gi.repository import Playerctl -except ValueError: - raise PlayerctlImportError - - -class MprisPlayer(Service): - """A service to manage a mpris player.""" - - @Signal - def exit(self, value: bool) -> bool: ... - - @Signal - def changed(self) -> None: ... - - def __init__( - self, - player: Playerctl.Player, - **kwargs, - ): - self._signal_connectors: dict = {} - self._player: Playerctl.Player = player - super().__init__(**kwargs) - for sn in ["playback-status", "loop-status", "shuffle"]: - self._signal_connectors[sn] = self._player.connect( - sn, - lambda *args, sn=sn: self.notifier(sn, args), - ) - - self._signal_connectors["exit"] = self._player.connect( - "exit", - self.on_player_exit, - ) - self._signal_connectors["metadata"] = self._player.connect( - "metadata", - lambda *_: self.update_status(), - ) - GLib.idle_add(self.update_status_once) - - def update_status(self): - # schedule each notifier asynchronously. - def notify_property(prop): - if self.get_property(prop) is not None: - self.notifier(prop) - - for prop in [ - "metadata", - "title", - "artist", - "arturl", - "length", - ]: - GLib.idle_add(lambda p=prop: (notify_property(p), False)) - for prop in [ - "can-seek", - "can-pause", - "can-shuffle", - "can-go-next", - "can-go-previous", - ]: - GLib.idle_add(lambda p=prop: (self.notifier(p), False)) - - def update_status_once(self): - # schedule notifier calls for each property - def notify_all(): - for prop in self.list_properties(): # type: ignore - self.notifier(prop.name) - return False - - GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE) - - def notifier(self, name: str, args=None): - def notify_and_emit(): - self.notify(name) - self.emit("changed") - return False - - GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE) - - def on_player_exit(self, player): - for id in list(self._signal_connectors.values()): - with contextlib.suppress(Exception): - self._player.disconnect(id) - del self._signal_connectors - GLib.idle_add(lambda: (self.emit("exit", True), False)) - del self._player - - def toggle_shuffle(self, *_): - if self.can_shuffle: - # schedule the shuffle toggle in the GLib idle loop - GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False)) - # else do nothing - - def play_pause(self, *_): - if self.can_pause: - GLib.idle_add(lambda: (self._player.play_pause(), False)) - - def next(self, *_): - if self.can_go_next: - GLib.idle_add(lambda: (self._player.next(), False)) - - def previous(self, *_): - if self.can_go_previous: - GLib.idle_add(lambda: (self._player.previous(), False)) - - # Properties - @Property(str, "readable") - def player_name(self) -> int: - return self._player.get_property("player-name") # type: ignore - - @Property(int, "read-write", default_value=0) - def position(self) -> int: - return self._player.get_property("position") # type: ignore - - @position.setter - def position(self, new_pos: int): - self._player.set_position(new_pos) - - @Property(object, "readable") - def metadata(self) -> dict: - return self._player.get_property("metadata") # type: ignore - - @Property(str or None, "readable") - def arturl(self) -> str | None: - if "mpris:artUrl" in self.metadata.keys(): # type: ignore # noqa: SIM118 - return self.metadata["mpris:artUrl"] # type: ignore - return None - - @Property(str or None, "readable") - def length(self) -> str | None: - if "mpris:length" in self.metadata.keys(): # type: ignore # noqa: SIM118 - return self.metadata["mpris:length"] # type: ignore - return None - - @Property(str, "readable") - def artist(self) -> str: - artist = self._player.get_artist() # type: ignore - if isinstance(artist, (list, tuple)): - return ", ".join(artist) - return artist - - @Property(str, "readable") - def album(self) -> str: - return self._player.get_album() # type: ignore - - @Property(str, "readable") - def title(self): - return self._player.get_title() - - @Property(bool, "read-write", default_value=False) - def shuffle(self) -> bool: - return self._player.get_property("shuffle") # type: ignore - - @shuffle.setter - def shuffle(self, do_shuffle: bool): - self.notifier("shuffle") - return self._player.set_shuffle(do_shuffle) - - @Property(str, "readable") - def playback_status(self) -> str: - return { - Playerctl.PlaybackStatus.PAUSED: "paused", - Playerctl.PlaybackStatus.PLAYING: "playing", - Playerctl.PlaybackStatus.STOPPED: "stopped", - # type: ignore - }.get(self._player.get_property("playback_status"), "unknown") - - @Property(str, "read-write") - def loop_status(self) -> str: - return { - Playerctl.LoopStatus.NONE: "none", - Playerctl.LoopStatus.TRACK: "track", - Playerctl.LoopStatus.PLAYLIST: "playlist", - }.get( - self._player.get_property("loop_status"), "unknown" - ) # type: ignore - - @loop_status.setter - def loop_status(self, status: str): - loop_status = { - "none": Playerctl.LoopStatus.NONE, - "track": Playerctl.LoopStatus.TRACK, - "playlist": Playerctl.LoopStatus.PLAYLIST, - }.get(status) - self._player.set_loop_status(loop_status) if loop_status else None - - @Property(bool, "readable", default_value=False) - def can_go_next(self) -> bool: - return self._player.get_property("can_go_next") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_go_previous(self) -> bool: - return self._player.get_property("can_go_previous") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_seek(self) -> bool: - return self._player.get_property("can_seek") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_pause(self) -> bool: - return self._player.get_property("can_pause") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_shuffle(self) -> bool: - try: - self._player.set_shuffle(self._player.get_property("shuffle")) - return True - except Exception: - return False - - @Property(bool, "readable", default_value=False) - def can_loop(self) -> bool: - try: - self._player.set_shuffle(self._player.get_property("shuffle")) - return True - except Exception: - return False - - -class MprisPlayerManager(Service): - """A service to manage mpris players.""" - - @Signal - def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ... - - @Signal - def player_vanished(self, player_name: str) -> str: ... - - def __init__( - self, - **kwargs, - ): - self._manager = Playerctl.PlayerManager.new() - self._signal_connections = [] - - # Track signal connections for cleanup - connections = bulk_connect( - self._manager, - { - "name-appeared": self.on_name_appeared, - "name-vanished": self.on_name_vanished, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self._manager, handler_id)) - - self.add_players() - super().__init__(**kwargs) - - def destroy(self): - """Clean up resources when the manager is destroyed.""" - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect manager signal: {e}") - self._signal_connections.clear() - - # Clean up the manager - if hasattr(self, '_manager'): - del self._manager - - def on_name_appeared(self, manager, player_name: Playerctl.PlayerName): - logger.info(f"[MprisPlayer] {player_name.name} appeared") - new_player = Playerctl.Player.new_from_name(player_name) - manager.manage_player(new_player) - self.emit("player-appeared", new_player) # type: ignore - - def on_name_vanished(self, manager, player_name: Playerctl.PlayerName): - logger.info(f"[MprisPlayer] {player_name.name} vanished") - self.emit("player-vanished", player_name.name) # type: ignore - - def add_players(self): - # type: ignore - for player in self._manager.get_property("player-names"): - self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore - - @Property(object, "readable") - def players(self): - return self._manager.get_property("players") # type: ignore diff --git a/services/network.py b/services/network.py deleted file mode 100644 index 619e81d5..00000000 --- a/services/network.py +++ /dev/null @@ -1,667 +0,0 @@ -from gi.repository import NM, GLib -import gi -from typing import List, Optional -from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case -from loguru import logger - -gi.require_version("NM", "1.0") # Ensure the correct version is loaded - - -class NetworkClient(Service): - """A service to manage network devices""" - - @Signal - def wifi_device_added(self) -> None: ... - - @Signal - def ethernet_device_added(self) -> None: ... - - @Signal - def wifi_device_removed(self) -> None: ... - - @Signal - def ethernet_device_removed(self) -> None: ... - - @Signal - def changed(self) -> None: ... - - @Property(list, "readable") - def connections(self) -> Optional[list]: - """Returns the active connections, if available.""" - return self._client.get_property("connections") if self._client else None - - @Property(object, "readable") - def wifi_device(self) -> Optional[object]: - """Returns the WiFi device if available.""" - return self._wifi_device - - @Property(object, "readable") - def ethernet_device(self) -> Optional[object]: - """Returns the Ethernet device if available.""" - return self._ethernet_device - - @Property(str, "readable") - def primary_connection(self) -> Optional[str]: - """Returns the primary connection if available.""" - return self._client.get_property("primary_connection") if self._client else None - - @Property(str, "readable") - def active_connection(self) -> Optional[str]: - """Returns the active connection if available.""" - return self._client.get_property("active_connection") if self._client else None - - @Property(str, "readable") - def state(self) -> str: - """Returns the current network state.""" - if not self._client: - return "disconnected" - return snake_case_to_kebab_case( - get_enum_member_name( - self._client.get_property("state"), default="disconnected" - ) - ) - - @Property(str, "readable") - def connectivity(self) -> str: - """Returns the connectivity state.""" - if not self._client: - return "disconnected" - return snake_case_to_kebab_case( - get_enum_member_name( - self._client.get_property("connectivity"), default="disconnected" - ) - ) - - @Property(list, "readable") - def devices(self) -> Optional[list]: - """Returns the list of network devices if available.""" - return self._client.get_property("devices") if self._client else None - - @Property(str, "readable") - def hostname(self) -> Optional[str]: - """Returns the hostname if available.""" - return self._client.get_property("hostname") if self._client else None - - @Property(bool, "read-write", default_value=False) - def networking_enabled(self) -> bool: - """Checks if networking is enabled.""" - return ( - self._client.get_property("networking_enabled") if self._client else False - ) - - @networking_enabled.setter - def networking_enabled(self, value: bool): - """Sets the networking state if the client is available.""" - if self._client: - self._client.set_property("networking_enabled", value) - - @Property(bool, "read-write", default_value=False) - def wireless_enabled(self) -> bool: - """Checks if wireless networking is enabled.""" - return self._client.get_property("wireless_enabled") if self._client else False - - @wireless_enabled.setter - def wireless_enabled(self, value: bool): - """Sets the wireless networking state if the client is available.""" - if self._client: - self._client.set_property("wireless_enabled", value) - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self._client: NM.Client = None - self._wifi_device: Wifi | None = None - self._ethernet_device: Ethernet | None = None - - logger.info("[Network] Initializing client asynchronously...") - - # Start async NM client initialization - NM.Client.new_async(None, self.on_client_ready) - - def on_client_ready(self, source, result): - """Callback when NM.Client is ready.""" - try: - self._client = NM.Client.new_finish(result) # Retrieve client instance - logger.info("[Network] NM.Client initialized successfully!") - - # Connect signals - bulk_connect( - self._client, - { - "device-added": lambda _, device: self.on_device_added( - device=device - ), - "device-removed": lambda _, device: self.on_device_removed( - device=device - ), - "notify::state": lambda *args: self.notifier("state"), - "notify::networking-enabled": lambda *args: self.notifier( - "networking-enabled" - ), - "notify::wireless-enabled": lambda *args: self.notifier( - "wireless-enabled" - ), - # "notify::primary-connection": lambda *args: self.notifier('primary-connection'), - # "notify::active-connection": lambda *args: self.notifier('active-connection'), - # "active-connection-added": lambda *args: self.emit("changed"), - # "active-connection-removed": lambda *args: self.emit("changed") - }, - ) - - # Process devices AFTER client is ready - for device in self.do_get_raw_devices(): - self.on_device_added(device=device) - - self.notify("state") - self.notify("networking-enabled") - self.notify("wireless-enabled") - - except Exception as e: - logger.error(f"[Network] Error initializing NM.Client: {e}") - - def do_get_raw_devices(self) -> list[NM.Device]: - return [ - dev - for dev in self.devices - if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET) - ] - - def on_device_added(self, device): - device_type = device.get_device_type() - if device_type == NM.DeviceType.WIFI and not self._wifi_device: - logger.info("[Network] WiFi device detected, initializing...") - self._wifi_device = Wifi(client=self, device=device) - self.wifi_device_added.emit() - - elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device: - logger.info("[Network] Ethernet device detected, initializing...") - self._ethernet_device = Ethernet(client=self, device=device) - self.ethernet_device_added.emit() - - def on_device_removed(self, device): - if device == self._wifi_device: - logger.info("[Network] WiFi device removed.") - self._wifi_device = None - self.wifi_device_removed.emit() - - elif device == self._ethernet_device: - logger.info("[Network] Ethernet device removed.") - self._ethernet_device = None - self.ethernet_device_removed.emit() - - def toggle_network(self): - """Enable or disable Network""" - self.networking_enabled = not self.networking_enabled - - def deactivate_connection(self, connection): - """Disconnect""" - self._client.deactivate_connection_async(connection, None, None) - - def notifier(self, name): - self.notify(name) - self.emit("changed") - - -class AccessPoint(Service): - """A service to manage access points""" - - @Signal - def changed(self) -> None: ... - - @Property(object, "readable") - def device(self) -> object: - return self._device - - @Property(int, "readable") - def strength(self) -> int: - return self._ap.get_property("strength") - - @Property(int, "readable") - def frequency(self) -> int: - return self._ap.get_property("frequency") - - @Property(str, "readable") - def bssid(self) -> str: - return self._ap.get_property("bssid") if self._ap else None - - @Property(str, "readable") - def hw_address(self) -> str: - return self._ap.get_property("hw_address") - - @Property(str, "readable") - def ssid(self) -> str: - ssid = self._ap.get_ssid() - return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - @Property(str, "readable") - def icon(self) -> str: - return { - 80: "network-wireless-signal-excellent-symbolic", - 60: "network-wireless-signal-good-symbolic", - 40: "network-wireless-signal-ok-symbolic", - 20: "network-wireless-signal-weak-symbolic", - 00: "network-wireless-signal-none-symbolic", - }.get( - min(80, 20 * round(self.strength / 20)), - "network-wireless-no-route-symbolic", - ) - - @Property(bool, "readable", default_value=False) - def requires_password(self) -> bool: - ssid = self.ssid - settings = self._client.connections - connection = None - for setting in settings: - wifi_setting = setting.get_setting_wireless() - if ( - wifi_setting - and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid - ): - connection = setting - break - if not connection: - return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags()) - return False - - @Property(bool, "readable", default_value=False) - def is_active(self) -> bool: - if self._device.active_access_point: - return self.bssid == self._device.active_access_point.get_bssid() - return False - - def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs): - super().__init__(**kwargs) - self._client: NetworkClient = device.client - self._device: Wifi = device - self._ap: NM.AccessPoint = ap - - self._ap.connect("notify::strength", lambda *args: self.notifier("strength")) - self._device.connect( - "notify::active-access-point", lambda *args: self.notifier("is-active") - ) - - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - return - - -class Wifi(Service): - """A service to manage wifi devices""" - - @Signal - def changed(self) -> None: ... - - @Signal - def ap_added(self, ap: AccessPoint) -> None: ... - - @Signal - def ap_removed(self, ap: AccessPoint) -> None: ... - - @Property(NetworkClient, "readable") - def client(self) -> NetworkClient: - """Returns the client""" - return self._client - - @Property(bool, "read-write", default_value=False) - def wireless_enabled(self) -> bool: - """Returns if the wifi is enabled""" - return self._client.get_property("wireless_enabled") - - @wireless_enabled.setter - def wireless_enabled(self, value: bool): - return self._client.set_property("wireless_enabled", value) - - @Property(list[AccessPoint], "readable") - def access_points(self) -> list[AccessPoint]: - return sorted( - self._access_points.values(), key=lambda x: x.is_active, reverse=True - ) - - @Property(AccessPoint, "readable") - def active_access_point(self) -> Optional[AccessPoint]: - return self._active_access_point - - def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs): - super().__init__(**kwargs) - self._client: NetworkClient = client - self._device: NM.DeviceWifi = device - self._active_access_point: NM.AccessPoint | None = None - self._access_points: dict[str, AccessPoint] = {} - - bulk_connect( - self._device, - { - "notify::active-access-point": lambda *args: self.on_access_point_activated(), - "access-point-added": lambda _, ap: self.on_access_point_added(ap=ap), - "access-point-removed": lambda _, ap: self.on_access_point_removed( - ap=ap - ), - # "state-changed": lambda device, new, old, reason: self.on_state_changed(new), - }, - ) - - self._client.connect( - "notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled") - ) - - for ap in self.do_get_access_points(): - self.on_access_point_added(ap=ap) - - self.on_access_point_activated() - - def on_state_changed(self, state): - self.emit("changed") - - def do_get_access_points(self): - return self._device.get_access_points() - - def on_access_point_added(self, ap): - ssid = ap.get_ssid() - ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - access_point: AccessPoint = AccessPoint(ap=ap, device=self) - - self._access_points[ssid] = access_point - - self.ap_added.emit(access_point) - - # self.notifier("access-points") - - logger.info(f"[Wifi] New access point added with ssid: {ssid}") - - def on_access_point_removed(self, ap): - ssid = ap.get_ssid() - ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - if not (access_point := self._access_points.pop(ssid, None)): - return logger.warning( - f"[Network] tried to remove a unknwon access point with ssid {ssid}" - ) - - self.ap_removed.emit(access_point) - - logger.info(f"[Wifi] Access point with ssid: {ssid} removed.") - - def on_access_point_activated(self): - if self._device.get_active_access_point(): - self._active_access_point: AccessPoint = AccessPoint( - ap=self._device.get_active_access_point(), device=self - ) - - else: - self._active_access_point = None - - self.notifier("active-access-point") - - logger.info("[Wifi] New active connection") - - def disconnect_wifi(self): - """Disconnect from the current WiFi network.""" - active_connection = self._device.get_active_connection() - self._client.deactivate_connection(active_connection) - logger.info("[Wifi] Wifi network disconnected") - - def scan(self): - self._device.request_scan_async( - None, - lambda device, result: [ - device.request_scan_finish(result), - self.notifier("access-points"), - ], - ) - logger.info("[Wifi] Scan started") - - def toggle_wifi(self): - """Enable or disable WiFi""" - self.wireless_enabled = not self.wireless_enabled - - def connect_to_wifi(self, ap: AccessPoint, password: str = None, callback=None): - """Connect to a WiFi network.""" - - ssid = ap.ssid - - if ssid == "Unknown": - logger.error("Invalid access point data") - if callback: - callback(False, "Invalid access point data") - return False - - logger.info(f"Connecting to WiFi SSID: {ssid}") - - # Check for existing connections - settings = self._client.connections - connection = None - - for setting in settings: - wifi_setting = setting.get_setting_wireless() - if ( - wifi_setting - and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid - ): - connection = setting - break - - def on_activation_result(client, result): - """Handle the result of connection activation""" - try: - active_connection = client.activate_connection_finish(result) - if active_connection: - logger.info(f"Successfully connected to '{ssid}'") - if callback: - callback(True, "Connected successfully") - else: - logger.error(f"Failed to connect to '{ssid}'") - if callback: - callback(False, "Failed to connect. Please try again.") - except Exception as e: - error_msg = str(e).lower() - logger.error(f"Connection to '{ssid}' failed: {e}") - - # Parse NetworkManager error messages and provide user-friendly responses - if "802-11-wireless-security" in error_msg: - if "property is invalid" in error_msg or "psk" in error_msg: - user_msg = "Incorrect password. Please try again." - else: - user_msg = "Security configuration error. Please try again." - elif "secrets were required" in error_msg or "no secrets" in error_msg: - user_msg = "Incorrect password. Please try again." - elif "timeout" in error_msg: - user_msg = "Connection timeout. Please try again." - elif "not found" in error_msg or "no such device" in error_msg: - user_msg = "Network not available. Please try again." - elif "already connected" in error_msg: - user_msg = "Already connected to this network." - else: - user_msg = "Failed to connect. Please try again." - - if callback: - callback(False, user_msg) - - if not connection: - # Create a new connection profile - logger.info(f"Creating new WiFi connection for SSID '{ssid}'") - connection = NM.SimpleConnection.new() - - # Required connection settings - s_con = NM.SettingConnection.new() - s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) - s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") - s_con.set_property( - NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface() - ) # Set interface name - connection.add_setting(s_con) - - # Wireless settings - s_wifi = NM.SettingWireless.new() - s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode())) - s_wifi.set_property( - NM.SETTING_WIRELESS_MODE, "infrastructure" - ) # Ensure mode is correct - connection.add_setting(s_wifi) - - # Security settings (only if password is required and provided) - if ap.requires_password: - if not password: - logger.error("Password required but not provided") - if callback: - callback(False, "Password required but not provided") - return False - - s_sec = NM.SettingWirelessSecurity.new() - s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") - s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password) - connection.add_setting(s_sec) - - # IPv4 settings - s_ipv4 = NM.SettingIP4Config.new() - s_ipv4.set_property("method", "auto") - connection.add_setting(s_ipv4) - - # IPv6 settings - s_ipv6 = NM.SettingIP6Config.new() - s_ipv6.set_property("method", "auto") - connection.add_setting(s_ipv6) - - # Callback for async connection - def on_connection_added(client, result): - try: - new_connection = client.add_connection_finish(result) - if not new_connection: - logger.error(f"Failed to create connection for '{ssid}'") - if callback: - callback( - False, "Failed to create connection. Please try again." - ) - return - - logger.info(f"Connection for '{ssid}' added successfully") - - # Now activate the newly created connection - client.activate_connection_async( - new_connection, self._device, None, None, on_activation_result - ) - except Exception as e: - error_msg = str(e).lower() - logger.error(f"Failed to add connection: {e}") - - # Parse connection creation errors and provide user-friendly responses - if "802-11-wireless-security" in error_msg: - if "property is invalid" in error_msg or "psk" in error_msg: - user_msg = "Incorrect password. Please try again." - else: - user_msg = "Security configuration error. Please try again." - elif "invalid" in error_msg or "property" in error_msg: - user_msg = "Invalid network configuration. Please try again." - else: - user_msg = "Failed to connect. Please try again." - - if callback: - callback(False, user_msg) - - # Save the new connection - self._client._client.add_connection_async( - connection, True, None, on_connection_added - ) - else: - # Activate existing connection - self._client._client.activate_connection_async( - connection, self._device, None, None, on_activation_result - ) - - return True - - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - return - - -class Ethernet(Service): - """A service to manage ethernet devices""" - - @Signal - def changed(self) -> None: ... - - @Signal - def enabled(self) -> bool: ... - - @Property(int, "readable") - def speed(self) -> str: - speed_mbps = self._device.get_speed() - return f"{speed_mbps} Mb/s" - - @Property(str, "readable") - def state(self) -> str: - return snake_case_to_kebab_case( - get_enum_member_name(self._device.get_state(), default="disconnected") - ) - - @Property(str, "readable") - def internet(self) -> str: - if self._active_connection: - return snake_case_to_kebab_case( - get_enum_member_name( - self._active_connection.get_state(), default="disconnected" - ) - ) - return "disconnected" - - @Property(str, "readable") - def iface(self) -> str: - return self._device.get_iface() if self._device else None - - @Property(str, "readable") - def icon_name(self) -> str: - network = self.internet - if network == "activated": - return "network-wired-symbolic" - elif network == "activating": - return "network-wired-acquiring-symbolic" - return "network-wired-disconnected-symbolic" - - def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None: - super().__init__(**kwargs) - self._client: NM.Client = client - self._device: NM.DeviceEthernet = device - self._active_connection = None - - self._device.connect( - "state-changed", lambda *args: self.on_network_state_changed() - ) - - self.update_active_connection() - - def on_network_state_changed(self): - """Called when networking is toggled on/off.""" - if self.state != "unmanaged": - # Re-initialize device when networking is re-enabled - self.update_active_connection() - else: - self._active_connection = None - - def update_active_connection(self): - """Updates the active connection and connects to its state change signal.""" - active_connection = self._device.get_active_connection() - if active_connection: - self._active_connection = active_connection - self._active_connection.connect( - "state-changed", lambda *args: self.emit("changed") - ) - - def get_network_stats(self): - """Fetch received and transmitted bytes from the system files""" - try: - # Read data from /sys/class/net for accurate speed - with open( - f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r" - ) as rx_file: - rx_bytes = int(rx_file.read().strip()) - with open( - f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r" - ) as tx_file: - tx_bytes = int(tx_file.read().strip()) - return rx_bytes, tx_bytes - except FileNotFoundError: - return None, None diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/assets/icons/applets/bluetooth-clear.svg b/src/assets/icons/applets/bluetooth-clear.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-clear.svg rename to src/assets/icons/applets/bluetooth-clear.svg diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/src/assets/icons/applets/bluetooth-off-clear.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-off-clear.svg rename to src/assets/icons/applets/bluetooth-off-clear.svg diff --git a/config/assets/icons/applets/bluetooth-off.svg b/src/assets/icons/applets/bluetooth-off.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-off.svg rename to src/assets/icons/applets/bluetooth-off.svg diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/src/assets/icons/applets/bluetooth-paired.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-paired.svg rename to src/assets/icons/applets/bluetooth-paired.svg diff --git a/config/assets/icons/applets/bluetooth.svg b/src/assets/icons/applets/bluetooth.svg similarity index 100% rename from config/assets/icons/applets/bluetooth.svg rename to src/assets/icons/applets/bluetooth.svg diff --git a/config/assets/icons/brightness/brightness.svg b/src/assets/icons/applets/brightness.svg similarity index 100% rename from config/assets/icons/brightness/brightness.svg rename to src/assets/icons/applets/brightness.svg diff --git a/config/assets/icons/applets/caffeine-off.svg b/src/assets/icons/applets/caffeine-off.svg similarity index 100% rename from config/assets/icons/applets/caffeine-off.svg rename to src/assets/icons/applets/caffeine-off.svg diff --git a/config/assets/icons/applets/caffeine-on.svg b/src/assets/icons/applets/caffeine-on.svg similarity index 100% rename from config/assets/icons/applets/caffeine-on.svg rename to src/assets/icons/applets/caffeine-on.svg diff --git a/config/assets/icons/applets/dnd-clear.svg b/src/assets/icons/applets/dnd-clear.svg similarity index 100% rename from config/assets/icons/applets/dnd-clear.svg rename to src/assets/icons/applets/dnd-clear.svg diff --git a/config/assets/icons/applets/dnd-off.svg b/src/assets/icons/applets/dnd-off.svg similarity index 100% rename from config/assets/icons/applets/dnd-off.svg rename to src/assets/icons/applets/dnd-off.svg diff --git a/config/assets/icons/applets/dnd.svg b/src/assets/icons/applets/dnd.svg similarity index 97% rename from config/assets/icons/applets/dnd.svg rename to src/assets/icons/applets/dnd.svg index 4ecdd999..aaa79ea1 100644 --- a/config/assets/icons/applets/dnd.svg +++ b/src/assets/icons/applets/dnd.svg @@ -1,4 +1,4 @@ <svg viewBox="-10 -10 45 45" width="28" height="28" stroke="#fff" stroke-width="2" fill="#2369ff" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"> <circle cx="12" cy="12" r="18" stroke="#2369ff" stroke-width="1.5" fill="#2369ff"></circle> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="#fff"></path> -</svg> \ No newline at end of file +</svg> diff --git a/config/assets/icons/applets/flight-off.svg b/src/assets/icons/applets/flight-off.svg similarity index 100% rename from config/assets/icons/applets/flight-off.svg rename to src/assets/icons/applets/flight-off.svg diff --git a/config/assets/icons/applets/flight-on.svg b/src/assets/icons/applets/flight-on.svg similarity index 100% rename from config/assets/icons/applets/flight-on.svg rename to src/assets/icons/applets/flight-on.svg diff --git a/config/assets/icons/applets/network-wired-offline.svg b/src/assets/icons/applets/network-wired-offline.svg similarity index 100% rename from config/assets/icons/applets/network-wired-offline.svg rename to src/assets/icons/applets/network-wired-offline.svg diff --git a/config/assets/icons/applets/network-wired.svg b/src/assets/icons/applets/network-wired.svg similarity index 100% rename from config/assets/icons/applets/network-wired.svg rename to src/assets/icons/applets/network-wired.svg diff --git a/config/assets/icons/applets/redshift-status-off.svg b/src/assets/icons/applets/redshift-status-off.svg similarity index 100% rename from config/assets/icons/applets/redshift-status-off.svg rename to src/assets/icons/applets/redshift-status-off.svg diff --git a/config/assets/icons/applets/redshift-status-on.svg b/src/assets/icons/applets/redshift-status-on.svg similarity index 100% rename from config/assets/icons/applets/redshift-status-on.svg rename to src/assets/icons/applets/redshift-status-on.svg diff --git a/config/assets/icons/applets/wifi-clear.svg b/src/assets/icons/applets/wifi-clear.svg similarity index 100% rename from config/assets/icons/applets/wifi-clear.svg rename to src/assets/icons/applets/wifi-clear.svg diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/src/assets/icons/applets/wifi-off-clear.svg similarity index 100% rename from config/assets/icons/applets/wifi-off-clear.svg rename to src/assets/icons/applets/wifi-off-clear.svg diff --git a/config/assets/icons/applets/wifi-off.svg b/src/assets/icons/applets/wifi-off.svg similarity index 100% rename from config/assets/icons/applets/wifi-off.svg rename to src/assets/icons/applets/wifi-off.svg diff --git a/config/assets/icons/applets/wifi.svg b/src/assets/icons/applets/wifi.svg similarity index 100% rename from config/assets/icons/applets/wifi.svg rename to src/assets/icons/applets/wifi.svg diff --git a/config/assets/icons/battery/battery-000-charging.svg b/src/assets/icons/battery/battery-000-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-000-charging.svg rename to src/assets/icons/battery/battery-000-charging.svg diff --git a/config/assets/icons/battery/battery-000.svg b/src/assets/icons/battery/battery-000.svg similarity index 100% rename from config/assets/icons/battery/battery-000.svg rename to src/assets/icons/battery/battery-000.svg diff --git a/config/assets/icons/battery/battery-010-charging.svg b/src/assets/icons/battery/battery-010-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-010-charging.svg rename to src/assets/icons/battery/battery-010-charging.svg diff --git a/config/assets/icons/battery/battery-010.svg b/src/assets/icons/battery/battery-010.svg similarity index 100% rename from config/assets/icons/battery/battery-010.svg rename to src/assets/icons/battery/battery-010.svg diff --git a/config/assets/icons/battery/battery-020-charging.svg b/src/assets/icons/battery/battery-020-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-020-charging.svg rename to src/assets/icons/battery/battery-020-charging.svg diff --git a/config/assets/icons/battery/battery-020.svg b/src/assets/icons/battery/battery-020.svg similarity index 100% rename from config/assets/icons/battery/battery-020.svg rename to src/assets/icons/battery/battery-020.svg diff --git a/config/assets/icons/battery/battery-030-charging.svg b/src/assets/icons/battery/battery-030-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-030-charging.svg rename to src/assets/icons/battery/battery-030-charging.svg diff --git a/config/assets/icons/battery/battery-030.svg b/src/assets/icons/battery/battery-030.svg similarity index 100% rename from config/assets/icons/battery/battery-030.svg rename to src/assets/icons/battery/battery-030.svg diff --git a/config/assets/icons/battery/battery-040-charging.svg b/src/assets/icons/battery/battery-040-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-040-charging.svg rename to src/assets/icons/battery/battery-040-charging.svg diff --git a/config/assets/icons/battery/battery-040.svg b/src/assets/icons/battery/battery-040.svg similarity index 100% rename from config/assets/icons/battery/battery-040.svg rename to src/assets/icons/battery/battery-040.svg diff --git a/config/assets/icons/battery/battery-050-charging.svg b/src/assets/icons/battery/battery-050-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-050-charging.svg rename to src/assets/icons/battery/battery-050-charging.svg diff --git a/config/assets/icons/battery/battery-050.svg b/src/assets/icons/battery/battery-050.svg similarity index 100% rename from config/assets/icons/battery/battery-050.svg rename to src/assets/icons/battery/battery-050.svg diff --git a/config/assets/icons/battery/battery-060-charging.svg b/src/assets/icons/battery/battery-060-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-060-charging.svg rename to src/assets/icons/battery/battery-060-charging.svg diff --git a/config/assets/icons/battery/battery-060.svg b/src/assets/icons/battery/battery-060.svg similarity index 100% rename from config/assets/icons/battery/battery-060.svg rename to src/assets/icons/battery/battery-060.svg diff --git a/config/assets/icons/battery/battery-070-charging.svg b/src/assets/icons/battery/battery-070-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-070-charging.svg rename to src/assets/icons/battery/battery-070-charging.svg diff --git a/config/assets/icons/battery/battery-070.svg b/src/assets/icons/battery/battery-070.svg similarity index 100% rename from config/assets/icons/battery/battery-070.svg rename to src/assets/icons/battery/battery-070.svg diff --git a/config/assets/icons/battery/battery-080-charging.svg b/src/assets/icons/battery/battery-080-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-080-charging.svg rename to src/assets/icons/battery/battery-080-charging.svg diff --git a/config/assets/icons/battery/battery-080.svg b/src/assets/icons/battery/battery-080.svg similarity index 100% rename from config/assets/icons/battery/battery-080.svg rename to src/assets/icons/battery/battery-080.svg diff --git a/config/assets/icons/battery/battery-090-charging.svg b/src/assets/icons/battery/battery-090-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-090-charging.svg rename to src/assets/icons/battery/battery-090-charging.svg diff --git a/config/assets/icons/battery/battery-090.svg b/src/assets/icons/battery/battery-090.svg similarity index 100% rename from config/assets/icons/battery/battery-090.svg rename to src/assets/icons/battery/battery-090.svg diff --git a/config/assets/icons/battery/battery-100-charging.svg b/src/assets/icons/battery/battery-100-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-100-charging.svg rename to src/assets/icons/battery/battery-100-charging.svg diff --git a/config/assets/icons/battery/battery-100.svg b/src/assets/icons/battery/battery-100.svg similarity index 100% rename from config/assets/icons/battery/battery-100.svg rename to src/assets/icons/battery/battery-100.svg diff --git a/config/assets/icons/brightness/brightness-0.svg b/src/assets/icons/brightness/brightness-0.svg similarity index 100% rename from config/assets/icons/brightness/brightness-0.svg rename to src/assets/icons/brightness/brightness-0.svg diff --git a/config/assets/icons/brightness/brightness-1.svg b/src/assets/icons/brightness/brightness-1.svg similarity index 100% rename from config/assets/icons/brightness/brightness-1.svg rename to src/assets/icons/brightness/brightness-1.svg diff --git a/config/assets/icons/brightness/brightness-2.svg b/src/assets/icons/brightness/brightness-2.svg similarity index 100% rename from config/assets/icons/brightness/brightness-2.svg rename to src/assets/icons/brightness/brightness-2.svg diff --git a/config/assets/icons/brightness/brightness-3.svg b/src/assets/icons/brightness/brightness-3.svg similarity index 100% rename from config/assets/icons/brightness/brightness-3.svg rename to src/assets/icons/brightness/brightness-3.svg diff --git a/src/assets/icons/brightness/brightness.svg b/src/assets/icons/brightness/brightness.svg new file mode 100755 index 00000000..d6065c7b --- /dev/null +++ b/src/assets/icons/brightness/brightness.svg @@ -0,0 +1,6 @@ +<svg width="22" height="22" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <defs> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text { color:#f2f2f2; } .ColorScheme-Highlight { color:#4285f4; }</style> + </defs> + <path d="m10.75 3.5a0.25 0.25 0 0 0-0.25 0.25v1.5c0 0.139 0.112 0.25 0.25 0.25h0.5a0.25 0.25 0 0 0 0.25-0.25v-1.5a0.25 0.25 0 0 0-0.25-0.25zm-4.699 1.947a0.25 0.25 0 0 0-0.178 0.073l-0.353 0.353a0.25 0.25 0 0 0 0 0.354l1.06 1.06a0.25 0.25 0 0 0 0.354 0l0.353-0.353a0.25 0.25 0 0 0 0-0.354l-1.06-1.06a0.246 0.246 0 0 0-0.176-0.073zm9.898 0a0.246 0.246 0 0 0-0.175 0.073l-1.06 1.06a0.25 0.25 0 0 0 0 0.354l0.353 0.353a0.25 0.25 0 0 0 0.353 0l1.06-1.06a0.25 0.25 0 0 0 0-0.354l-0.353-0.353a0.25 0.25 0 0 0-0.178-0.073zm-4.949 2.053a3.5 3.5 0 0 0-3.5 3.5 3.5 3.5 0 0 0 3.5 3.5 3.5 3.5 0 0 0 3.5-3.5 3.5 3.5 0 0 0-3.5-3.5zm0 1a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5 2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5zm-7.25 2a0.25 0.25 0 0 0-0.25 0.25v0.5c0 0.139 0.112 0.25 0.25 0.25h1.5a0.25 0.25 0 0 0 0.25-0.25v-0.5a0.25 0.25 0 0 0-0.25-0.25zm13 0a0.25 0.25 0 0 0-0.25 0.25v0.5c0 0.139 0.112 0.25 0.25 0.25h1.5a0.25 0.25 0 0 0 0.25-0.25v-0.5a0.25 0.25 0 0 0-0.25-0.25zm-9.992 4.139a0.252 0.252 0 0 0-0.178 0.074l-1.06 1.06a0.25 0.25 0 0 0 0 0.354l0.353 0.353a0.25 0.25 0 0 0 0.354 0l1.06-1.06a0.25 0.25 0 0 0 0-0.354l-0.353-0.353a0.248 0.248 0 0 0-0.176-0.074zm8.484 0a0.248 0.248 0 0 0-0.175 0.074l-0.354 0.353a0.25 0.25 0 0 0 0 0.354l1.06 1.06a0.25 0.25 0 0 0 0.354 0l0.354-0.353a0.25 0.25 0 0 0 0-0.354l-1.06-1.06a0.252 0.252 0 0 0-0.179-0.074zm-4.492 1.861a0.25 0.25 0 0 0-0.25 0.25v1.5c0 0.139 0.112 0.25 0.25 0.25h0.5a0.25 0.25 0 0 0 0.25-0.25v-1.5a0.25 0.25 0 0 0-0.25-0.25z" class="ColorScheme-Text" fill="currentColor"/> +</svg> diff --git a/src/assets/icons/google-lens.svg b/src/assets/icons/google-lens.svg new file mode 100644 index 00000000..044d0a6a --- /dev/null +++ b/src/assets/icons/google-lens.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="768" height="768"><path d="M552.96 84.48c69.12 0 126.72 57.6 126.72 126.72v84.48H768v-80.64C768 96 672 0 552.96 0h-84.48v84.48z" style="fill:#fbbc04;stroke-width:38.4"/><path d="M215.04 683.52c-69.12 0-126.72-57.6-126.72-126.72v-84.48H0v84.48C0 672 96 768 215.04 768h84.48v-84.48z" style="fill:#4285f4;stroke-width:38.4"/><path d="M215.04 84.48c-69.12 0-126.72 57.6-126.72 126.72v84.48H0v-80.64C0 96 96 0 215.04 0h84.48v84.48z" style="fill:#ea4335;stroke-width:38.4"/><circle cx="384" cy="384" r="149.76" style="fill:#4285f4;stroke-width:38.4"/><circle cx="641.28" cy="641.28" r="84.48" style="fill:#34a853;stroke-width:38.4"/></svg> diff --git a/src/assets/icons/mic/microphone-0.svg b/src/assets/icons/mic/microphone-0.svg new file mode 100644 index 00000000..83c28314 --- /dev/null +++ b/src/assets/icons/mic/microphone-0.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-mic-off"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg> diff --git a/config/assets/icons/mic/microphone-1.svg b/src/assets/icons/mic/microphone-1.svg similarity index 100% rename from config/assets/icons/mic/microphone-1.svg rename to src/assets/icons/mic/microphone-1.svg diff --git a/config/assets/icons/mic/microphone-2.svg b/src/assets/icons/mic/microphone-2.svg similarity index 100% rename from config/assets/icons/mic/microphone-2.svg rename to src/assets/icons/mic/microphone-2.svg diff --git a/config/assets/icons/mic/microphone-3.svg b/src/assets/icons/mic/microphone-3.svg similarity index 100% rename from config/assets/icons/mic/microphone-3.svg rename to src/assets/icons/mic/microphone-3.svg diff --git a/config/assets/icons/mic/microphone.svg b/src/assets/icons/mic/microphone.svg similarity index 100% rename from config/assets/icons/mic/microphone.svg rename to src/assets/icons/mic/microphone.svg diff --git a/config/assets/icons/misc/chevron-left.svg b/src/assets/icons/misc/chevron-left.svg similarity index 88% rename from config/assets/icons/misc/chevron-left.svg rename to src/assets/icons/misc/chevron-left.svg index 8cade293..61c1356d 100644 --- a/config/assets/icons/misc/chevron-left.svg +++ b/src/assets/icons/misc/chevron-left.svg @@ -6,7 +6,7 @@ } </style> </defs> - <path style="fill:currentColor;fill-opacity:1;stroke:none" + <path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 15.292969 4 L 9.1679688 10.125 L 7.2929688 12 L 9.1679688 13.875 L 15.292969 20 L 16 19.292969 L 9.875 13.167969 L 8.7070312 12 L 9.875 10.832031 L 16 4.7070312 L 15.292969 4 z " class="ColorScheme-Text" /> diff --git a/config/assets/icons/misc/chevron-right.svg b/src/assets/icons/misc/chevron-right.svg similarity index 88% rename from config/assets/icons/misc/chevron-right.svg rename to src/assets/icons/misc/chevron-right.svg index f81b1e67..bb171892 100644 --- a/config/assets/icons/misc/chevron-right.svg +++ b/src/assets/icons/misc/chevron-right.svg @@ -6,7 +6,7 @@ } </style> </defs> - <path style="fill:currentColor;fill-opacity:1;stroke:none" + <path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 8.7070312 4 L 8 4.7070312 L 14.125 10.832031 L 15.292969 12 L 14.125 13.167969 L 8 19.292969 L 8.7070312 20 L 14.832031 13.875 L 16.707031 12 L 14.832031 10.125 L 8.7070312 4 z " class="ColorScheme-Text" /> diff --git a/src/assets/icons/misc/color-picker.svg b/src/assets/icons/misc/color-picker.svg new file mode 100644 index 00000000..ad58f632 --- /dev/null +++ b/src/assets/icons/misc/color-picker.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M22 0C21.4696 8.03e-05 20.9609 0.210847 20.5859 0.585938L17.793 3.37891L16.207 1.79297L14.793 3.20703L15.9414 4.35547L2.76562 17.5293C2.25587 18.039 2 18.711 2 19.3828V20.5859L0 22.5859L1.41406 24L3.41406 22H4.61914C5.29096 22 5.96291 21.7461 6.47266 21.2363L19.6484 8.0625L20.793 9.20703L22.207 7.79297L20.6211 6.20703L23.4141 3.41406C23.7892 3.03905 23.9999 2.5304 24 2V1.5C24 1.10218 23.842 0.720644 23.5607 0.43934C23.2794 0.158035 22.8978 0 22.5 0H22ZM16.6484 5.0625L18.9414 7.35547L10.2949 16H5.70898L16.6484 5.0625Z" fill="white"/> +</svg> diff --git a/config/assets/icons/misc/control-center.svg b/src/assets/icons/misc/control-center.svg similarity index 78% rename from config/assets/icons/misc/control-center.svg rename to src/assets/icons/misc/control-center.svg index 24f2195a..c1b12330 100644 --- a/config/assets/icons/misc/control-center.svg +++ b/src/assets/icons/misc/control-center.svg @@ -1,4 +1,4 @@ -<svg class="svg-icon" style="width: 2em;height: 2em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> +<svg class="svg-icon" style="width: 24px;height: 24px;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> <path d="M349.090909 488.727273h325.818182a139.636364 139.636364 0 0 0 0-279.272728H349.090909a139.636364 139.636364 0 0 0 0 279.272728z m0-232.727273h325.818182a93.090909 93.090909 0 0 1 0 186.181818H349.090909a93.090909 93.090909 0 0 1 0-186.181818z" fill="#EEEEEE" /> <path d="M349.090909 349.090909m-69.818182 0a69.818182 69.818182 0 1 0 139.636364 0 69.818182 69.818182 0 1 0-139.636364 0Z" fill="#EEEEEE" /> <path d="M674.909091 535.272727H349.090909a139.636364 139.636364 0 0 0 0 279.272728h325.818182a139.636364 139.636364 0 0 0 0-279.272728z m0 209.454546a69.818182 69.818182 0 1 1 69.818182-69.818182 69.818182 69.818182 0 0 1-69.818182 69.818182z" fill="#EEEEEE" /> diff --git a/src/assets/icons/misc/control.svg b/src/assets/icons/misc/control.svg new file mode 100644 index 00000000..054ac34d --- /dev/null +++ b/src/assets/icons/misc/control.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-command"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"></path></svg> diff --git a/src/assets/icons/misc/imac.svg b/src/assets/icons/misc/imac.svg new file mode 100644 index 00000000..e0094dab --- /dev/null +++ b/src/assets/icons/misc/imac.svg @@ -0,0 +1,120 @@ +<svg width="1216" height="735" viewBox="0 0 1216 735" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_601_318)"> +<path d="M1216 689.6V685H0V689.6C0 703.041 0 709.762 2.61584 714.896C4.91681 719.412 8.58832 723.083 13.1042 725.384C18.2381 728 24.9587 728 38.4 728H1177.6C1191.04 728 1197.76 728 1202.9 725.384C1207.41 723.083 1211.08 719.412 1213.38 714.896C1216 709.762 1216 703.041 1216 689.6Z" fill="url(#paint0_linear_601_318)"/> +<path d="M1146 731C1147.6 730.6 1148.67 728.833 1149 728H1069.5C1069.83 728.833 1070.9 730.6 1072.5 731C1072.67 732.167 1073.4 734.5 1075 734.5H1143.5C1145.1 734.5 1145.83 732.167 1146 731Z" fill="url(#paint1_linear_601_318)"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1145.93 731.406V731.25H1072.54C1072.77 732.47 1073.51 734.5 1075 734.5H1143.5C1144.93 734.5 1145.67 732.635 1145.93 731.406Z" fill="url(#paint2_linear_601_318)"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1145.93 731.25V731.406C1145.85 731.754 1145.74 732.152 1145.59 732.545H1072.91C1072.73 732.092 1072.61 731.631 1072.54 731.25H1145.93Z" fill="#383838"/> +<path d="M142.5 731C144.1 730.6 145.167 728.833 145.5 728H66C66.3333 728.833 67.4 730.6 69 731C69.1667 732.167 69.9 734.5 71.5 734.5H140C141.6 734.5 142.333 732.167 142.5 731Z" fill="url(#paint3_linear_601_318)"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M142.427 731.406V731.25H69.0413C69.269 732.47 70.0074 734.5 71.4999 734.5H140C141.43 734.5 142.168 732.635 142.427 731.406Z" fill="url(#paint4_linear_601_318)"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M142.428 731.25V731.406C142.354 731.754 142.243 732.152 142.088 732.545H69.4123C69.2338 732.092 69.1127 731.631 69.0415 731.25H142.428Z" fill="#383838"/> +<path d="M502 685H713.5C713.5 694.113 706.113 701.5 697 701.5H518.5C509.387 701.5 502 694.113 502 685Z" fill="url(#paint5_linear_601_318)"/> +<path d="M712.992 685.5C712.728 694.105 705.669 701 697 701H518.5C509.831 701 502.772 694.105 502.508 685.5H712.992Z" stroke="white" stroke-opacity="0.1"/> +<rect x="1121.5" y="685" width="81" height="33" fill="url(#paint6_linear_601_318)"/> +<rect width="84" height="33" transform="matrix(-1 0 0 1 84.5 685)" fill="url(#paint7_linear_601_318)"/> +<g filter="url(#filter0_f_601_318)"> +<path d="M1192.5 685H1205.5V705.112C1205.5 709.469 1201.97 713 1197.61 713C1194.79 713 1192.5 710.711 1192.5 707.888V685Z" fill="#D2D2DA"/> +</g> +<g filter="url(#filter1_f_601_318)"> +<path d="M20.5 685H9.5V706.326C9.5 710.012 12.4881 713 16.1742 713C18.5633 713 20.5 711.063 20.5 708.674V685Z" fill="#D2D2DA"/> +</g> +</g> +<path d="M608.5 2H1078.5C1093.96 2 1106.5 14.536 1106.5 30V683H608.5H608H110V30C110 14.536 122.536 2 138 2H608H608.5Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1078.5 4H138C123.641 4 112 15.6406 112 30V681H1104.5V30C1104.5 15.6406 1092.86 4 1078.5 4ZM1078.5 2H138C122.536 2 110 14.536 110 30V683H1106.5V30C1106.5 14.536 1093.96 2 1078.5 2Z" fill="#201F24"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1108.5 685H108V30C108 13.4315 121.431 0 138 0H1078.5C1095.07 0 1108.5 13.4315 1108.5 30V685ZM1078.5 2H138C122.536 2 110 14.536 110 30V683H1106.5V30C1106.5 14.536 1093.96 2 1078.5 2Z" fill="#7D7D7F"/> +<path d="M112.846 657H1103.5V676C1103.5 678.209 1101.71 680 1099.5 680H116.846C114.637 680 112.846 678.209 112.846 676V657Z" fill="url(#paint8_linear_601_318)"/> +<rect x="124.5" y="17.75" width="967" height="628" fill="url(#paint9_linear_601_318)"/> +<path d="M667 20.5C667 18 670 18 671.5 18H544.5C546 18 549 18 549 20.5V30C549 34.4183 552.582 38 557 38H659C663.418 38 667 34.4183 667 30V20.5Z" fill="black"/> +<circle cx="608" cy="24.0907" r="3.77901" fill="#0F0F0F" stroke="#151515" stroke-width="0.260621"/> +<circle cx="607.609" cy="25.0028" r="0.912175" fill="#16181E"/> +<circle cx="607.609" cy="23.6997" r="0.912175" fill="#0A0B0D"/> +<circle opacity="0.6" cx="608" cy="24.0906" r="1.30311" fill="#1F2531"/> +<defs> +<filter id="filter0_f_601_318" x="1184.5" y="677" width="29" height="44" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="4" result="effect1_foregroundBlur_601_318"/> +</filter> +<filter id="filter1_f_601_318" x="-0.5" y="675" width="31" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="5" result="effect1_foregroundBlur_601_318"/> +</filter> +<linearGradient id="paint0_linear_601_318" x1="608" y1="685" x2="608" y2="728" gradientUnits="userSpaceOnUse"> +<stop stop-color="#AEAFB4"/> +<stop offset="0.192522" stop-color="#AFB0B5"/> +<stop offset="0.376094" stop-color="#AFB0B5"/> +<stop offset="0.598958" stop-color="#7D7E82"/> +<stop offset="0.713542" stop-color="#696A6F"/> +<stop offset="0.852125" stop-color="#838489"/> +<stop offset="0.931356" stop-color="#ABACB0"/> +</linearGradient> +<linearGradient id="paint1_linear_601_318" x1="1069.5" y1="731.25" x2="1149" y2="731.25" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8C8D91"/> +<stop offset="0.173014" stop-color="#525358"/> +<stop offset="0.31824" stop-color="#78797D"/> +<stop offset="0.465029" stop-color="#A0A0A2"/> +<stop offset="0.691458" stop-color="#9A9A9C"/> +<stop offset="0.825754" stop-color="#4F5055"/> +<stop offset="0.941311" stop-color="#9E9EA0"/> +<stop offset="1" stop-color="#848589"/> +</linearGradient> +<linearGradient id="paint2_linear_601_318" x1="1072.54" y1="732.875" x2="1145.93" y2="732.875" gradientUnits="userSpaceOnUse"> +<stop stop-color="#575757"/> +<stop offset="0.0827846" stop-color="#2D2D2D"/> +<stop offset="0.490477" stop-color="#4E4E4E"/> +<stop offset="0.824814" stop-color="#2A2A2A"/> +<stop offset="1" stop-color="#5C5C5C"/> +</linearGradient> +<linearGradient id="paint3_linear_601_318" x1="66" y1="731.25" x2="145.5" y2="731.25" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8C8D91"/> +<stop offset="0.173014" stop-color="#525358"/> +<stop offset="0.31824" stop-color="#78797D"/> +<stop offset="0.465029" stop-color="#A0A0A2"/> +<stop offset="0.691458" stop-color="#9A9A9C"/> +<stop offset="0.825754" stop-color="#4F5055"/> +<stop offset="0.941311" stop-color="#9E9EA0"/> +<stop offset="1" stop-color="#848589"/> +</linearGradient> +<linearGradient id="paint4_linear_601_318" x1="69.0413" y1="732.875" x2="142.427" y2="732.875" gradientUnits="userSpaceOnUse"> +<stop stop-color="#575757"/> +<stop offset="0.0827846" stop-color="#2D2D2D"/> +<stop offset="0.490477" stop-color="#4E4E4E"/> +<stop offset="0.824814" stop-color="#2A2A2A"/> +<stop offset="1" stop-color="#5C5C5C"/> +</linearGradient> +<linearGradient id="paint5_linear_601_318" x1="502.5" y1="693" x2="713.5" y2="693" gradientUnits="userSpaceOnUse"> +<stop stop-color="#25262B"/> +<stop offset="0.015659" stop-color="#606166"/> +<stop offset="0.035819" stop-color="#9FA0A4"/> +<stop offset="0.0504351" stop-color="#C8C8CE"/> +<stop offset="0.479167" stop-color="#D7D8DD"/> +<stop offset="0.941214" stop-color="#CBCBD3"/> +<stop offset="0.970403" stop-color="#A7A8AC"/> +<stop offset="0.980129" stop-color="#595A5E"/> +<stop offset="1" stop-color="#27282C"/> +</linearGradient> +<linearGradient id="paint6_linear_601_318" x1="1202.5" y1="702" x2="1121.5" y2="702" gradientUnits="userSpaceOnUse"> +<stop stop-color="#65666B" stop-opacity="0"/> +<stop offset="0.259918" stop-color="#65666B"/> +<stop offset="1" stop-color="#65666B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint7_linear_601_318" x1="84" y1="17" x2="2.87514e-08" y2="17" gradientUnits="userSpaceOnUse"> +<stop stop-color="#65666B" stop-opacity="0"/> +<stop offset="0.259918" stop-color="#65666B"/> +<stop offset="1" stop-color="#65666B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint8_linear_601_318" x1="607.5" y1="657" x2="607.5" y2="680" gradientUnits="userSpaceOnUse"> +<stop stop-color="#222222"/> +<stop offset="0.0852114" stop-color="#242424"/> +<stop offset="0.830431" stop-color="#242424"/> +<stop offset="1" stop-color="#201F24"/> +</linearGradient> +<linearGradient id="paint9_linear_601_318" x1="608" y1="17.75" x2="608" y2="645.75" gradientUnits="userSpaceOnUse"> +<stop stop-color="#68A7FF"/> +<stop offset="1" stop-color="#AED0FF"/> +</linearGradient> +<clipPath id="clip0_601_318"> +<rect width="1216" height="49.5" fill="white" transform="translate(0 685)"/> +</clipPath> +</defs> +</svg> diff --git a/src/assets/icons/misc/logo.svg b/src/assets/icons/misc/logo.svg new file mode 100644 index 00000000..1dd9b65d --- /dev/null +++ b/src/assets/icons/misc/logo.svg @@ -0,0 +1 @@ +<svg fill="#ffffff" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M18.71 19.5C17.88 20.74 17 21.95 15.66 21.97C14.32 22 13.89 21.18 12.37 21.18C10.84 21.18 10.37 21.95 9.09997 22C7.78997 22.05 6.79997 20.68 5.95997 19.47C4.24997 17 2.93997 12.45 4.69997 9.39C5.56997 7.87 7.12997 6.91 8.81997 6.88C10.1 6.86 11.32 7.75 12.11 7.75C12.89 7.75 14.37 6.68 15.92 6.84C16.57 6.87 18.39 7.1 19.56 8.82C19.47 8.88 17.39 10.1 17.41 12.63C17.44 15.65 20.06 16.66 20.09 16.67C20.06 16.74 19.67 18.11 18.71 19.5ZM13 3.5C13.73 2.67 14.94 2.04 15.94 2C16.07 3.17 15.6 4.35 14.9 5.19C14.21 6.04 13.07 6.7 11.95 6.61C11.8 5.46 12.36 4.26 13 3.5Z"></path> </g></svg> diff --git a/config/assets/icons/misc/search.svg b/src/assets/icons/misc/search.svg similarity index 100% rename from config/assets/icons/misc/search.svg rename to src/assets/icons/misc/search.svg diff --git a/src/assets/icons/misc/trash-empty.svg b/src/assets/icons/misc/trash-empty.svg new file mode 100644 index 00000000..fa6be123 --- /dev/null +++ b/src/assets/icons/misc/trash-empty.svg @@ -0,0 +1,9 @@ +<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="1024" height="1024" fill="url(#pattern0_8_7)"/> +<defs> +<pattern id="pattern0_8_7" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_8_7" transform="scale(0.000976562)"/> +</pattern> +<image id="image0_8_7" width="1024" height="1024" preserveAspectRatio="none" xlink:href="data:image/png;base64,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AkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEjIARMAJGwAgYASNgBIyAETACRsAIGAEjYASMgBEwAkbACBgBI2AEzgsC3fPSELfDCBgBI2AEjMAZR+C8/SaPzvj5cPhGwAgYASNgBM4dAr1z1yI3yAgYASNgBIyAETACRsAIGAEjYASMgBEoENgoalxhBIyAETACRuDeEDhvGex7Q+HetYzfvWN3ETS9ouIinGW30QgYASOwZgS8AmDNANu8ETACRsAIGAEjYASMgBEwAkbACBiB04CAsw2n4Sw4BiNgBIzAfATO2j36rMU7H/XFtee9fYtbfjY45z1Dftbad9biPRu93FEaASNgBFoi4BUALQG0uhEwAkbACBgBI2AEjIARMAJGwAgY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r7dexqfM+JSdvdMzXuQQCIEQOJIE3B9rRxJKBh0CIRACIRACIRACIRACIRACIRACq0bAdYtWbbwZTwiEQAjsJ4Hea6yzn1fPsfMmsIs/r73Lv2j9vB33ef05Hif37mhw8ZZN7zro846nt2Pda+865r16Z8+O96LtyZv5Fq1nPMouP+0jh0AIhEAITCHAP+ammORQCIRACIRACIRACIRACIRACIRACITAshNw3Z5lH1/qD4EQCIFlIjDvNbnXn/aUe9nxpnJvvEX7u/qZb9H2jNebj/5HXXYdbsen15/2lF0+dqz325/1sZ5e/X7bM1/kEAiBEAiBBRDIHyMLgJgQIRACIRACIRACIRACIRACIRACIXDYCfR2Zw77eFJfCIRACBxlAvNe0/faf9747qb1vPG5dvY7H/NTdvXQfr/l3g73vPW5jjbjz1ufy7fX8TkeV8+89vSn3Juf/pFDIARCIAQOgMBh/2PiAJAkZQiEQAiEQAiEQAiEQAiEQAiEQAisHoFFd0tWj1BGFAIhEAJHh8CivxMOOt6i8/euhEXfZD/o8bjxL7ojPG9H3dXr9L3j6bVfdP79jufyRR8CIRACIXAICSz6j5NDOMSUFAIhEAIhEAIhEAIhEAIhEAIhEAIhcNi7CZmhEAiBEAiB1SGw1985ex2/dyaWvZ5Fd7R7+R10fta73/Xsdz6ON3IIhEAIhMAKEsgOgBWc1AwpBEIgBEIgBEIgBEIgBEIgBEIgBEjgsHUnWF/kEAiBEAiBELhaAqv+HXfYxrdqHetVG8/VnkfxC4EQCIEQWCEC2QGwQpOZoYRACIRACIRACIRACIRACIRACITAlQiMr6TI8RAIgRAIgRAIgRDoIJCOeQesmIZACIRACITAQRDIDoCDoJ6cIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACIRACS0rg/wPLER4Wrgv2ZgAAAABJRU5ErkJggg=="/> +</defs> +</svg> diff --git a/src/assets/icons/misc/trash-full.svg b/src/assets/icons/misc/trash-full.svg new file mode 100644 index 00000000..22bee4ca --- /dev/null +++ b/src/assets/icons/misc/trash-full.svg @@ -0,0 +1,9 @@ +<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="1024" height="1024" fill="url(#pattern0_8_11)"/> +<defs> +<pattern id="pattern0_8_11" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_8_11" transform="scale(0.000976562)"/> +</pattern> +<image id="image0_8_11" width="1024" height="1024" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAYAAAEIGhsVAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAEAKADAAQAAAABAAAEAAAAAADwFcgnAABAAElEQVR4Aey9B4BdV33nf97Mm940oz7qzZIsy90yuGFsejE2tllCCm0pCSSEBFjYwAZCYNkk+98UQiCbbJwEwm4wxtgGU4x7x11Wt7o80kgzI03vM/v9/M79vXfnaSRLmhlh9s9v5r577+nn/Or5nXPvDeFX8P/rEcj8Evf+WG0f/SXu02lvetFpr3HiFYJ4Dtpe0rx//1c4JwdhxTqORRyKOinwuvx8Upl/lXjyRsARwBkEl+qoPHTw0P39AwOjR44cGe3p7YXzK5M40kyUENJ1Ud9klKlifgUnOgKOdB94Q/p3v/vdpRvXb7zn6aefHu3u7h5zQARNTft/qAomSgjUbfWNjIyMdnZ1jR45fGRvQbmk+aWHl1snvD0uyhng7AsvvPA7ZWVlfz44OBhGR0dCZ2dX6OvrC2vXrg2ZoqIwOjKSQwT3AGEbNmy4dN26dU/odtgCQyDhS9kItAGVkpV06R4YGEiyxtNPfvzjZddff/0+3VHmiZQ3Jv/L7cYH/BfZrnQbGHigTuL9u0Ojo5dlRoYzg0NDQZwYQIa4PkAINTU1YcGCBZbYkW43+hno77ejtKwsFIkgfvrTn6aRRrI8xXim/NmQL7XyZG1t7arunp58THLVfODAJ5cvX/413fYnQb+0hJAe/KM6OsUBXrdze3bv7r031dTV3DgyPByGEqQ78oeGBkUAQ+L8Xp0HQ0a5V61aFXpEEDt37gxPPfWkjqfDcxvXhw+87z+Gc889L0ybNi1IcoSK8vIwNDxyZ2Pj3BvUp6GCfmULwrIyLP945uzZnwL5aelCPgjRYDTsm1Y/baWuKc+lAVEvJWFI87IBR8LpbJDXmUM83Facza6iEcNCPMgeGhwKjvzhhCA2b94cyoVMcaZJAqTBgQP7Q0lJSdizZ0/okmq498H7wpWXvSqsOGNFmDNnbpg9e06orq4yxLW3t4cVy5b9WfOhtlXZTKZ2OIxeWFJSXD0yOHIvdUvidMye2bC5oqrqU45oJEgaCKd9wMjwSOjvG/jOgkUL3qNbJwSiHAolzcuOOKD+qQZHOPVU6EBsYthlJeZ/VFVTfTlcNjI6asiH+xlYkP/Ezx8PixcuVOqsEN4R2o8cCVXz5plYv/vuu8KiRYtVTAjT6upCW3W1JEN/6O3rCAcPNYfurs5QP71BqmAw9PUPhPKy0vDJT35SSM58qKev776S4nDl1i1bwhvf9KZwpL3j4l179t1JWQsXzsshn3snhO9975Zw3XVvJ8iANgJl5aU3HmxuvhEJ09PTffezzz57i2yEA4qCIOgn4DZIvIu/Thy/UKIYS97p5k3OtSO/qPPQkY0q0gZFIvaLsti7QT7VgPw0NDfvD7d9/9awfMUZ4WYNfEVFRWhrazdOJl1G8v/Nb3qLcTjcXyapgE3Q0dEtcV8bjhzpyCG/QUQwY0ZDKC2L5kVleWndzOl115Bv+owZIrbh0NnRDmGGNatXXcs5EiGEGPH2/IYnwhmr14Rv3/5w6OvtNQK1dBBuchRlwhvVrD9ftmzFZ597bsO3d+zY+dzefft3Nh04eFBHq2Ynt8uY/bjylemAMGiQS0HuGSsfL12eHpjKCinbOtja2vp4T1fP1kRUZh9//PGz1qxZ8xBddOQjBbZuWB/ae/rCoeZdYeUZZ4eK6hobBVTA4cOHw6GDzSbSyyoqxdl9mg10hiefxMgPYf68+SKA9nDffffZfU1NbThzzZl23dXVFUpLS8P1198QZs2aFVpbW2RL9FvYzJkzw/bt28Os2Y1NVRWljWQY0UzDzuLyZ559Nqxf/1w49+J1kl2D4UfPR/H/h+84JwyrzWmgPagMZXuqv6/vWY8rLs6816+JLyopH66rreka7u//SVdXx62r16y5OYlPSwoKH8sZXsgknqeaAKDycs2j22uqq2fpenjThg1vmDtv3uekj1c1TJ9uXTEVACcJ0et//lBYvPoc0/Egvri4WFO/Uen6A6Gj/YilP7BvQ1i0/IKQzZaE1paWsHXb1jB37lyL2717V3haBuF5518QGhqm28yhVtKhsqoqrFt3cZgpru+UeuiXWmhsbLQ6MTiLioskSSqk0/Mc3tp2OPzlP/w0dA02hOqStlC7cIXV0XIwas51qwbCW65ca2HMTLBJ8lDUpfZ/50hnV11mZODtpSWlMkSHjGibm5vNeCXtBesuuW3WjIaVsjvOGOrrv/nxJx7/dGqaSRKnsikhhqkiAMqF+8vkuJlz9dVXb8+WlBw6fLhjcPvOnY+2tbZ20bPXvubVv4XRB5jYFeeBGAaSeT5IP7hnYzjrwivFta1CeLEIIg5+U9OLoSSbNU5ua2s1BLe1tQW4fcPzz5sRCAGgGnqbtoZL3vxOmxWoHWFQ00mQzrRy/vx5JtKFgFAkGd7Z0WGcjWh/z+99I5RKpQzIrgB6ug6HpasvCaU1s+2en4HO5nDNqxeFlUuqAwTrNgNEa9dFJbcdPPDiNaTtVttoa4ckhfsXsFtuvPE/hNLi0nv3Nje3LJw/b6lmF+cVjQz/+ey5c7+gbGnjctKlwmQTgJcH59t0qqik7BNlpdmip559/pb+/v4RTcuKODMga89a81p0MmKRwcK6fuD++8NN//wvRAthteE1V7/WRHuf5vbM+xcsWCh9Xm4q4NChQ2GbuB+48MILQ3t7h4n3+9tvCwvfUG/hq0fOCc0j2+3af8478qZQXzc//MVf/JmV//rXvz5AAEgbAA79jfd8yJOHkZLluWu/SBMCRHBg76bwsfeuC3V1tZ5EKqYszJg560hvT/e0B+6/zxDfJelD+Bmyb4Af/fjOMGvm7PDe970v7G1qdlXA9LVo2ZIlr6ipqhgZZ7o5adLAEZZr9AQuKMt0vsT8DUtXrPjW9i0v3HvwyJG2xtkz3t7U3HILZU+rqW5vbmmtcUK44NyzbijKFIUHH3oofP/WW0NZKbQjK6mkzCz3OXPnhKVLlmggZ4ZdO3dJCmSlr2fb/L5H83Ss/SOa3i1ZuSp8dSYeYBlzw38Visvb7Jqf4b6GMFx0IPR2R/19Xc+nJYJHDOHPPPN0eOc7fy3UaiaBRHF4nxCybcc2SYAGzT52eXCom7Y4LK2K6uaFgSqTCEQ+e/+fhI+879fDzfcMGCFUiaAaG+epnaXh8JH2cO89PwsH9h8I1TXVZntgk5TLR7Fk6VLNYqaFekmr6pq6po2bNz/slfkYnblq1SWVFaWHpk+ffoni3PmUthc8y0mfJ4sAHPllTO1kYS/Zs/fFp2iNplyap2cv7+3urB8aDvfLSmoh3KVAT3fPzDu+/91XDQ0PGif49A3x/fGP/wFJc9Ah8bxt2zYT4cMjw6G3ty/8e+XqsH1a5PZcwuRietkLYdXoZ+zOkf+B6r8ML+5pDpq6WfjDjzwafvu3PxwWaLqZJgAi/8f/+P9kmA6Hzc0HQuehu8LC3oEwp7Y0vLKqJ0ybnQ1Nw43hm/sGbOr5tje/PTy6sdjUxGVVu8PbPvFpMziZwfRq5rDh+fXh4YcfkQpok7qqli+jyiRBtaav7373u8OhQ63MXp7t6O7eZg1L/UAIIpbsqjOWXSPnVpWiXC1MmAgmgwAc+cXy5H1Tnry1ne2d6w+3dzDdMQLgjB7EiYNOxpED9+7fv1/++udDU9MBcWSXDUxj4xyz1ufKQHvDW64PH/ud94UbbriRIo6CBw52hc+3IHSOD40ld4W3Du8Kr5n16zIAu8PPH39Mg30k1GiWUTetToZfX3j9G99kNkChW/k/XvfBcGHFtlDZsscqAfEAyH8oMzdUVFbII7k63Hb3ixbOz+euLAvPbhkIN3zuY+a4Ygaz/YVt4ZAMVuyPFtkz6H6gWm247LLLQuO8BUxHQ3PL4ZwaIB7kc4YAOBcQASJtQurgpUePWl8aUJ7l8xbMu7Gnp3dnOnmlkA6g27HYD+xvCljquHTNqyeCAJAAOGs+9vsfDyAfa/ybN30jfP1rXwxvWb0s4JbFOQRw5nhFQ3n40Rmldnx+hhvLlsR+3ttQFL43fyj87ewrwxsa32NhuI4PHTxo1jh+gDLZE5vlEMIALET+fffeG1pHmnLIp4DpVX2hfOm80FA7M1SVnWNS667He0JldX2YNmNROPvsVaFvcHeo7Hna6htKDMMqIRrfAzBj+nRzYp21Zm1YvGhxaJEtQ9/xZiIRSQPiC5FPeFvb4S24zHUJQYC/CTFxtHpUygSABsDtpZ/81KfeJ9He1Hb4SK5RqACgvmHmgfvvv28xUzD0uNKZ569cIrJIXpSujs7wx3/8+bB3715z9FRWSp/LMFyx8rzw5M494Qd33C5J8WK48KKLrDz/8SnkHI3tb83IhnfVZ8K7poVwY/VQWF02alNIm5uLYOR8Cjt27Ahbt0bDcdaLz4Xwv74W1r37N0P9vCVWr5fb3d0z+skv/SBzRU02NJZvD/NmD4RR6e3ewWwYOtwZDi6/NLSVzAlz5XVc1/x/QmfrttBWuTy8teRIKN53V+gcrQnZZeeEeiSM+soqJjMdbBlc1Bi0uKhRa4zH9BnTjTmqqioWh0zxRoXlxlBENKJ7Y9bi0TA4Z97cdV/+8pe/qrZC9UdTvnfiBM5Rnp1AwuMkcSlSLAo36h0vbX1DfStivkuUzvQOaxhXLgs2DAaLN4Cv9nGNurjs8su5DDfddJOcPk+Fd9x4Y/j2//7fFuY/rP4BTClB9qFDB216iP4VF5lu19KuXMQtYdvWLaGlpS2s694TiuUAAhZd8Co7Q0xIAaZzb37HH2Xg6q3Fs8P8qnMU/5hxPwm7Zy8L9fuzYaD7SNi25afhgY5suG5uT5h/8FvhhYNWVJg+N4Tbb78jfPCDH5BjSWyayUtqGXO2otnRIWeWxgE1xMwAYCZULEMYG8klQCwx4IWcJgdSdXdHxz952ETPEyWAHJWqIdlDLW2jFWWljfV1tVvdBvAGosN+873/8ZZ/+oe/M4c61jA2APN7vHiAL712djIXHw1Y0iDlDz71u2Fma5yLk+7NZywN3/jRT8K8xUu5zQEchpHFfgEcM9gZqBnKwT+w/YWtYdeu3WZv9O14UvkkKgTYAPgHsMyBda88186dhzQl1FVG0uWNS8uNAPqW/Vo4vKck7B2dF64qvTu0LZoRvvK3t4SuI21h785t4ScPPBKK7/3f4bnaleHs6SXhhW1b5WuYL4QOBRxfGLeE4b6mTQ5SEVCIjaf6cXFRcfFjHscZ5M9qaBhZecby87/yla/8pYIcdzDgKRuDE1UBNJiDxpT/1m+++xMlZWWzamqqsu3tna0Ky+ltzeNNgS9evPzgo48+vJgpUDCuyGiadNi8dINaAcS92i83L3P82XLbvve97w1ZiW2Hikot7SrdHd/811AxvSKsPOv8MCxkMyvokn5nxQ8ny5DCKKdEHjjumdtrg4hNyXr7usPC9l2hPdMXql/31tB4wWXi0CKzS979oTeG6ZpOcmRVRnffSHhRQnbTcHkoq3lTaG4dDk8ONISGkvIwu/PWsOL3vhpq1KbpcxrD/CXLw5WvujKsev11YUFJa1h04RvCw488HP7Pv38nXHr+WYpfZv3SmkColHQa1LI2fo1zzj4bSZVpamoyYhwa7K8rKavYKEk0iiqAeUSgfeecu/b12D5veP3rP624Ho3JgI5TRj5j6uKb61OBvFxTbvwoR9paivYfOLhq8eIF17DkWlhoZVXloTVrzmp6UZ1l8eZNb3qzGUUYSMVyx5bI24c4HJYo3LlrV2hqbpKlHQ1JOy9dEWrPX2fF/uMX/lv4k994p/numRJibcP5iPBB5R/SvBOPH1Jm166doa21zbh/7+5ooM177Y1huOtxSaIDVt5/+tR/Cts2toT2pna7r2usC0zvZk2LxttP99wbto7MDgeGtdGkb2s4fPWnzYBrkcsYSXWk9WB49Javhub1D4bK5VeEzZs3mbqhD5/4/BdDRm1avXp1uErOrTPktzhr7VrZA3MCdhCqB0mFT4MjDatWLDv/vHPXXgPyAakHdF68sZBT/0mL8FMthTJYTat75JHH76yuqzu7aHQog2cP/StP2Ghz88Gn2zs697kUQL9h7R5q3veqN2r6VSbRS/oNGzca19bW1pn376t/81cylGolKiX+5ZQBSladbWccRoT3an4NYXzlO99nYCLyBwfE9YM23WS2gaphwYfpZvOBprCq6Ulx6FCY88pFVtZFv/edgI3w+595V+g/XBXks7a5/QXnrAsN84ZNbbzuFa8P9zzwXHjxUKfl+epf/Gl4fuMmTd/mmR3z+EM/CKtnVIZZ57xOZQ2rzqawb98+2RzbbNbTr/YMDmhm881vG8HaXgLVWScHlAN+B9TQOeecIzug8uZLX7nuVdhVEHR6lqLxWqw8h3VgYU9oKjhRCaD6czC0v7n52WxmVKu1GTPG8Ofv2b0rMzjQd/7sWdOvOWPF8qsb6uqyGDdIgrPPuWBvRUW5LcTQ8fPOPdfm1Oju1hbTINLNpWFa/YxQffYFdlCbewsJn3v5q40opBcN+XA8xFQqAmHgWKFj2ofRCXQ996QMtMYw/cooRQjDcMQSX95THw4PrQjzl5aGhXMXBSTFi9sGwuLFkVBeffnZoa5iNPzbP/99uO0HPyCr1fHg7f8WBrRcXbbkUs312wz57fL+Adg6GLsLFy6yfnz8D34/dEvl4XresWN7+NldPzW19eEPf9jSs2J51VVXh0tecdENsgVm+pqBRR79MyHxT3GTKQGqbrjhhjVf/NMv3yN5KCTEhREWRUAESAEwyqY3NODqHenu7uqvra5CehiwDOuc8dnP/ueQLY6iFx8BMDgokS7uhii0k2dM/JPPPh5+/T+8O1xyySUm+qfVN2iHUHuQrglN4kYWYR556Pbw2oVXhpVLtYuoH/MuhFtmzwh//hv/UwRQEv7u9z4b2maV24YSkN/ZVxHe9663hte+/u1hYEhtk+0xKmJpa9oT7n3sKRPnrQe2GPLPffVbbS2CBR+gWKoMwlsgxLOIhaG5Y+dOcwdjqAKXXX5ZuPLVV1ncvPkyFCWtsG+yJVlxvJaNpVNRjagHJADAtSRAoy67k2NCROCWJGVPBNBHw7fffnuTCIBWiqticSCeRRakAsRgy6Y93SKEEa3AZiqgcDoM0OnO3u6wI5mexRLiryM/HebXxMGxjz76WFi1cmWYL8NqUJ42ppRMswDsjQtklJ1V8mjOm15cjRTIG5gj5WeEjtGMpFBDePOb3xIWzJsdhnX/4oEWIyqWoxfMbwwzFywOSw8eCWnkY2T6cnBWK5YQ3Qot+EDw9B8CW7v27HDppZeh/0fl+sh09fSNCukZZgQtcpKBZCRRkTYUFMVpv7U9jfze7p59FjhJNsBkEUDSpnjCsSNmNqT7Sp+f8bwVaYBcXw8x/RKRDMh462KZVBzAIsqfipCwDVinZ4kYAwn/gdYVTLeS5uabv2OGFtJg5qy4RPvtb387fOY//5GmgN02zcL1CpFtf2Fz+LW5lWFwYX4p95aqiHy4btuWDeGA1g4GmmeGtu35GVi59gjAwW1N28LcJWtDd29/GJGaWak9h4/86Nmw+pLXm5RxQqtQ+jr5Ns6/4ALbewBCe/oHDdFRPY0yExAj2wQsU5RBWiLNYJqxGrnwntHt7Oh8JDXYUaymAk72cjIJYEhIjR4ZtcIb76KfhhHG6hgUPSC1ADBdcwqvluNnRJsm9CyGcVOX/PY4RpAcTAvJjzphoydc9a53/TqqxGYih5r3Z0ijLdvh59pLCLeBPDaCsFt4jeqtWpif9TY3yLkz8lRY3H6uGV53/PDHYfHihWGgcSBsu2Vb2HDThjDwrneHs2WpY0iuOf8yzd0rQ0lpudy2EsvFJaPv+ugfjWqTR0azjVFN3YroCzMPV38HkgUnjCLaDtIhJmXWtUSAufGLJDCjzUSaYh0OjB0iP6P+M0bMNP71W/96O8OWpCHxhFTAZNkANIRVqvpNmzbvQnfROeuARMEwKBpljp8xnQYXy7tp8XQ6Go0Zm/6xIocvgLwcIBzO4Uhfq0QjCAjBRazGaAxo3DTP1SDKHgFGRqMtkUskoigpihIJSYHRSp0QXEyv/CqT9gHe1sJr0pMP8LxcE+Z5OBfJpskWU6CIYDS2CUJAIpbJ81tULOZICMBUAX3TeNA/CABiqK6qOldqdK+Kx9igkAkRwGRIgDhaaolgAOqHAAA6MypEMyiOG5BIh3zA4oBbcvvhnn2CLhXoNP4BfE04axw8P2VHBAwbojwN7chobEZFeAO6zg71haER/CZ5GBmSM6Z0QDs0y00lIcAoK0pn4Ym9m/RF9cpeyWWk/YDXNZogk7yAt82vHamjIyJic72kcSa/RzH3ERXk9fTkLwTF9abCJqwC8iOaKvUUL00sydh6lvzONaMaPL8mnA5CBA4YhSyUDNrcfcgQYWpAg+kbRj2tn608iEGHtIWVSbmRGDjnxyUzMGLI97x+bu/P0+3wkOyEwT7JLHUhE0U46TR/UduZg8dc3o9YD4SHlMrPdmgDx3jg/abvXGeUl6MYokjWCXyTKXYPTDQeISRq1lXAeFWdVNhkEYD3eqi19eAd3gIfMKReyEiUFWnQxS0MAMzCmU763B3XLVzIE0CIZCOExCYgLUB6O/IMmSA+imFHDtwvh5QUQJphrAj7qamKu4dGBjQzEQGASAPVJzekpnwy9hQ2KJ0+KtXB2gTIo3zawsG9H6Qb1FSRA58Gq5mckRY+Do5QGw9VVlwU+5QZjGf0f+xbHi0uCWPjxvzmuWhM8Mnd5Gs6uXzHTH3TTTfdVRg5mhWlpxCGPQD3MJgAg+nXDCjSIEcIstDjwKNfx3IYnMnhA+z1OvLh/jRki+Jiz0g2uh6KZNCxhgDyDIR4WRyhGM7kSAjD4uWLcCJwpHM2QkAMAagCHfSFlUjf/FKpTSNMAwHaZmcnON2MloyPBtTfOJDWY0nF46Q6waBxazjBvIXJEEvmC8CKdzCqHx5ramAAOsI9HUhkgFEJIB+pACFwz+GzgXR68qSRT5kMMOUrexhMmoHu5+gdLA6DwyzCZEKfjsE+NpYIiUM8lRRLlp0eBjQ1y4EiuMWQjaoJQox2B2farLmJESLGHABhsOOHg3YzHtiRaeQXJcv9SIhi+RpOBHp7ejYn6ZLWnkiu46cZi5njpz2h2ERHjUmbzWCIjaU1RxyDCJEwaDhvMKwYLAgA8HRueLkYJZy8Dp5OuDdgaZhjYBSDrcKQMJyIoVHNRoaElBERhSOeTDh9iqWPS0fzbS3GYgckEbDeIQI1OIaprdE+iGKe4ouk6gBWNtHxwDB6PmlYCWKf9WWBTEs7n+jPrl178AE48pOenmju8dNNNgHExlnH3VJmsKlGOlMDDzAWRUKeI40wuLwQEK8YegwyBALy84TATCKK4HQ+0mFtmxRSXvIDVE3tlAnyhUlrB2rFrXlpZaXAKEsRAJkF6bbGgNguwmmXnymfe85OoLi3R9U/mx0lRYN874tWfgLGMoABaGcnMruLPz/84R33pG5JOGE7YDIJINcYPYA5KnsP/8a4YKJTiGIAjPPN0IpJmRc7GAGw4ylBPPfk8QFOI8XLJA3IZ3SEWi9KZ8R26jZ1mSaCUXtkLxUJckGGEES9dlax1O1twcGD1LJ4ZYWYmVloSdQKGlVfHUYkIWibQ5EcQ8XSMUVFCqd8gU+jPY2fv/CFL+CipDAYLRbukad4niwC8KGlYQPDA1Kw2biGT7scYek2Mkg5LhEXgviRAjXh6UlHGSA5LSkgHgA9bKLZM+icRj75QD6Idu4nKfeF4D4IIy6wakdcLXSCK0R8vMceoY2ad+j5RoiwOEVxIFU+HSE/TZQsGuVRYNO/lPQpbNtU3Odrn3jpudHUhob7q6qrrqDI6OuOhTMNdGYokq4FmSAn6lc4bOzgFDYJhCO9LX0qEgI5FpgqUCTqwjySScI08vMqgKkZXB6NS4gOpLMi59ecnePTiPf6hzR9HdLiFM4hlaLVQzjcY8c/jyIVqDcF9Im2IH18KpjYV24DpFKf+uXYWk+9nHTOoW0vbLmDAQeh6cHNJWK6JBC/5IJy0kAhuEE5xgVx2PEQnh7IyPmkT+qD43WdRj51MMAMNgi1AQdpuuZw5HPNVI57/Pm4swv7B4Gyz88dOpRthmHC9Yj/8cCIbryIY4fFDh07/oRjJpsAjDp/fOedP6cFzn3O2W4E+j1pXKy6mCZsPADp7jAi3onAz57H70eFaK6jhJEK0LV7FiFKP4rR3yA/ITpDsJDMmSMiPvrjQTxuaUe8E7d580C+lotBPtJNjmvVpzqRWgmh+0zA2zosvX+SMKk+AOqebAKgTPMFcJEGONM8gT6tUqStiSR6EkKAU0EYYt4RSRmEwV1GJAk3E55Owz3gaSgjXuetceLhduf4UiE06mYhOOFukF4q7jZCEFH4GUJIEy5lOQxo+tijN5iwZw/kA3J1SfxnTQVEH0Jk2hh+ctM/ytNeA3OxW9EETBKcNAmeQL22LGyWuMgL14xLAtzBYTRNxCICuENIEbZMGoDUTDI5h4ux5wtFdsYMASHTDMc44N4u0oJ4g+SclZWtOYcWXfL0bgilXjlvIL6I4Lx+Jz9E6xKqEPneJvrGRpCBvgGlpytjketG5TB1iRJoGb9M+/BLylwItC8Hys4UMa3KiNsbn7V0/Z/KkMt5ShdTQQDWENCoi2IGKI8Qbz+DkPTBLOc4LGTELoDbfQB8oInLlZPkVbIxYPEgHYTG8c5ttHCHjhlUQjoIB0CwIzqqhTziC5HulTlB004e/MSBZbMOqjb0yvZJiXd6KlLSgdSC2GJJ9I0+eF/97PVYW5ObqfABUPRkE0AOJeLktI3nfVKNGo6UEDCEKzY/AdBAIQ00kI5IR+wIXERaIx+lcE4nMEG85yGoRO5WONJEvrKO2ipbfs3drXnSOoCEuPwcQxzZHk+d6HLc1OwfiE1AUqh8iFkJHZGaC3u2ONHJP+1l4SPyOLITKA1sixsPpsIHQD35Fo5X68mF0XcANsfH2j2SLa1mkFn8KQSMLxZi4Bzcr5xBOyNljhdlAHGEG94VDmEYJCcXzxh4rNfjD/B1e+dwm1szG5G3zaZVKswRVNgmvx8P6agmqveFKgxS6ndDUI227JSdRryXiUga0ppDkbavkS+6qnKx1rb8nWgffZKC8VzsqehTvpxMAqAROd2k9+s8NXfu7CtYHXNEeSuLSiMnD8fnRnPWOWIbhKdFn4sRCgaBZsmDxBwxCCuJ+MBKJ68d2lQpcsjpdkeWoseAF0MgcSDaCYRrAFGPz4KDbV8OGYgbSCGf20H1uaTAFkD/2/OeidjPplQEeY4HKWI4mpOOl/EE4iabALxK8wUYAWgg8M1rbdX83MkOb08nBCFSEbuRrbMpb6BP20hs0iCFLeOi1L2ngePRv1j3IDKbEIeJZ1WRzpK2L2KDItfB3SDdDxDPdA5woy6FlJhVv875JQVE5gmQAKmHfi3YpE1CR56Oc5oJkHApyKnZVNgpX04FARiV4gu44vIrNOKxvUWjevonE5X/8IAQXorYTtSAVIR4Fd43dZD22KV7VkgEOb2fYNWQwwxBeHT7wFy/Vkh+3JyzCSad3/u1b+xM+w6cWKKhGDFMmKsAlxqUmVWfB6XWSsZd5o1ePzaryGiQDZCySRJ7wMuCyOgzhChg8FwCRG4hdIJwDFo95VIpj7XOaj39M+OZZ9dvYROkIwMCGNEmjWEtD0MEtnafmtdnUwOWJoK0JPDrAq4w+Y1UcCLJcZCL6VSXQDhtgm5AorfPEe7I9ixOICDGEK4yUVfUh8NXz0CaGiA+jXSRgcg6Lv1SVrZI202Ky0KlgvB02tSzjPcj8EwkUos1BzmfuFaZTgDaCv7AzFkz/4OK4HEjtjhNGgFMhQRQ+8KYLeIEmD9gEBEbxRnEjmSD680E9PVaEgsY4DQRxFDSx/y5EWCgQEACMY82eyTr79q37VFg266N05Nr47AUEZIgjXDuc4jXNcRhGziw+FVGVshzsE2faooTX5nZBvn6h7T/vyLDzvkyy+IrgW4Q2tNAqfK83Mcef+x+XTv3e/CknPOtn5TirJB8j1NljqrjRSUReR5sQl86Gs5n/filwHffMrWDO+xIId9mEUkhIA1IE0fkepFbgnziuYb20gfhnt+JIUqKKPJzZZI3IZ44X8n3oSTVLmwCjgrt/s3wWLzAZylcw/2+DZz7Qul209htdpOKs0ktjManYUQWHyqZAcMGyAxJx8tAKx7NC57ihM3T4j9XRmLA+b0hXsh3BMJpzm1p5OfTJ90ToqhmRBzpeZmA+qH5o2fJsZkRhERUmvs9kasMdcz6ZlJJksHbg/QCHPHxLv+bXujiWYA0QHBO3B7OI3e6hrGQAvnGeoIJnCebAJwFaKie0B7el25bJtkzx0JJ2tmCNZCGnOiHegQ+sJ6m8F4jphK86khwzuSGLOdGcStIHyoQs0OJ8UX52YQY6ADhEAJ5OI8BVWCEndTrUgHke3/YD+hHOm+a+9PhPtNIh3E9FcvAXsdkEwDl5ii0vVNvX0gAhGMdl6S4WpKPyb0y5JFH8kL971zriPezrCblTTADESQc6Y4cxDZhcD7LwKRPgyE5CfBrJwaaZnIqIQhNKi0ldVMmDif3UXBPODuJQSLvBM4UzHchBADuT6x6u/cff0G1q55CNeDpJvs8FQTgbRx6+IF7b/UbRwr37IhFCjg4x/i9LZxwkxALg+tEYGIcUQ5SSSJu9sMC9KPkBj5Fs8EE+SICVugAED4u6I3gTANz8Yl0QAp4eYX5aB+Q3kt4VJpsLNE3ux4Vr/7gu3Cbg3gvN5V2XPsqFX/Sl3ksnHTWY2agTOY+dVpKbXxu/YZn7Hk4BbC4wmtSAB8IntdLNgBbOOt/QI5gEjVgYQnSLQEDhnBm6ki4uM+BQYwGX7TamaoBEALLsezSPRaU6LH7QXvMMZEASUK5s3QVPY22eoezKcU+vgqIFHAoTpxHTO+AIhFBcVn8jE2813SSKZ/sgMJ3AhCPLYDnUS/WbNQtU0CmEJNKBJEdVOoUwLA486jHcgr1H8gvKokLLNaGxKqO13nV4BwPshEMRhcyCBMhIf5MsCEEIBGcCNL9AvkAhuh4RPBSyC+W/8JXFb1cd0lDh2nk5+IT5HNfZG/SiTFMAccDa7vsC7ifo7+v/4FUujx1pQIncjmVBBB9AcYRYztb2PlRYVMoMe6kMy4duM4hnpuUBHDEEwzkiELXaTFqkSf481KcH3cMY+1HiVOUOK60C1qzHKRRJDDibTeQGoV6KxHhjerJYB5UcZt/RGqmcBzSzYT7gan0AVD+VBHAUWIK8Z9NiUc6zyA4sHlSAUIyc3xEt0YLupHYPh7iPT/nNBHYvQISKZxOZtdIgTQgETwsolGDo7qZuLA/xQy/Y0hfvRdJDYZYIQTPHUuHEAbVIQxGiMGfCPK62RB6PEj5ANBbUEV+0I6X8QTjIpmdYOJTSGaPiyOuczo9KSSNfC8XBCACkZoMOMA9iPXD03J28yCWLwscYnkJKCmYCXhyRz73IJ6DWYM9CJS0xdMea6MI8RABqoaz718g3Ka26pg9hpZQZXqTqH+EqrAPKR+AFc/PZMJUEICzABSrd/T07vU5+hCsnQLuh8TxDgwS3nV4BcRDBE4IniZ9hihyUFA24RAdlrtPqY6F/Iy435FuiFdes1VS9og7f0y8q9zjEUGuTZpCOkEQ5gYufS4ci5yhaASf79hU+gBo01iMEDI5kGPF9s7O/At3krKd+5kRjCb7/7xa1vALke6EUBjuefzMjMBmBR6QnHNLwuJqJwaimIpy6IMmJupt2iWuB/mkY/8BYdRrO3cStTEe8tPcTtkIEJuyJnYC9gGbY+w5AUkCHiSFyH/RMNUtGHry8UduS3la9eWOqPP8nB4Ad6ESxqCb+C8YpPGIwdWDI8YH1u8pL07juMojPt6JM7VIIyvNEGbphCwnNj+z28ihUJ15OGdDvJDP86V2yD5AogGMQ3woVSHq33jgxt84cZOq+7388Vvhsad+plxbFpYvYMFzz61/xnbrCFPuTMFLx966tARgKhcXXVhkiXP5dBPy+jGvNmJ8HBvEtOFJZefTauClc12E60KiON/tYi3Rjg6rXU5oilcjhb08wqnDt5oVpQzZdNu4Ts9YcgSjssgLIfF+QzyBbDX3l0dArLYYxIMmevGUHjjRM5WxPerDIb0TSG+zmhofAG2eagkAF5svIIcAVcqDFA6sD/iTsdF1G3W/x6fP40mEGF9AECl7IM2tOUJAt+swZGoHEsgx1VCAdCOEpAEgNIfUJMymemmCUL9YbTBIkGgSJNWeJKsRpU8bPQw6yV9n9Jm8jif8XuecWk2FTfjy+HOQCRd/9L4AisQAigZa7C+IRXTmeq80MSz22YmHLdVA0ThLx5Zfg850Cz8C5dtSrZAaJQNnSZ0UY3PNrqSjICGEtL1AGjyAKU0wJptxv/plggS/AI9HU1eC/GLboZzvYVo9jSnIskS+/MGdP7xHt84tBE66GphKAsg1lsfF9ai0mIFVwNhdBgAkOXIJVbSBWe4avLQYJ2I8xMcc+V+Qn+P0FPLzKY4uFyIwbkxJAIjFgY898OKHIbZxpx/iIAE2DagR0v0ZPykvInIUbRIGg4BQVzVcQ7C6T4dZouTHH7HTrRNBOnpSrqeKABgBB3tcvKQ8bo1KD6wncESP5fo0AiJlpInF86bPiGieJsoBFJUglcGG470uT3Os+zRSRpldYMqpDIjA7YW00eqPhHm5R51NOkR1Q9luC3k68y76TXKeah8A1USyLKh4Em+NcvX0zLNuCY9XNjjicGRwBtlpJIyXjzDSevr0O3yM64hLCGAkeTZhvDK9DM4OHub3ZpiJAEyKia7MKOReyKRvHNTFIeGfa1euzESigHjKGA8K2zbVPgDaMJUE4KNpr5Efr8Me5uMBrhiw43G6D14aQen00iw2wOjvjG01RyJEqYAoBwGFA+3tOJUzdYPw3AERJH0Y0y7j+oh41J9LkcI6IbRxICXWxomdQNBUEkCuWf/2zX/+p9yNLgrFH3Hj93usziQdg5rjKr8X4aD7bcAToysk7+ALGS0/+2HpI12eCBF4mlx7aaQOVInPCjAKsTk4aJepISj5BIDy3W44RvLUQ3THSDHB4KkmAFTAuI+LR07MU3vhmDmHO7ILuYl+54gh2X3j0sHHZDh59GhY+xE5gPTXu0CAH56n8Ew7QVJh2Z4u3a70NfHkyRGPZxjnbG7gouTJmCRezx0WvhLuxKhqnPKPFzTVBGB1p59rY0AQlw4+sMeSAKRLD6wTRg75ii8Um6gBgHf+ARlt8uDQN5jivUsJuzv+j3Fowvmk9PYW5ioM93vvl9+TL+2b8HLQCq4W6A9fX/U4sqSuJ/XydBBAnMKkplWFOhAuRAKMN0MA0djgAMhnsagIjtf6O44c98y5KeYLT6Rnh8FRbwgn4hjTadoBpKWCv+4FBDoS/WyJUz+exuMhdr9OJTuhy3FeC39C+U420VRNA70dOeMl9+o4odD5nwEqkq6GM+GUQjVAISCf160ZESTLqBbOViKB7SPQGa5xi98ijvPDtI66HSA8kE5Yercy8WkdTbwTaUR2vgwvizN9ied44Qs/1GHl206m6AOIKfO/LgUKHgfPJ5jkq6kkANdZYMoeFy8qrahOt9+mUFoPSHNJTtyLw+09e4qPjpWYs3DrtH8LgFh7+7bOJmJFTTnqi1m14qemjEZ/RBIU1UeyJJ1uh8UnmHRkc2srekglxzIJDaFMAYVUzTTcbiHKkG7yPS9BUrvESHIi8EurAnIN53Fxf4oGbi3U2+lRMFEvrrflM0UUa8nYD7ZXcbDhkoPrYwGvfC3y17JyTpCP5w/Opg0QIYc9TcwSrQ4PA8nxJRJIBzg7ci3LxSDWEK/0bHrlPn4MgrLiUi9hlk4N5GwSJCf/YqstjUnCsdLkdPgAaMHYWmObpuJ3qKvzyAP2sgiVnhbVY0VxgkxxvwP764c1oA6+cwbLmYN7CIGFGfemFYl4sklREIG++Wnv/+VFFLh0nXtBNMjEqRORCiLjtYUbomOYI9+RytkQXyjukRJJGGkAP9t1EmcRBT/HYIr8YBSkn4zb00EApqzZ2+YSIN1w4z6NGIPkiLF4IdYB7ndwpDsheHiJJuQjvLwhtc/Q4/zsO3ptedYQGOvkDWEvBbQvjUjamj6Oyp+yMTwOKQLQ52MBRJAwSNoHcGwxd6yCTjD82C05wQJOMNm4voDxKD4n/lWw6/7BRGyC9DTiubY59DEakZXY5wByZ0kN53wX1cSnkTvePWE280gWg3K2ikXkf3AMmQ0gyWV9SbCO9ItEJGJXGNemhkySxYddvRS9Fn5KXgnn5afPedmaDp3866MeF6eKtCpIV6l3iegvSr408tNp/DpNEB6WPg+lPhRlKkIkb+IcbkxNTfXyjjFgSExCmGKyfwCZlNEXQSyrNnZQDlvYgLRfgrSiTCE6FholhWwZQ3Yc8kKCowyXDFP1SjjqKITTJQGs3rhGHwcF5I8nAXiZpG0M1WAlfpvCNo97P4jRmII04o37tQ09I1Hvbw1LJdU2LckaIS26c8cuUYNYfAH6Lpy+Ut4vru02e2NUbwkbETHYOwFQP0n9frbymR3A6dgZ2BMYh2nuP4YqGOeVcOnmTur16ZAAeSMmxXH04lgSAHGZthf8IQv0vAMIL9EAOuLT156GM8gv0hYsnttzgnNuh7NpAwtHvttHWxeMs8nr8/6YTgQ2MqiPSmdCRXmvnRm8UXvUnamqpIHKAtggyosj3EYwbk+mmlynud8NV8uY/BT4AMZSdjrhJFxPtQRwjJkvoK9/YN9LtdnfJ2zSQgPqe+zJNyhj0Y6E2wgD8YATgt3ox3U+yMdZBPJB/KCIyN5ACsernDTyhTHPftQ5qo9oT0AEwJBmH7y0Ii2pTL0kyM9xPkgv2EhihCApB9h1qu6069wSTOHPVBMATc9RsD8u7r5w58jC/qW5H/HBweNXaRWSRrxfU05a9DvyLVzIjvkjt/tqnnM+yI/IY1s63Aunxikh+nzUxPfY4RoW8gexDZKyTQDA6ZY3crpPc/1MW9LeRfwPx4D81OcYCSYjeGyPJqPEY5cxtG3rpkfTyB03aXF8eljCeUy0+MzevEXgkBiQx2SRBgDcP5JIAud87v1xbXQ8iKZuF9OWkR8QD4ealLBbQzzId4AwzMgTYqVPcuXyjSFmqGz1HgL7xAtss4hnntg5rz4nVs4xc58uAjBqTu1xyzXIpUBxVos7Be8QStSmIc4z8Po115suSRz5EAbgZ574GZSYBvlpxINQB66dO3nxYzrO01APO34ohy1hIL5fuj+3upgQX9TvlH10Oe7WQFrwVnE3Fq3opCLGgg9dC/hxCeBqNEk1uafTRQC0elxfwEl3RywHEWAYAiDbr9NlEYbaSCM/IjsiP33NAg+HzwJc1fiZcl16mNGocgshK+IBEBy+eXUMMUldDGnWMOSUUFhAcj/Vj4MXVns6ZgFeZ+418gysi1h9OMbjc2feIQz4Bx3HzAoSt/AwIy1uchgUUkbFnfaRPwXyenhRhnE+iIhOn6MRZ9xtBBCtf1/to1yXDOkwr48d6sUpF3UuHE2gdiENotQR0vEdYBeoSXwcKg3qhqLyfDjVj4On6+Y6X3NhzOTe53WZBhtg4I8FJeVjB+lY6QzJivQz6RDRgJ7rzSFfQ6zrPLFEUR3TQYggx7kVpLm6IE881FaFR+6P5djHICgz4XxNJ0ys0y/EuCx5O/s29ZeyfZwIcJlbB6IKmHL8THkFSWf8NMC+gMLBGIeRZEgNhfLS6GdDuCIFAD/HG4ntxBPHvYt1bAQnBEOmkOfcDPId0tce5mcnBM/vG0PSbwvzRSqmktYniEAqCnvQHlRVOGqFMpzAXPJ5PX6mDOB0bAX3OjnnRyMdOrnXzupm1HR3du6DS+xgYOwRwlhhSWpKVIS1rQ9O+ls00kTAM72AEYO4W6Nrh1vf+kRjbsBt4FEBqAyHxFr322OdHWkeb9JAItwR7+H+fEC0E6LUsH0BUks5ohTheRPSEsjLcAlwupaBvd7TQQDU5fJ3qGn//nu8cj+bNy1Zqy/RA5QOfPABgAjsJZGJ8cVAgnw+AcsAIxwcWba0SzaF26By1hEJjpW2eG1TtoQQFK00cSgcYV4e9fvDpBm95oXHyR3S14TFvCI+EQlIji/Hiu5gy4NrmMpODPJq88TSn1Kq00UAucZt3rjedZwhhQgGJa0G7E2iBS9RYkoYN3GI+4Us5bBBZqD9OlpZRg0JMuD86NgZkUQBotMGkR2lEGEgzm2EtOg38a944/BEBZHeXiaREAJxTixGRGpbmvt9RmBcb88WukCk3/gfSH/CREH1kwqnkwBQAcOJn1sIyBN4RCKfaNOn2lg0Kc03K+7eERIR68ItXKWFOEOK0KbwqF8j9yUiV8jCDWvf7dEgO/KHkjpH3MGgBjE7AKwNEFYKGelrS6QfpIotKukM8k0SofSJs3u1XeVH7rdgKxPCg0COh+y+3r59ynHafAC0LvY+tvO0/uY4UEgFQIBNkaUK9FUVvVZeulatK8pt8IAoYmL7xGyxdgIlXCgTXRycFKRBBijfda5N1+SwH9T7+IaG9XWvxNZwu4C6QZBzP/kd+X7Of+7HkR0RTVqzRXSG6FSt7hPVBLEkBJX7uggZxgEIQ6+F5+vgDq42/X5KzqebAI7aF8DUKqJMA1ikN2ln+2U563Vqem3LwBC6FFevvITaPezfFs5qv5e+zm4zBQxFpAM45SNMDsXKP+REQaDYFQ2O14G3eoEk7AWQDtIcQJiLfrcLLM64l7R5cR03gEJ84m7m90qjag3pUaKggiCCfLu8HjvrYRBJFEXH+HG2gufF5JiMk3eT783klTleSdRDL6t01G/avHUXCIBzshqxrLDnXEjm4RGIQO8P0kf1hrTuDkAEaRjR0uxYKJMcSKSARcRXvrjLNZ02owdFS8ywlKcQV7GOvARIpwSZiTRIqQ1SuJ5nlxAEw7Yyn4WMRX5CBNZXNpCqv7wlREepXh3Pm0Jtr4AqqqurW6lZQIuK79JBh8d2WgGTDccgzcmuRriJgB0wcOTwEd/yNKYiBs4GT7yKPZCRLuC1KkD6DV+O/KKh+CLSzDAfd+alTohfupQnhHhvRdgPyOcLYrxswjndY+H+SJj5cwyjXTHMNoLqGm6PBUZbA+Tn2m/lRPFv/RHyIfZYRn4rmNfr54Jl4NOiAk4XAdBH79DQPffc9d99QLzzZuFnS/Rpv8ghqIM0EUAITgT4CEolEUayFZY92fYnpDJhww0rjhQNiLOsTkcMZzg/Pk0UuT4WEAkvnS53jZFo4h+VE3U7Zw4zSFEt4n7SQyzGzbqO/UvKHUfOHmcGYMayj8tUn8dp2pRVSV3Mxer0PaH659Y/v4VBkugcKS3VR9bEvprZy1aKW70RySMy2IZZOZMhOMTLnKQSBuRX54NLrhKcKOJ9nOrZdEEVgRS9jFFONm3b4NNkgqx90BQj0emR0LinL14d/QtiAcorhMK4vMRgH5GkAlmlPvISpDiUlZbmxD/GHzbAQH//5mnTpl2l1N3JMeXin76cbiOQOpF0/VjpfELNH8UC+XK0jEi3Qgmy1GXEkTqBYnEhIxIVgn6xDcp07o82AoQwmDwSXqwisAdAhgw+fagE8RwJQY5ovc0VIzBvoVNFoTpIqh1zojxP54gmAdcYejpZuSCea3AP4u2aNujCPw7lBWME83mZr33ta19Owuj2WOr0xFNwpo2nC2w8VJkZgk8/89yDtbU18yXaTQJki7OjIgZrD2Mm+tDOrUE9+YX1n5cGw7LgMRAdkApp4FWsPBDKE0FsBbPvBSbzDG3c0KeJhrRYF8cXcQ04Uj2csPG4nXDSOPGkkU4chOBA2RACYNyvOPL5K+H862CmMhSXGICHlZzXwtOpsR1TwFRAvsVTUfrRZTLifDWp7urXve7ir3/t698rr6gY0TsEM2U67PGsRMwiFk38Y6HbIouWVQeTT42qAOIc0kSRJghECa9ldUQgdQyYto3TczHjS4JN+bAJhGDJA4lvCVHNBECyg0/7fHrHLAfIEYAsf/L7DIC4yoqKxTpBAHQSCj+B1ijVBOF0qwAXbUMP3nffRpZKAbhfo8MA5ebEiEvbzZsQhHFeYh+Qp0j2HTYCwPCySwcY0Ze5sBeyevsn5zKrUUu0xCdIKvIpXXJOb9jMEYmVNvYn0k+06D1GTRJEBDvCCbH2J20H8WbkJvdqPMjNUczdP/vZl3QfO3OaOJ82ArlGxNsp/6U+LLVyHfWPPPL49xsbZ59TWVUzUqJ91OkPJtISBhEd6UulPl/nCWHfBZRWEeQB0tLB7qU2+NafHjG1Mgmj3GPBsYgAjsdbaXq9iM+gR3A7RtaqqJiP38bv34L40fiiIrN10mpO/gAZvpFwGhoamP/D/RiAk/phyKSJxzydbglAQ9BtsP7AFVdc+u6Nm7Y8Uzo8lCnJRvPOuQjkA6ZLk2viIAZ9isTikAojiVU/pLEH0nrcAvhhJqGHQm2vvl7HShBDn9E7YsdNH/FCsqPKM5ylkG+Jkh8QbpKMfHpJkcxa60SOQNKJk2u9CuZZjGLd+riMk2rqglJdnbpKximZgSnWO3NLr7jy6rPnzJmzkPfnYgQyiI588kULG0s6GlF2rzR+tkFXHNIjhpFWnJ7M39m3j+OHdMSblJEqiFwc0xUrAk61TaE4bKw68XumWC8AtXDJC01PFE7YOP2xINJQh/YhOu6pU32KxGx9SzqHtlNkmDlz5htEhB0qACLgOLZoslom9yeyzeSWeSKlGbWL8DvfccO1H+vq7Czq1zYqF/UUALdzFAJhSIUxB0aVws2IFIfjarVDL2DOlpSb5W3TLzmazN4oKFSzBWYgEGA8VJYRBNhLgPLhas+qKUo/L7tOH8QNskghMOkkCaUZhnwQMZs+J5fRdQ7BX/3br/+9ykXkg3i3cHPxlDPVEElzqms5unzqxRbgjSE1Qlbjpq0vPDC9flpRmZxCINkYBERoEJ0QnEBcf493nxbpTCVlKWpKGREgS0A+Jd80fnSjCHFkcQ3yvLx0OHHyV/RLxtt3YLWJVR5MFoeVP3Xtoh/igY64NztAwqSjq7t35RkrzoUJlM2dPzT0tBLAL1IFMF5ARuJ/9Lnn19/z2te85p0lpaW4g4VzcX9iqGnsxkgHRgjkm4FoRWjgpecRvRZHfBIu6sHS1VaMyL0ihjFED2LJxSFk57KBeMI8nuJAbi6t1p+5huNpK2eMVK49TdxHmEnZGeqIZEu/dkYuX7pknTaPgnjEP9xP3TkJo+vTAmMG47TUmK+Eun1GUCX3cI0GcLpWCh+ePr1hNC0J8lnilUsABhyAGLTfPoc8IS13HXPIDizg/DRHg1XndNKn40C6l8GZj0qeDPCdBMAlRPuRw03rLrrgLYODg0cU3KMDIkAFnHbuV525pXiuTzcwsDg8oP5uRKH0euvqVWdcsmXz1uckIvVENh+fHjP+R92nG42Rlr736zTyQS4HSBcB2eHI9zjygXhHvn1JVIjXx5Bzup08L3XIz1HU19dfRL7unp7MJz7x8d99OSGffo47YEScJvD6x0gCDWyF8FP55DPPP9g4Z5Zc/nruJpkzYw/A8Q5OIGkpIEQa1RQi3vOA/DTSPRy9Pjg8EuejCnRu97SeTuHebg8acxZh5axXjNsDTU333Xj92z4jVdZTwPlYjIiUkxMrY2qb2M1xOzKxok84t7fBiQDDqlIqocwJ4edPPPXvM2fNmldZUc6Gipy3kBqcGMZTARAAXC29jJ5FR9c4Mgl3cE7n3pHOtacF4UzbefaPsAAAQABJREFUZCLkxJE+FNkzOGJGbC4dz/w59A/2hs3Pb7r1o7/zoT8D8WpDb2LwucXPGfiFIZ/KffC5/kWCtwMiwDnFwaKRNs3kCeGLX/rKe65529veX1tTnSlnN02yeKR0Bs753Dj3g2g4njAQ6oh/KW5XWr1sLI9wuNoJgrJANlNLRzpu6b6h/tDWfHjz17/xt5+57+67m1OIB9noe0c+SP+F6HzVOwZ84McE/gJvaA/iE8sJIsBljEQYQwi6D9dce+15H/jAhz+9cNGixorycs0kzBGUAdkaeDunEeaIH4/bPV0a6S7GPY4608hm/4F9f1hfG9+2fZdxOmlAOueE49OIRzy4yH9ZIJ92vtwIIN2mNCFADBAC5xwxkFj4ruQMICGWL19xxfyFC1fBnZpe6slxpl7jg3N4IbILEU3u4d5ebWToC3v2Hbj1Jz+586e33Xrr017qOEhnYQfkO9JfdojPtd0vXoZnR1whIUAELiGMGGi7ODXuD9N1miiIA1511VWz6+Jn2MPiRcsWETZz1sxpnA8dPMSULOzavX035/aOjiZEONeFkEY2cdLrIBoYD+mEu7jnOmdDcPNyAB/kl0NbjtWGdBvTxEB6iCFNEISZFY/twE0a0kTi4RCLI9XDOCPC0/cpRBMMsuFqAASnOd3DOL9sRD2NGQ/Sgzte/MstLN1eJwbaCBEAfvbrtKczHWeJ9eNTPl+L93A/p5FMmN8XntOWvE8vXnbc7p1Kn9MDmg7/ZbkubD9EARwP8eMRQsyVR7Df+9kRzn0a2dz/UiGcBqehcADTcb+s18frkxPIqfTNEU3eXwruPpFOHm+wTiT/L3Oawr7/P4PUX2ak/KrtvxqBX43A6RyBQjF4OuueaF3jtf1XYvwkR3UiRtFJVjVpyUE8R9Hjjz/+Sp1ZP+DwaeF4hKHoUwKvy8+nVMjLOdNkDtZU9TPdRkcyU7msllrb5ca1epNn6y7QDVM2AifihPE6vT7q8OnfRMqlnJcVvFwlgHMcZ9roXF72wgsv/N4zzzzTvWPHjnY2jLAczCG//6qOjo7uTRs23KD0eAHJgz/Ay9LlCUGuzub9+78s72F/Z1dXv6TNxcpNebSHNP9PwMupI+m2OOdlNfDn1tRO+5vqqorzeT17Z0eHvYRx1erVY7aPgw3fLMJZ7yC4s7FxLsRwMhLBkV9GvRdeeOFD2smTQ7Re4XIqZebyvxwv0oP+i2qftyGHdHH579TW1Pw5S7FsrOQM8nkJY0dHpxHA2rVrxyDcG4806O7qsm3hbBPv7+39s9lz535B8WlC8OSFZ29DuepsTyOfhJTNxpOG+mksIrGVbaKqhmJ/oUCHf1EA4jloQwkitqu7e9+RI0e6p02ryyGf5wdB/qC2Vh1p79SOHT3lo/ve3p7Q090VNm54Pnz/e98L7/mt3wzrXnlu+PrX/y7ce9+94cWmptDW1sbnYD7V09vb/d3vfne+6nHV4CqlRMT2h9SfHMSPQb6rGHtQVQTAvsO21tb2lKpxovH+qIhfHqDRpxu8TgauTAN57cLFi7/F5hsbZCEXJLMmn+Z8ba4MTz35ZCgrLwv33n13uPbt14fmAwfCjp07wozp08OuXbvDhg3PWV+uve56bIKwdOlSPXY9LZSXl4fu7u5QVVUV6uvq/qz5UNuqbCZT297VfmV1dbUUe/G9ZJw+o/7xkvKKTx5rc6kVrp8hfT/In0l86OGHl11//fUHCNbhhqInfdm7jx0Z3uCpOFsd4sAlGqidqsA4Rhx/4eozz3yIzZ5uyUMADO6Q9tiLa7WuH8fvR3feGS697PLw/e/fGkr1do1VK1fyZhET9eUiiEMtLaG1tTW8sO2F8NzG9eHsM9cGPXAZDh5qDrNmzrY+XXLJK8Orr7o6PPbE0zcvnDf3hr179yh/Z1i8ZGlo7+q9hURnrFh+dWV5aZ1lSP1ohmHEqEfZc8hPRfNa+vaDBw7cMjDQ9w979+49kCIIT/ayJYzjrYx540/17MRVJOQvuuySSz6kgj6nI4u1nhWHAojY9BnO8h05cPWCBQvCijNWhoMHD4bzzjvf7tmDoTKMAEB+TXVN2L9/f+js7Agz6+u1iziWvWrVantjd0tLm9XBz8L585byHqEacf5KEVKvCG3GzDmvkMp4AuQXfpsYO+LRxx4LVQ22dySsXbHSytITh3oJ3ag99CGKrps5c/p7Ozq7z1m2rPzZjes3LpndOLu+p3dgZnG2uKqmuHT/kZ7O9fub9//lunXrnlEBSAsgTRguLU6rM8uRFJszub+UbWJe4rdb4pdNnjZ/F/JafZu377uE+zdteCbUVNWGytpppgZoDnv52Iq1c8d24/qZM2boeb+S0NPTHfbs3hMe//kT4fLLLzURf0Aq4Zbvfy9cdO75QYZfQLzz6nakxtq1Z4cLL7iA/fmWtrunNyxbuiS0t7fbswY11VXS7zRXDoRE8kCMX/zTL4XXvvY1oWHGzPBi83C47Z7d4aO/eWZYMn+OGaiWQT99fb3W1tjegX/ycGHztaUlxfO1HdzaMVpUMlxVUTZQVVHxhIzaRx58+OFvSGLsU/pfCDFMlQTIIV/brX40ODBIB8slCaYtW7bis3o0aqS8XHM1ARIAXQ80zl8SmvbtNALo7+sRwkvZ3qXHrgZzIn9YKqJMOp3t29VC2tq1a4LEbmCXsGYOobS8Qa8Nyr9CJvkES6iXZNB8PvdgSak2kQK8sAnRjs2AYQnSAdr0pS//1zBQuiL81T89Hs6/8jILL62ZHf7+1tYw0LkxfPZ3Lgqanlp4jwjKQT17r1wGt+mtZa39/QOjvd1x11h7+5HQ3NxcLEO3Qkbt5bPmLpy55ozV65oOHDwPKdHc3vKPy5cv/2uVg7/BCYIGTZlUmAoJ4GUih8uFnHY9N7dF++9W7tvX9NSmzVueV3g4/7xzbtR0qsKRj+jF+NNMwM6dnZ1h09MPhLlL1pLcQG8TM1E/ffqMsGfPbh60tPDpM6aHTZs2Gbc/+uhjYd7cOWHBokVGEJUSPBVlo2Hl6vNNImiTqD3HB2HMmT3bEE0aJBIEgP3B1PNf/uWfw/3PZcNAX4eIqjYcadkdKqvrw8Izr4yNSX5L+zaFT33kLfI7RDWT64+eTdFrT/ZWVVbU79y5vRrCPSJpw8yEup0wkVLXXntteGHn3pu1Z7F0XmPjhTVVFSP7m178T6vXrLlZ1UwpITiyxnRqAjeUB2cXY+SduWbNrYcOHZrZ29lz797m5hZt0yuS/nZdFy6+8LwbtPfOkGBTu76+8F8+97nQIoMOPb7ijBWhcW5j6BAxzJ83PyxctDBUVqJJtMleKmDLli1m/GEUVskOwKi7+577Qu0Nh0LZop4w3NcQGoZ4ADkPs4uWhbNHrwoDeqrjZz+7K5x77nnhoosuMgLAgdQl2+Khhx4Mf/HXf2+ZRkqW5zMnV9NmLApzlp6fCx/obA7VJW3hXdfkiZV+NTRM1wykTPX8TPZJZ+iSBOrq7LJ+6ZVwQZIgbNu6TducS8L73//BLdt27FrvhS6YPXvGzLlzrhgZ7P8LqbPPKXxKCCGykNd66mcnJNP5ssgfxgB64qlnH6irqV6nt2DXC/nGImkiaGs7vKW2tnolXIe45dOsIB4OLi7RJ99Ly4KeFg6XXXZZ2Ld3X2h6sckGdOasaNnPnz/fDMCuru7w4FlXh92ZXaFsTXGYWRZVcHG5quyqDodKX7Ce9XYPhoMdLWFuOFd2xYB8CBvD0iVLcr1mB3ltXV1YvuIMCxvoawu9R+7KxVdIEqBizurdHQ48szG01J4ZGmbOCzs2PRxecdHh8JOfNBnSV8tLOVeEi1qhD2eeuSZ8/1bNYITohukNRgz4NYAb3/EOU2+SOnQqRwAwjI5bJBVWtR0+0vfCtq2XyYB8wjJNbJ0jKSKeHHFjAk/hhnJKcLa85a1v3b5l87bbvIwecfXo8MA16PqBkcwthKelwKMP3XfDli1b9TlW6XW9/g2kMmg33viOsGrVKi/GROaePXukQw/Iy1ei+/7w0+6K8NDCNbk0hRdXZH9Xr5Nhiq4X7wj5C0tWh9dkPhy2btksoy3q5V27d4WPfOSjZqCl8/f09o3+8X/5L5mN+2pD3+Gfh/Yju8I6KbX5pQNh7fyY8vH+NeHOXdvCijNnhOWNrwwPPtsblpd2hzklLeG9X/6zUKb3GJaXV4QdMmBRUUiWqopKk1YQQ7Wk1po1ZwXpffNc7tzzIiLfYPaM6Z3NLa01MAwBTFFloDws9/a1ukUaTIptMBkSwJCvBmWvu+667S/uffE79XW14XB7B141A8Qf8/uysvK3z5V1jv7DINKCjolEEoF8pm3Ax37/4+L4veHyq94YHrj7TgvDkmegOACmirftkp/+WPt5leb+ob+xtBDCJ+d82Z7d37Fzp/kNiFi8eEm46648hxOGGgCqqiozixcvlBFzb5g/oytU2pNdQTOHodDaXR6ahhvDbr195I2veWt4/sVZQv5uy5etXxauWBAljh5ilKqJTxQjDS699DLj/rY2GZF6omhAxLRt29awTiro8JH2UFZSMqt/cPAgBaWRLwM3u2fP3vuwD7RA9cVEJZBswkQQe0tRpwYgnzKyTU37b25taX2AYhz5cD9QWlF9CxzQIaTL9WpzenRkeVl5qK6pNu7vlRX9oQ9+yJCP4wWLftXSeeEtq5eFv/va31o5hT9fXlwZfnRGabhtsRBzhJds5eH80qHw+Rkj4Xvzh8LHZv+PUQgIomHwIUgAw++yyy+zaSX3jnyugVtu/2moOLQhVLbssftps7OG/L6qc0JP5XlhzVlnhQObWnIGIrbB8sq6UNW8PTz22KPmyOrXGJTqPcdxFiyrWLMV7Jqzzz47oCpmzZwVDjQ3G0P0dLdfYRUV/PT19w8RdLit7Ym6+vpP6bJcBzOFCcNkSAAakq2tq72wo73jieKSLFaXzcMqJdadCJ577lkzgrB6GQCMI46l8sRhGL3lLW+2adphIbJ+Wr2J5M9/4Qvhox9tDT/56V3hvvvuk5//G6FKhOHgTiR091+d15BDIAQEwMzMMHHtDgj5zPl3SupABAz+0J/8fmgk4Tt/jd8x8Huf+UY4Y/5lYUVpRSjvfjZMr4KYDQ/hRVKubgzZndXhiu5/DEsGs+GJ7IqwYOlbw8LDPwvbd2TD1oaNsl0utyksL6yapj7Rd9RXVA2VMmAPmSFohrDNgvQNNH9SIWkN3J9c6rW3mVpUk+4J48jPdz3RSZ4nKgFy1YF8PY9nJjcNzUUkF9e+/Z334ZHDEkbvPrd+velhfPsXrbsoXP2a15qakGFoswCy4c795re+ZYYhbwv/8Ic/FP7tW9801zHuY5xIcDV+Ag4Q3ydHT5MWgnAKHT58OIbJ29epGQLSp2l/k7yI3aH4v33aWjb9E59IWpj3Sn7lv345PPfc5rBH8/d9Q/Nz8X4xfdnSsHXDcDhy8Cfhke7KsG+gNFzXuynUbv9x6NvxZNgrgmBGgnuadoJwVF99fYP63GczGaaFjAWqgRnM7t1RhWRGBlnCVp6o+71OP0+rq0XqOvdPGH856vIKTvGcfeqpJ5967ete90dSA1FpFxRU31DfKkZtkQ6bgZcOeH7D+rBo0WK7Zv7cKUmAdYyhpwEIJTr+/htfDxu3rQ8zWzss3b88+lB44Gv/LXz1/mfMieRSgGkkHrzm5oM2yAw8bl69j9g8dJSJvj3c2jbGUVQzb43m/oOmBpAk//D33wjf+d63ra6hztpwy/4QPlg+FKYv1WtMZi8LxdXrQsueEhP1s/Y9G8744t+G1eddHFq2PBlGN+8NW38YQuuL20KVZjFtP/v30L7kwnD2OedYeageEI+/g/awQNXXuU+GYXk4a+3asFP2CbZSsWZC40FNfX2cnkTu9yQQxCk7ipySvLCTPTs1lsqw6z7n7LPfM2fO7BXth9t3SfKaHNYj/K16ZrpaS7ojS5ev2PnYo4+c2aOOV1SixvQgpuyCLZu3hPPPP9/8AQwOYZxZdv/YH34kh/zMsulShL3hSFd/+N4/fiPc8MHftqXhQuSbNNAsAWOT7wUh+nds3xG2b99uBFZ3SDOJ/t2hf9maUFQ7EmYvP08GQCbcd+894eYf/F2YPrPSjtHeLhaKwmN9QkjZWaFzYFV44NCMsGuwNJybeThU3PihsGDJGVpFnBka5i/VWsV54ep3vDMsml8fVqxaE1rKZ5iR+ZOf/DjceMONpuunyzcgZhHnl5onc8Wq88Ickw71YfsL22xFtLq6tkXtsZ0oIs4GjUX50mVLL545o2EmjPKnX/zif9fQMb4+G2AoTwkmLEK81uSLl2HXrl2hdnrDqxYumGeekr6BQWEtAmLt3e/7wM0s2uzbt8+s4quuek2orKqWvuZrYPpglKaAmi2EAxLViPyFvQNGLBBM+X4Rzllr7ejt6QvXrF4R1/xFLIcOtYY+ze1xEQ/qVfK8l4dy9A5CxR0K+17cZ/E4mTDsgKqFkf77JClYi/jUf/6D0N7Ubgfx51xydpg1TQ4pTfOe1DRwY2tXODAcJzcL3/E+uaSnSV0dMaMSA/L5n/6f8MjNfx1qll8UuvWuYlQdfd25d3f45Kc+EV55ySVh1uxZtiqJVFi2fIVmNctsdkJ9eAo5DrW0zvKp8qyGhpErLr/kioZptbZK2dvds09JozFCpgnCZBGAfQxKT9nygoTQfrg1s3ff3vn19bXXrFqxLO8ySxr7/g995Oa6uvoW5sDM9f/4jz9n9sDmTRtCa8sh6chK0+Mkrz77gjFdLJFRxjHzFZcaYbz/VZfa/NoWYyQ+EbFwCcD7PfD/HziwP7RJ9ONoOiQVsqBkKFT8xh+Gkn558Gpm2qAjehfOXRTqGuNq8LaNLVYG9697xeuNGHZ07zeL/8JXTAtPbNhq+XgL2H4R1y3f+oaI7VBYcPG14aBWKNs1rQNqampD4+xGPYLeHL7yX7+kN4POsv0LeAI1pxcRVVh7mRLj50B6LZg/r4pVy1defOHbl65YeiWSzGGyvyw2WSoAW6Liox/93d+HAFjA4UDHyaqvra6qXLlofuNqOXuKtArXIv08On/+ou6lixcuLtGiDKtwLM/iBGIfALry9tvvCHubj4SGaTWhvHFByGpdf7R+po2DL/dWzl8cahYtCQ8/9EB4xSsusTi4Hy8fA4xv/6CmWPu1VIw9sEurh+tKWsOcd3881G2PPpefLf718JozFoTnn3g4bP75vrCvbSBMbywKvZ1ZvV+4y8oczg6Fi+Vp3LX/sXDta68M7C3YKUm3cuUq494n7rpVC1h1oWHZOpvi6lVwRtB9WsXk62XTtCmlSkS9fcdO2SFbTAIwNg8++IC2RGUUti38r3/8x3Dlq18d3nrN27RGMbNOC11zIWQbT7XCmeu7t37vn2+/7TaWlJkBQOmnrP+VV7VPDMiPFMFBX79p0+ZdmovJOOMDyvbixWixa2GE/Xy8vhWjjGled+9AU1VFqc3CvAkYcbiFN2zcKGv/W+Yc8jjO+AoA+34QHxdM4MiRjrB99+bwd3/zP42b8CQyYEz3UDUQADOQ7kd/HK760G/nkE/2++d8NnzmNy4KP/zev4YHNhWH0oFtYV/rk6H/cJV0dG34yEc/GubOmB70Xlj7LDyfqadtzONZhGpr2hY6ezrCouUXmFU/LBUEYMMIibZyyb1WATUL2WYOoN1SDRi/199wg9kw58r+YQbTq5VD9hgwTrxSXsyRe6M4Bipqprqq6lxJib0qEuqkslihLk4F4NyJQJr67Mvg+gqMHGAqVkTA/DaC3qCpt3WDFFywvTiIRkcae/u0nVud5fMwSAGQf1D6+u6777JsWX05DLD0djX2x+Nb2vvCskWrwt/89V8bwtgC1tEhXS4x7D73DevvDjf+5q+PQX77Ms24eE2jYOTn+6Vv5ocZMxrC4sXXhYsv1gKRXjM/IG27v6XVbIvW1pawetXK8Lbr3qB1idaw+4UnQ/2MeWHerKVSXUojIgdYpQSJLFtjhwCztfK4cOEiif15Yf6C+aFf+wd6e/vkKWRGEF9Gog9LZIR9m80I9TnmBPkOGtPIBTEg6jqPPIXzRAnAq7Sed7W3P9cwvf4ce68+3+3Rm7oB3/Zl3j/N+809mgzWiLgVQEQjFkn7/vd/gC9oiNOzmssfwcoe1bw6w9r9vffdK+S8wpaDb/qnm0xKLGqcLqta3wzS52OwuK+55ro4xZIIBuD+C6YtDrPbng2DZbNN99e+6lPhls3/K8zISIyrzqen7w0De6rDjpbtcs50mA9imhaGQCKARw8ktouw6mpnhO8//qNwwfmrFT9N8/0W092kmzOH+f40IXmhzWLgahmko+LwIqQCxu4BSSQ+JAWI9gV6e7mQnGcYwsaCeyllHGL9R0obm+SU7iaDAJwKh1pbD94BAfCJFogWjqdT6DLOcAXimV2+/tYujB46x7yfePYBMlBYw+RjPr9v7x5jAcpbu/Ycs+YZ4M9/8Uv2uZmO9vZMi9bZDzbtMz8/jhc4D1czU064/71Ll+cGqGbtteEmIR94w1klJoZn1ZwV5p9XGrZvq5Sh+GDQ4p3W6d8W5slJQ3nz5y8w7yQEUaG2fuYzn+ErJSN6OknvIhzWW00H7I2gqD/azOyDZxgEajYvitYnZiQZ4+fkIkfDKADIN7HPdU5qIiT10AsDqbzu77AM8WdCot/LiS3xu1M7UwbbYqpuuOGGNVrWvYdi6BwI5HB7gPDKSqxeXsIcX6xMvBmCEpul8ooBhAH+LIA2lFh6iIV8Hs+gMbB+WKbkRyaIjBMZUXqdG8CHpMaAkGofqi6qsMFFAlE+bXVQM+37gNyDGAiw8Jr7dP88v7fR89lHosTuph4tU1460n7ri+ogPSqR9FxHwsm/PV1SELsJxcUxYSKYLAIAc9Xi4hl8D1DXOQLQZ9q0Nz8SA+G4PumYDxBhjDljWyGxD+Didb0HwpEKhURgCfXjyLeBkh0BkB5E6NMNYUTEUzwSFf3QiBxDPfosbWVZ6JX7tqJa3xJQnYNyr+OiRfIEfYFE/ChE0QbsE2YptC//UUgnBFb7vC76kyYEa4h+nAAMsfoYRUJDHm3EX6GlYZl+ue8KOQHwcQv6BTAefF18Wv20dbpljumOIKJPGSZDBYypHO7mi5opRoqDpwFF7I/IQeJAGr726cTAIAFwg17w58kUH7mSgSdN4cvAGfjIeVFyOPL5wOSwPhxlkyWV5sin4AotxbKoxrQR4hqRvSL8qC4RkFSs0C0JIiIwJtN10h7ayjVtoe3AsZBvkckPaTxvFooSZMtEagrHCSU6E5TyI2Tn+x7vY/o9e198yhLEn9jZVMCpXE4mAYz5MrgjS6/c5sPd0pexeXC0cw4hDApg3Kczg0sYlI/BBETk2mVCTBCYpIrKjMQQ58ukMOSkJGN2KBqCcH9W+lzfpbeC+OxskZw4IwMsKEGEskuYwmXki5cEoFV8higLjpN2RCkD4mObvV3cu2qCeFn1wyOZJm4qdcLhGlBVokq7zP3A/ceCO+74Ps4LNwBJGCnwWBlOIHyyCCDfEInfdK8yIu3RxOK39ojT9K2FHFINkcmAMog+kAwqAwa4NPEBt0D9JHixW+IgOsQ+APeP2vcY7Db3498b9gD2Bhonq41x4iWxqz4MDMuJM1ImCaKUJqmUTueC1xPnCNHLg5DZts5U1AmDMOwLgDqwS4o0X44jlacA7y+zEr/2cjGUv/CFLzymezroRODRp3yeDAJIeNsaNTAgwue7SyDHxKRcrA58MoWB0LdbLc6R5mcfJLi6WMTg+o/86QHh2rmwMG5YHwQpEWvxLl8awkem4f7BYel6Dd2IiIQR5CPSA92R4PTxMCFXQt8+Lxe5u1TTWH2smOLpiA4hymyLSJQxQuWp90aIidimXfgeRhJORn0xDoh9pCDvF5aFYdll4nkxZgMY0R9HAuQSx4vY0ILAk72dDAIYU2dPZ8f6ck0FPTAtAUAO0yA6mkYuA4S/m3DUgxG4+RHi4DnCOfs1+V0ikD8HkjD9GulRKFED3muDHI3LQaXjizGYeYN9UVJoDmhZMfjwrurF8CYBCLRrLhIRRPsMSUimBOEuhWgDbaJ90dZRX0QwpvqwRVCFCXLhfpDvYxC/ZDaWsKi2ECbbB0D5L11rYSvGv3dqHHp+4/pvjZ9EoYljyBHn6Vzsc++49MEmLfExLg4yMwkGj8ORz8CTdkgjPqIvjIO8Uc27OYbFvUMgOFE9NqcW4ke1DxHAkreHU0cjP5BXny2VRBCH4tXEOiwAEI86oA3p6SjXVh5fPscnMCTClpE5KsnHuRCwQxzog4M7fvy+wA+QUK/Hnvo5X+OplzEm54/vvPPnFpB4ujySr3kDPHgDQkEWSHbkw/nxyHMz8W4cOtEwSBxRUnjp4l0NMMYc6fX9aDwAucO51RADcuH6BKmEeVlcD4sIhvThzyFJoGGIBs5OmmR1y6Zx4nPk2zK2iBJnVqkQCtJNCgjhenjQHE1qsQhFRmUi/slryC/YA5aeAfhUmF4mBBEt2Nht9XLiMJkEAHkPsy/Ap20YAnAoX/BOA4gFYSDfudst/nQ6v0aUOzHgCeWI07e4uwb1QXksOI0HEI/+TfQbJ4H8RPSn0zsh4MtnwGk7H5OEWJzLsR0c8UgiwktLeTV99P9THkRoHK/+sVkF5EtcWFWKSVcp4pKxm3zY0tZERBhAAcdrIaxnc5Jx/E4mkSd7ijLvZHMdO/2YqeCIuKhIX2vTGIwBfa1Da/MSjRoY42xxHlzq4n9MYm4Urw+/G7FACKQ1IJ/Awuxq7I8TIpyNM8oA0Z/kG5taeAbhNEKHu2xxBLmoj5In+iK4Rqc70ZTIz6EJhZaz4/OFtIzZBBY9yIf7AZcAEH5a/BOX5n7u07Br155HdO/Ijx1PJzjF68kmAGuGBhttKibSYI4leKFcCCCVcaH6oUEy3OhsYjER8SC1ELEepiQnDO6skcko9XP0uIHANOIhAEe0cz1h6bbFNIleSFqiL1Lak8d6EFb51T1EjhNqkqYoE13daevfO3K8+T9pfvjDO+7xtDozAgVslYo9icvJJIDxGyRbIEoBRZtdEJNl7KPPcJv6Mh5H2uBFhDkhpM8gCfCwwj7D/TlOFzIKke+cy2fdEfeOdM7paxDvyE8j3vNTL/X0Sd+zxJs0y6abtNBVgNYBZVfKS5rYAEe1V8ZRkTaPWPpEDaTTFPgAjqbkdOKTuJ4sAnA+R0TJAhrqlvmtLeL54o2LJCaxxCFejEFcrRLu1lzT0xLP1jMh3/W5G39uK0hpGNEcC/H6pq9JlKha3EWsusTFuTUGESLtAfGId/P3c62BJxxIEwHXPoUD8TnCUjquh2SDdHayekk+y65yJHOSsugjwRiGWvO3BKNa7CkENsQcTw0Upp+M+zyGJl5ajipb2w4/NXfu7CuMA1z3jlM+NoCGUJ2OkTiAIApXdWO4OJW/KPlkPNIDSevgiDEDSoPvxJOVoSW3UihNDDHjeJAjNRSRGxEfCSBvi+SJIGmgKsrVkUgt1hF4KxmOpExi9Hp7nODwBYwMqgzhvF+nUfqpY0j2hRjFiAOCTqsB2piGqfABUP5kEoC3d2jbC1vugADMCEvm/h6Js2VEzhgAZOOUgQggBnDCAGPwAT7Y8TpiGg7N0VSChHQ81xRk6w+c4XA4GFWk+wzWSUkMi0mjfoezFZ3jfOKwYdyQ5B7we4iL2QiriHA2REyYvjcfE5q606UiiqX1zCjyqbC6os+aiRBk56hSHm5hZ1QhFBJBEj++qi3MfIL3Y8nsBDMdJ5lZqTlfQEHC4Uw0YtNTPhfpDCBgZyGWcBDHPYf50MGQgIGGeDj7oQuLI53P27WlzsR6sZw5We0YyuqBi+Jy+5qYTd/Ym+/r7knRIgKIIx5WoH5AOgeIpj6mnWxc5a0ghJtaoEXmN5CIT5DPsvKoVj+HdD88HEU/Zcb09DD2x+vxM/12cNWle3wAPguInfVEEzjna5pAIQVZoy8gsA6bB+NC2ABHSTIHt3GC8zQUiHMQbbo5wYYNkcS0z8WdGEwKmMQQghlDIcWmaqnxNOMthfisnuQt0h7DuDEjin6qccTnW3o0wkE6Ngi+BratgXyu0xLK2p8gDsRzpCErYgTSyE3HF16DeAf5AJ7168k+j23l5JQefQEjInncqA7FIt4xJBEjioshZqGaOBefnsfOkdjNWEwGBe6PUVF1JKrduJ1wc6gYF0duhBgAF/N2k/pBePh4g2zAjUzOIJ/Nm4N62sgdNIhnyjOAeFPIj4H5X0c+IZQHAUtO5RJ43lxAwYWecfymgpz7c7ZWQbJTuk1h6JTyF2YaB8UxSUaL68OZ6MnMaD486ittSQlIg0xiGxCEAXUsYAAdETliSDCIqMeJY1a7RLIj3YvzM2WnOZhw0poXTzfG8biW8TBKYvksxOyIAuRTlhOMEyNhQBbK1pIDagBCSCPbJF9MZuHMAMxxlIT56dOf/vQTfq0zVHfMcU6lO6HLySaAfKXS4wwcYnsExFNTsrqa1UubBvS8HXFjCMESR8SndJ+V6SLxKMIA8WBPB3HoZJgxrjxiIxw9Vo4suN2vqYRZCNNPkA2B4TtIE0mO45XPw+Fll1wgd1hLz4Zk6o06jhSREDQIg3IXlmkLmCM/TRCWUD8usfw+eeyOjiAFXrYSwFmWRg709PUf1hNB9d6JwnNWLmLAlkt1hvtZj9fI5MIKicAyFP4IGWLdRKxqdGSo8RHntE62LC6ujTCjMefIN7GvcgoRnq4K5IN02fuyN7iOS9WeBkR6eegzWRsiSo/Nn9FGSimCGA0lSCrWAlKq0gkiPQNIpoD5QibxarIlQI46O7u67xEBvD3d1mIZgTYTkDqAS5EQEoq6kBEH4hMxbm7bcQYPDmeNjzm3Q3qgCIsEpVTi4v/L3ptAaXZUd54v99r3TaUq7SptSAUSkkCAkAQWa7NjGhhsPA0G95n2GbexTR8fG3uMPW6Pp0+77fF4bPdYxz6MuxuMhcAGg0BCQmhBu1i0q/Z9y6Uqs3Kd/+/G+78vvpdfpkqosioLdKteRrzYl7vFjXjxeSfRaTVr4Q3Kl98IZmomMiZRfBybFRIpXHEC2lsB5ZRtHZWfA2S+X1DGPEl3Dr5i+2u0UytPFaj+6rNxWgGVj0tEpXMI4hBaBrZi/1WdDR0gC3rx3rxbL760RgmjTz35o3v82j6RDjvyDhLk0KEB9LIt/AyuwGF5WiYMC1usFDQj+eSDGvmyKsn+xgQw+Uw8VA4iBTWXdTHRflyfJ9/vqTwhRjnxhFMeyAqwwRXLR8V78pl0P4PlMnBCtoMuRJ2Ar9vgJnwA0wryusr4yfKsVcYXEDYTCBDaKrYA5LEBJGA9Dtisqql09JRufRCsAxDuJ5/4VgXBFYLqSzGAwZkJxg3js8SGMCM9FIBfQBoAtgzC8CQNXoEl8hAPV4q2gILoPVm/4vJrHYubizWItDozkMt4s3zi4Fp1KJXPGbEBUNdMIADlhi0ADxAUW1JOWiGLkkWJQD5YEVD+sW4QacrJrvudPi/fYXUXC93YuM4gaLImAaq7ntHyifgSIXLOkPSAkkOJiqmXVYiRgu3fCcl1/Z1UhQNg9UC+FWzxQXjO1Xg/cvjInbgltGi8o348d6YQINkCUJqysYD1MUBAC8tn1YN88k3xRJricSUwNdDQ3mQuYuKE6j055Od4GACLyh+H4VYAUpSghaW9lUu9MflNykESXR1aSbTpuBnUPxWwfQxUiqP86ABe3kak/tx73713yAmu6rDj6U7u2YsvfTIfy8q0/EMBMkziAqWMJD4m2wnlQsm2t8M+8yfnBLBr7AEgAeCTOCCBRVFERJx9NbdEgpxrGKGYfEOgoc7/AbDxMTZ5wj+ZYBE6LwRuuummW8v0VHDc5+u4F5h1btiHMQhDQbLsgw2GKGCcGGQ9gf1yJ/SgDAZkiJACpvhbynbL02AQJWWCLLBV6nsxAPs3MsqYXRXF5APmLvgty/PdPcIB9S652WHQCJjiT2YDIMVkjJoi37EGH28EMFkEOQyPjG2rNwQk4IlljyyC7dwQomciqDkNjpeDlh+m+qB8JlsP41dRP3NbhhMGlRqHrKWbA9AeTuvWuUDezg7dCAI4LyIgn/yw2KkZndgtpgF99yxkSHM2lTgY1+nhHOo6wExtA7vO440AlFth6Z59+++xxauqsMR8I0G3Fs5Qp1cGTtfSZZIFMAbOG+YQzEJsP0BLRe84pu1mLblK3YP46SYfpjTGiqXktkYCbdpqwquuVSsFjn7Fhy5CqhzJmHwg5wIT3aJ/IX8OHAg9mdA8ise3JaMP3Hf3LSUBSNlpUIs1YE7FwBLhBkwKSIDc5hNqU318NamJD2p/Pi5eIoi/P6A7nsB61+ACOSRpxN8EiPGEPEIFcZT4RsCRco1gDmJfH+BUT/6ACOgE1gtY1o2qnehAYxx0LVdDLmcatyF3pkn0QqOeb0hfaHmkp0zOOy3Qsen1jz72g4f5WhjoxlhScgC0YAZjWFY9wtkswXJHGI+Vt8ioP+BPXSWocMqUT7psQEE6lEFzAT4Xmwp6StO0fswk5Dlf7QJm/fhFwBXUEYAII0GVSJ7gECCwKL9Nx8i5NzgUYVkEOVYeV+Qg/oT8sVUtYqBORIH6sld3AvGV1XH7HDxvG/6sS/WoF/8uqq3us2HAAvPL5Y9Lj/sDNPl8QOpJtxzPtfr65Dt/uKZ8vZAXMMfx5EfgNH+O6mMQng59ERSTn4kMsuXsv9XktyoaHAIpWPpWeo0SjpXL0VZ58jCu383eM/mThb5I70wiQEtbgNsLMkD1QNPXw2WCYPnlZOaIUEYnI0zOv2Cr2R6BJ0n3FjtLWnmIQeUrgliNlPI6dBGdY7GcDkqEGimjnDSXWxVaeurUnzEitSuJP0Qc30pSvuuol8M71E/dXL+r12ZZ1SrDiwibKQSYxGuhSJZShk6x5vrEM9CmfqfL3ZgIBSQ3IQ+cIecOdSRABKAHtGdigjI98Qx2hya9QgpxAZYQ5HF9VRs0geYsDsvfg+JFp3TeOmreHrao3WdzKpfTyp1pGwB1NrSeVi348cIa2p62hbEFJOUuTT46wDCmvgwZXE1CkMTpGHxEQj5QyHfdo+bkpT/hWhhmhAlMCHmsC+BHp2gFQWmBGGzLKkVJqSq44Scj74D3CCrlIwUnJFCfKEucgsVCurhC8craJh0HewH9ia5L1zGVowTSxlbHwWfaBkDrGySZ+nI8/wbr0tXsjzAZppQRUQHUz3t8Ol4igoZBA5QmP590GuTJRKefCkCY4AY5O8jyYmKFCwQnQBlk0waXSfczVeHEa9WAdp+DtX3CwkZRiomE38kAFYis6edSDMB6Tt3kS1y93zNtA4g6+TMD4JEa3bl7d3Wg0RPs+jguxQoARIDILCJiMkUVuIAHpl2b835cxiSk0CR5JRHHuzTxDDZymMOoPLSDJx0ASdvEnpgoV2l5zx++K+DxpNeRIRArdUIavfqitsZFU2U/2C5uBWwH55CvOrLw1iwsS/Djeptr/3FLmSbf4z987FYIKB9gFEAgln6lIsg74xfsMJt8IwHxgL8pwF9HBjizvzqOyWPyVTmTz4Q3OAnlJCQgzg8IwW2fPDnCpPzoBS3mQVQfNgvNOpPJI6tB4ymRmONh9CXvjxGbvpwsmEkEQASMld+0Vf2rc4EkHppZfx1h8oFj0gG7VcHy+HOyPAw/Rh1zl7w+I0HFEZh4uEWJHC7HbebOAEPIbE0+BIxGn0+s0+Su45n0WA2QsYQ4xUwZWRhXwil6xs4BuO6GRuWQGXJRg6YCJhxCaQx+82HNqfKR3mB27Xe7UK9IWq+t9Ye8DOfBdXhOpUwiYoBvEFj28Q46UjesOzgF/YTbJDwNjhbpaMcxAmUd72vhp6q6gYZTpXhx4dV9AWmoGoXl62n1V4Nottx6oBjkqSDYfFBtlkLfD47rbh4WZckYlNi3RQwpc3+Ws/J68s09IoLGlmCkA7lDnygnnxVAvb05B2gqz4XV3K/d+nVuWrENYMbmacYKVuMrgTk6wZdwUAiKXaNKBsWDXOv/pFcPIBGmTk8A7zzMTSheJbHrp9tj8r0foMu4q3KPvd7U3qi/nHy0/DhCDtLyqG7eUT5N+VRURwLqdNuhct6nase//eQnv64iGEMjAUUed5gpEWBypfGyBQT5xugjT0ECXLiA7llu2SkPjAfMg+l3r+15dxgFoYBpKqLMMe76U62+J7hdP//YClwXcZTld9rJROXIl+d3m6YKIx+Py8vTTeW3HqA8lRldaRuybqqMP2Z4gxx/zAKOJRtXx1Xp0IaF+TknqOJKjyfUbquBJqnjm/ILsQKk0Bm4mSNu59D9gWGUKbkQ+etl1CeronxN5AuFqo8gUvS5gQx5Pa2MQCfCBkB/ZhIBjLVxjfwLHTwmJiZetgKsaPGuQ52oYVzJrksohAEy5mjC+e4SS2Bw+HJyXR/U76eYaL6UgUnIJ8J5cKvJKwNNzYEQecIW8U7rZDXblIOb3KnaoUSVKG3KcJxeZhIBqiZi07Yl0MqfWd1UA0pmJp7TwzztOmTZJlNsPDKlArBnwFfCxwsixqgXAdmfuJw3vTPBHVq7e6Jz134badKENriWJxg3XSaVyiEfE4nL0pC+pvckCggnj1dELt8tdH/K9/yoUGs56Ywvwp1pBGCmmo6I5231QDusidVD+Zp4aVci9AYRcOJWn5bqCtjReDz5cSRbecLiVjvs4fLrHADVhEEHEZgMrofDjUcTqNlKjwqw1zt5dplkfjnMjyfedYIAfigDIE9d9KSYhNS6dGJGroRzHbk7U0pgXke1FHQgg570wjQY3AjSNCBMvj7HNjDpAAIADgD4qBV3Co9psjDNVtgsStOdLNqDj6QpPTd0iY7820UgW50LgZA2+jinkRTKDZAOk5t1mdwcWAVE29BFaEcJeX6CnK+VEWimroRzW3L3RCBAXl+TnwngOnkg1glyQQR/NEI45+sNUD6pGTQftiSOye/SyI/AMUSN7XLHdbCjW0gwrInn4me+14GPeCJweUI+l6yZsqYyGMWEKV2kFyLm4DJBKuJjKVimdT2k96TneXO/EXKmroTL67J/phGgwbuhiAzMBZJcFDV6TEX9BiYfqo+Jzyg/n3zSevK7hFBcFwt0ihLhG3Hrt1zO9IWSmGYweHrcPKI4FMjm1imwDtXkpwhPej1ZvMONapwhuIv6wsejhnoah8/UlXAuP3ez5uTBx8VvxYV50K+3j6VfYTzGorUGiIOU+eSTtT75Li4mHw5QwmjtAgqH40JpHeVv8sQkSAcACaaD4B+hY2hypWU26StZRosyXBuFqAMEB/dQAEF+T76XgISdDJhJBKA/1YxwdVwEaFQs/91hrwx4FxNloRdRKH9wAMt7p8fNw2D9PCBBDp1i/34ij6gYJa1T6Xw2wOcDJHjyrE1+kIOfpJ3Q9bYTugmU28jrSABCePJTXUkcpMlPEx51S3wRlq8AjAyu9ETZAKKdrnSG3bg6TgK/ZTVeIhLJ0s/g7whBAhS9HOAEnniHhw5QvjDxOXBBYxtUX6M0kBE7vs8Q0JaKusXK8YfVUfXxK2bDQgTuOR7TZ970h7hq8jlgUtom8u3fmHDOA4J45UPbrADm7az5Ww9YLdGLeW0mmRdT0tR5Q4WPz8UzLlvnApwOzoFTtKSpH8DIJxmKr1N9Xob9E/qtQj7dYvK5Sr56mPh4NOlymUhWAVByIAQ8PCa1PA0kpZLVxfjYUaVJXIH4YPfkITkoLE4TCqbax1Hv0DVi9ZC4DNRfp3q3NZDtBGwDu74TgQDUVdkC6ruCbgguVr461E/b5vE5MuTh9rfr3H1b+dOthI16kjVpXCkb1A3FK9xAGJCUUw0PHKM86uU0uCBB2ChA0iw/lG8qh+VHdooolc984i3/4QzBHZS49jl4XuWM+Gd6FeBGT2kLoOMit6bfFAg2LFrigifGVjSnH3DSQKOJa5lnTZ/Cp0ICOMOolDsDlA5Bo2V4kuWJ6GiDfGnJlmjCE4aNgv0DLoIJgAsIhqUIdoQ/xYe9AMpXnJd+Xim4rMhY/sl1mDxcP017i96T4QOGMsNwIhCgIutgm+pSvhSC+hggfTAlRJjcW41/DMMYFMQkKs2xNNo6A0gzpuVknNJlwiFJg/yepETxQg8lyScsxSuPkCDaEA1SATrkGc1V+jhCDiILUt4G4hFGv8nfnm1Ho8NwO3gdZvJKuHpdvE9uQatUxydMKnQzQtcVsnSVLPftp0lqgQ/REn5U0j8sabfeRBQ2WD7ae6J8pSgnvzHpqR4mnzoJd915eQlxxQVkWtTvBadHAn+i/BAFJAMwB6e7iTXZJUK4HERf6DS0SQ/gg6X5OGRHweECzQMWuY7vn5lGAKF+QLC03r6BrbwFRaRwUUWtjxpEXyVbJqkcKJDHAxeIIKQa1Tzix+Ux8OONPp3D5KYJTtTJJo4BxS+UPiENbas/uXmYSY+Jl1bvDz7zr49cZu6CgLFa0MSDmAbrALlCfCKXgLRjphGAOqoZ7u3vvze/NILIgGyXzkHTuWPSDUY0ESBC/LiTmLD95AuEUFzI+lLOw1S8UWM35LbSk856QT7ZboPjPFEofRbSToML4pDWq4LKr0lPqwoNeMmFnC8UxlqY406EeyIQwP2Iz8VNXQR6QJ2A27uBDt2xx8MPNAE5tUYAx3zLr3nNzkEE+32GP9KWfxxnDT25yUATn6SXk5AmjRUCCJQeivBKwTeH5XcMcFdgiDchJj9bx23iQ0dlxtY1s3yb0BLp88a19jdYRev44xJ6ohAAghn7zGc+86hbPWnyM0NPuzZyxsZ1rapYeINRO2fDDaWshgjOYaqtUoecL7tbymzikM2m+jwPfj9Qr9sbV8GI/fMFsb8nxJpIGYHc2rkEH8JIFOIkySTClC0gtwKmkPRX4mGvfDN+FDyv80QhQNQpqhuEUkwRZocdusa9FczpljKFYqVIH7zI3cjDJ1fId+kAXs7FO2ElVTdxECZfbahA7xYFpHee3C9FJZILDdKn49LgjTQuJyFLo9zE2mk32ID4mX6oGYsT8Tm422t3+lY51fFxwxbACWGKg6WaqvLi4yZRTTo/9gAXALgdVHq6VlKaoFLzJtysG83bABKko2EKAwFKJOAr3fhVUTQykEAPk2QwEvBuJHCcXe4OAAk6+JStRNpsZad8ZXlyqc9IhMsmUGwEZXUKsfSTio021D4Hb0S4ATPgHsuS+nhU2yTPYJUiorAHpM1e/WR7OaAjUgN8BICTOoBUKHEBDWD8yDP40xgbj3mGA6GEpblvpBuVAaFT5SWrXQrP8yR5n6g1Ks3+MMljZVtAAu4C8sDF5AbPLzOoQXEjSiBeaj+IQV35EjP2AYQj5DIS1D4Hz1owc173Y+ZqELFnhacj4ub9ZYSRwOniUmm1rF06wIgM91LVIkqMQH6x3xKdgoMYA5xZLlp+sGT4hihMXCdKgEFzKxedDupnYqJoxBIeWHtqrrmA3xVZTZQFlpd/WCyTaILjqBxNvrmSuQyM5/kgswGQtCFPni/ji4hPI/siCngBWWPl5CPi6AFwAgNIABfo0uS1c5tWeak0oqBTpzugHh64AtQDMnQojl8cgeIY6HjEnnG93w+rzn+QybdyVdTYAoHcJlyzcX7+HYufH0++06SJZx9AbQlqpz1GJiFc0Hpe8mT/ibYB0IIThQDG5pa/Lu7Jy5GAZRZXu/l6N3580lBNngPkprW9Bl0DjR+wnKZ8kKALrBF06IImKNRUGoHlH084r7BuP5TB7WV+0oSn4QsWHwiYJt+XWLgs8gbG5hWV/hozdIomkenAmXCPAS+PS7XUM1fPfN2MtZabw5jEuDFU1GwEQD6PjR+NI/8To0NR8aj23uu3e42XGzIkmBjWxOjXwMIf6wV8aaLZ9p0KeiQHYO8s8fy7AjlHcr6kS6j5JfI4nIOhKHq0mfbDhZjwmGwlMhIiGtLXwPy4NOlQcPnBaf1imXYrSYcOQDsWLliwVlkPl88JQYIToQN4zMIWoAEarFOwBw23Qwc5NOXS9ruLEeS1jEPtmkk+7qgjAgW3dTMBSYaz0kK5BGLnsETvpPil8E59NEo9ud6WYtALJtNDtA1dIYtKkksBKoR4Jt8TbiRI1K/+IK5CJ0i1kD7KdKVy4QL79x/8fBnEODXYXZZuJrwnEgFofywFobRqsNs7RUgdbbp2tRxi3dLJjlvnsChD1MHua6eIYVSyXMF1JMBolDhCT4xPGlwpjtpkEFUpRwIPutBFw8v4NgjMcU7b5JaTzyQafH6Q3b18womnLE8+WVBh42dkccsCfBIIyvcK4Fd+9Vf+XNGhJ1GMnkYDy3wz4ZxIBKg6dGRw8OCihQur3xNi8hk4BoZtUljkmN67Nfljc2SRU05OCipEGCBDmRAjRwSQIBENBzDSMHe0qQQ4Q1vXONSZI0M+tp7AqQbX5bVCknoc70lnSIiQ1v3sEIpTiYVA6VNZAbMVgJFgqiYd1/CKQo5rqZMLszSmc8MPPnj//04SKVTB6nT2T0yga5zdsdALdHl0KG098/R7vzrX396tWzY1ufz6J7/2GbdtJgUxlEQMR0IKmekkfGVTKLVvjmPpLmKBfqiuq3NCiDDeJaRgMnlAjOd7nNaT7a6Rz2WkJaDfoeqEiCH/NcIgAuCy6Cf+HGorgBMmAsyV8rbMlJ+6QhHs6elZ+uhj339iTo/YfVfPRHeH9GZRPZYxKhex6IuvpJwhv32j9jAHO2wlUrqJkYqp6PQPOROAB2Na14uvaPBTxGhpdmyfGNV3JwkJPKle61tXqE+Oy81dJlczralNZQW1ezSlMFrEVa6onz6C2J3dPROdWoLA/kGk4aNHH1+yZMkNKt8KIAhgosmrPe7+EykCaDwcQAdqjx7VnbwTXd3tyZIv6mfEmPh88Ls1OCDCuGwCo/pULPYKxRHGyo9DEQ/iHbqAYDguJ6YCI0X6YQruIE4UqI6m6VGNTB6Sgro86eSNScUzBdj+z0Q2qFpKqo6HcfkFLiIAMMt3f3DjMCjyoAYf+9jHfqkMMvs/IZNPnScaASDZ6ORTTz75dxsve9nP6bseseY0+R0aWOQkAGVAtJ4k2CaIkD4MSUvEtNjT1z8eNuUbL3+cKQrRH7gAEL/9E8MqBKAK2wTK+YhJzAxTkan2ZxzkKfPiQN1Y+OXTSoCCUmHuQ7Rd+9Za/bVLxuknEVLnIhupy76W8l+nTCslUN4TA2m0T0xd1EJ9TWJg/oKFE/PmzmEAK5bIRhEIAIAEvLNONrUmrpDEJAhBuMUEecwhwq/1OlxCJwx4jUEPxNLgl1IhwvM/Le0BJUeGbROP/qIffdTUdurPOJpFNAh/lFXqNxIzbd4J1NfHgYJdoZcIaWQH2Lx564MXXbiB31c8qAfMZt3zE8sB1LeGGDh4sHfrnLnzTudn2UEAU4QnP2Qko6HJwu8dRHMFChuPy6CEACU7BxHgJAamnXv5CSEflEhYGztLAiMVfsO4WHkdjDQx4QmX1J6Jwe6urmEd/FhIesrX3woRoq4ayw9uF+nULyHuZZde8j8rk88ANJQaCjwB0BipE1BZWYXFwPDrXvvqf/X9H/zw4e7urokeTRGTbCTwhGMxG5HMZ8xBhJhQcQOQBK5gZDAiSMsKMZFPLAMNtHd2ZZSVlgpMmTTONm9TgxhpIoUcPnY0joqZJjYKKv9I2+/R5CcDhMLyOtUwpE4byA0HMPWTdVQHFUH4LVu3P6i6uAsoZ/9ZG0k9s1Di8sxW0qJ06tXSTGpgR/fglVdeeQ0/ngC7FOBWTz6oebgSaNHA1owK0hM/yIC8VwATiBIGkAfli8eKW+zChwYAAEAASURBVETkf5QPeR4yHQVRj0qS8Ih2wH60UEjzwoTiJy3gd8LycPzUDeAFfWmX9QDezzn7rLfo+BisHwTgmcx6FDiTkFo4kzVMLps6Ibz5PFoSLvz2HXfdum79unWLFszT53uN7/fgAjlYBDjM76Zwh4M0EtOyH4+1+QSxNIEYXF0DO2WfmUyDJlDFNt7zONLo5HJFqV0d7fp9rPFuXJk1K45g2R8IKWwwJ9CPZLevWr36Gu1B7FBR/Xr4chrOeMJFQPMIqwUnABg4RhaF5zBLQkTB7t27Dx0eHJoIZU8kMxVYRHjS7ZI+Jt6TT4DsCu0dXROsIIB88pnQ/NERcua8Ak9+lUYTzqT7iQLLP0w+XlzHh1vWQVkUTFm04f0f/OC7JcL2K8sRPYwDE9/ANr2cKJiSGma4AeYCUMscPQslApb8jy/c/CdXXnnFtXVOUG8LChmIYgAJYvKh+hI02OHPJ91xuEwGwMTg5hPOu4GJtJ/vDPj4A/dYgLSAhE+HVw3nnnvuNUy+8B7Kx/BjBDi2QinwOMLJQgC6kCMB4mAeSHDt9def/Rd/8ZdfWL50SXtPd1csDU317jcIEJNecgr8KFZSJvcNDh5d7nT1yWfSYcNQO2k86fiNEOGvTTphQJ4+hUz9V2kr7qrDJG3sf1x95RVvm2LywcYK0aYu9fjHnEwEoDc5EgQnkE7QI+pe/md//v/8+tve9tZ3zJ83Fzu+5GdDXzXbNyIw+R4aKH/u3J79RgQjgXClT5OyCIrPJ/KFTjwTKxgXErUcO7U9Jt51HD16pPjt3/6td99x223PKd9gC8o/aZPvCfDYnSw3RwKWpaEYagDnaq7m/d7v/+FHP/CBD3xs4YL5RY92dlophjS8zgGYeOXZ1z9weEXE16jeE6+DHUctw8t0OAGeRCZdxp8KyRzfPjF25Ojo+AK/y+CgL5ZksJIt4nBfb/Ef//iP3/3tb31rt/S/I1ouHlI6ZD7avtn+SZ182t0Si4k4wWAkgMzhBOgG8+AGRgTac+s3b//DM84887Wi8Davq8XSqz5A/aQDQABPck71DqvLdvJ4wkt/06Qz2SPjxYI8DfYJw9GRwWJscLC47/6H/vK3fvPTNzHpxJUTj6HHE08mK31Ve0l7MqAavJNRea1Ot4XNfTgBD4jQKTxYCCLIj9IWNz4jIl511VXvmi/O0C08kYjQleT6bFNQpqtY/XSTTlomVU/JuhOl11l5PtlQOOcR+PXRA7sPPv73f/93f3rLzTc/RFlMfMnqmfBWE0+yk6LwUXEdPOj18JP57jbliGBk0Fz3xDobhDAy0Nh3vOtdr7jxxrf8zMs3brxC263rNQn6Zc5ke5+qM2brTDZIAHiivbfgiR47MlT09g8+/vTTT91x6ze//lVYu8ttMemmcli9/SQ/6Szfbbbrwfb7bHHzdkGZiAZzBSMDbW1CCAJypODd8Pobbli9eNGitWedee6ZDlu5auUS/Hv37EU+F5s2P7MZt7evb0c+wYQBTHTyhbUxrnPHjqGw3JbPhPsxpc+6iXc/8oF22Gxz3UYjAu0zMthvBAmDjLkEkXAK3B8XYOfkLScaL5MNmLI92XkY/sRSTtLyjgYcC3hwjyXtbEjj9oa8VoO8NgQhgLpbj0+pxDlKjyfT4XWXyQVMyX6vu44/JSY9dSn99YDmYaeKv972OlLQDyNEqz7V4zyp9bR5uCfaaTzhvJ90jd6NeiFufRBfSN7ZmPb5+mMkeaFtzyeavKfkZLfq9PMNWKs8Pylh9b7/xEzqT8oEvdSPl0bgp2IE4ER1bvRT0fGXOnnyR+AlxDtxc9BqrJ9PJ7Xu+ZJ6cuLm6aeqplZI+VM1ADPU2VbjCrHbLFXcd999r7zkkkvuYnP7cP/AnXfcecdHacvFF174KtyLLrnkC7iC3PIwGxhCq76llk799yUGNvXYvBRzio4AhNDqgcj9YG/mYf86nh/94Acfuv3O7/6Dfhhw4vDhw02PzozFe//AwATpnKd0XZbLxs3r1+uMguty/dGep59++lP0Y+Dw4T27d+78j//wD/9wjloxXVtntJEvFf7CRoBJfQmmH4FWY2TVHWIwxL6CCGANUlxnzN64aNFCnf5tv2BgYCAOnOjMQdwdrM8fioWLFjnftC7nIDmJps/Hi/7e/s/v3L3zP1911VUPK5P3J2ZaQ3D/rcFEP0X4//acc875Pw4fqXbHox8c3qWfg4cP/9Hq00773bJztLXezpe0gnJwTqbjyT2ZbZhNddfHoyWhQ+QbN258T09Xz1UdPT036qjg4ur3H8rPE0b1GxVcEyBJHxeHQxQ8fOZ4/vnnR5/rZ1unGwgOQdePpPb39X/17ru/+7+8973v3aW8x5Mh5ONgwqd5nfT9TW9+8z3z5s5dWSd+t5+2um9jI6PbnnzqyQ+UTIskOTOYDUsaN/un0s0n+qdtAOp9rxN7JekWL1r8Dkny1zFAjdsEE+7Gz1CVJwn9OyDjuiyeI6g8Q0ODlfTnxoIVK1cWa9eunTTWJhhHHDk8UBw4cLBYsnhx5N+1e3cwkkXSHObPnx+XzHDikSu1uAVJX1Pu3bVz5x+dd955f16WkROai23piqjPFBPZ3CLSGg5j0bljx87/tnDRwrfMnzevmIr4KQMGUAc0mQP7Dnx+/ZnrP1rG1RlWnukl7aA+gDP0XieCGapmVhRb72uTZKOFWne/a8XKVZ/smdPzOgjSV4xwFQmQfvaDa0wTrvoIMS7SGcLn2hAkPZJ/z549xapVq0LthxFIbS7OOOPMKn7Xrp2FboUqHnnk4eK8884vdu3aFUT+xBNPFnPnzCke/eFjUe/a1WuLV73q6mLNmjXBEBYtXhJMgJPRaBQQl10uLpo/b35I3YXz558lY+LNUUiLP/S3d2DwDxcvXrRX15MEg6slCyaodG+/4KKLPkccy5kcPAZ5mP0wqDwerSjd8aOvZMeLO5/b9Nynp9AMXARuzhjy8JeYRD4aP6a/ThQ/ZjGzItvz9SWX8Ebsd61as+bX586bt5Ee5Gp2+E3oIuj4MSrdUGaJb8S2av/cc88V27dvk3p/btHXN1Bs27ZNd1kOF1dd/arijju+Xbz2tdcW3/rWrcVZZ55V6E6A4uGHHyrWnrZWUn5/sVJMAjh06FDR398fTGBgoL946smniue2bo64s9efWZy/4XzZAkZ0Bl73HupncIEFCxYUy5YtK1arzCViDPrSKcIXy3/GGWcU/QODO1QWSwS+aD5noL9vSZ8+V5PhLr5uvuKKK4JRLVi46JC+c3w2Mpd/FixYuGbhgrlrc0K2326efip//S4np+NOyImxkfZ5CxZYe/lLxVkzsOvkqWN+m959iWlMPz5V7PMRTZVwlnry9kPgY1Jnz7n2ddfepG893si7nkqNZf161Suv+sPlq1a8v96fYyF+WedF1IPF3PmLiscefbR47sFvF9e960PFgoWLQ91/8P47i545S4o1IuytW7eEZH/966+X6ru1OO30c3UrjG6skV1gTJoCBIg0heiBNWtOK3rlH5SmAOPoEyPYsWNXcc/37i5WLl0qQl9UHNUv1m+Q/WDBwgXFvn0HCu7XxD88zCcp+k5BzGHP3t3Fe9/7/uKqK68s5sxNX0I8/uQzt+gL+8vnz5uzjnphAAf27xfTWFNceOGFxfYdOwq0idGjo7fvOXToAGWtXrnirNPWrLoc/7GAFJ+J79x5Rxt9GGhbUbzpdZeOL1uysB2pn27045oOaQTldQxmoPWyGaNhrugYn9jpuI7ODm4NCFjY0b2zf2TwOdkWBrq7O587cODAI/2HD2+qaRJObnc65lFnFj9VmkVOQB6sU8F1uyupLuJed+O1b/inkfaxC2+7/fZzS8OY+9Kpj76+Mmfe3NehLhvytaoNbHXJ/+1v31685jWvLW6X++pXX1PcdefXiyuvfn1x6MCBYtGSpcVBSXBUcgCkZpmwbdvWkK7cC8L6nHW7UDpUf4gf0A2pxWFJ+YGBRJCEPf7EU/qVpETMujWRoOLJp54qduzeUaABrF6zNoiefOvPPDPU/kikP7t27grNQFK7uOSSlxWveMUrioULUxlD+lZZW4yhWegb5VieoLlcfPHFund1RF869oe94fTT1hSbtu64ZdWKZRcsW7b0Ai99XIdd37bk96eefqJ4+L5vFZdcfmXR07VcF/V1FUd07cmTzw0Uzx5oL9525aLi4nPPjeR1wvdyymXhMob84mJAW/uhwSNDd+h3ZbhIZxLoTtflXd1d6+bOnXMRtzLJViNNjh9052vBOcMwjKGJkb1ant0tRrFXDOO7tV0Ul9mKSfzEMwcTkgdhtrt5e72G79T+82d65s79dRq/c/v2D2vd+5WsI6j7UD1M4L/DBLK4Jm9O/EiuW79+S3HZxiuL7//gR8XVUuX7eg8W8+YvCGmPgY80EHb8epWu/4G57Nu3N6Q7hA8skMEOtRzC5ONRVH6WB6j6aACo8BAqd+Nh3NsruwHSHwJAyu/fv6/YvGN/cfrKRMxnnnlWSH3yAXMkuUmPDcDw6le9ulgs28LKFSv4ZLY4KKmMfWKEJYwIHyaw9rTTIo48MAgIETvFQrWTPCb+OrFbgpPvmS07iv/vb/+mGO5Ouxob1h4t5q26uFgmbQU4MNAXDAD/vj2dxXD/7mLtucuLD163vli9osE0ic8BpshlzGhLgP1DR4e3SQt6cE53VxMzUPgbdb/ven4cCqCvwXDFSHQ5RDBmGC7zc/ToULF8xWotm5YeEpPltnDdQzXniSNHBp/Tbz48tnnr1i+1YBB15pAzhlNaY8gJKgZvFv9xWyvCL9vauWPXni3z5nQvZp/8vvvv+zT78FoXb5y/aNEvCJFX9vYdjnXw7t17Bnq6uxe/7NKLN+bEXu+z16yHDx/RVTTpdlIs/MODR7j5JQAEg5CA/VKnV8q6/8AD94vAD8Sa3Jb6Hhnzli5dlghUCI3EHRo6UugqvFD/Uff3KT+AhoF0Hxo6GlKQ5cDmzZuK2+96sFixeE6BhEYzQO2HAcAksBXAFChndN+m4qJr3lQsX748mAnqNKq97RS0GekKs6G9AH2lHNJC+L6kG41Ie/mRJtKV9hD8R5X+z/70T4t9uxYX3auXFAMjy4oDe7cXw0N9xZEBMckFS4s16y8quheurvK38nQP/ah4+cvPLd746gsLXawYSQbFjDCgTgXuw+joxCO6LGrz0NGjo7r+5/rdO7cthelaw6PP1rC8rFLapmLp9+LFS585d8OFD61asfxVCxbMX4C2gUaB/WPOnJ41uu9xh7Zb7x4Y6Lu5NKjaNvETwRRMVE0DM4te6u3LiZ896RWs6ds729+/SNtl/QNHerUGfaa3r3+b7nUYPjwwcGT+ggVxiwx9OnL4yMp58+ftXTR//vkXXbRhY6zJhfh1az9SD0nHBSFIbXYEWPM/9NCDIZVR019++eVSoS8s5vbMLbrnzA0ieuaZp1WWdgAkZbZs2RJEf+EFF8RwUtby5XFFVXW9INLoqJAdibdNBkQIF1sAMCbtAkaAxnDXd+8W4xguVih+6fJlEb9s2fLQHOaqbqRc3+YfFFe88d2x3EDNRgMA8p0J0bSk4Vgwldu+dVtxufpw2caNkQdGh82AvnKYx9uSA319QfBRlsr91re+WXzjG7cWuwfPifJN8N1zGgebYAQAccCSFWcWy1aePi1DQDtY0HWgePNr1hanrT098kHsFWR+wrn8h4tkO7t6tum+sHU7d+4s9oipwuAYD5izDaoQuu0kaGLsphD/2GM/EE4MiMl3FTq0VLzzne8qlixdNrhpy7avat4rKQ8enXfOOUtgDBrXc/U7Hws1sHf19vXeUm67tmIKVX71IetI1aNZ4akT2KxolBqRt8tEX7DOf+0113wCyS7iXbl//6Ed/QcPPikF/7pVq1YWTz+75RYkAp1YsnBB76H+gcV5h/JJhQlcsOHcsP5ztXQOSMXv3HVX8bV//qfiUF+/JGYjvlO/UQKANJbGEOZ6be8h9QHW/sFcJPGRSEgjGAkGOCFTIGls10mKm9D7B/pjS3CtkJ+0MIe+s54qdo8/U/Qu2BLltvqz4cj1EfzY3nuLttOSQdHpBg8nDYX3OX0ri3fN+/ch/WnbPffcHQRw+eUvD8b0qquvnsQA8is5Dx08WHzik58s9vXqYNPQARkrE5HPzQjf9XbPSUxqvOs8BzW5F8r4OLzsnIohQPxoC7jP/ui7xWj/fcXFF1wYtpd1OjXp5RSFMJ66AlSSe0kwWN38JONiZxg1IerbbvtWMGMIHqOotSWWSkCXGB0akIFt2b7eQ2HHYd56e/uKV1/z2vFnnn3uKwgRp6u70qzaVy1Zsmzh0qUbrCns27/vT56HIcw6RpATWr2PJ/o9b0tF9GpErPGXLF/+64M6drpz154HkfA0jrXg0PDI8kVz5y6aaJ+47ojix8bbtko8PGRGUO+EmYC1ARH71UOHe9fvkCUcIxwSob+/r5g7b64kY/opO6ztObANh3X/oosuCkLXT0SEpMUuAKDCDmhd7i259APH/DBRuyTPsBgCmoVUdkl5bb0V23s7i+1iInedcUlezZT+tV23Fhf0aO0tydYKcsIn/n1Hfyd+av2QEB2Nw0CfH3zwoeK6664rXvPa1xYrZDNA8kNkOfE7Pe7uvfvH/8Nv/Fo7dokccqaQh+O/SvS28cyk/fC+bXRpsWskvT89PD+WDEMHv6ddkE3FFRuvKl51xfnFnoPjxR0Pj4YWcV73YS012qOdG8QYpKpHGxMz0M/G6ewDuynbt28vNm16LhgbWhq/Cey589apbSV2ac973vu+2IGBMRI+0d41JrvSXUdHRvYQPxWsXrG8f/e+/QthBqRh52TJ0iWv6NFPh2pJ5qPQde0AJJk1jCAnuqn6eSLC3Q4TPoY7rmv72vIVy1/X19f3+JYt258kbOniRUcP9vbpxtCx/ViAj5QIrbvXzxwbG96I9ASQEvPmL0S9PTQ0ONSpNfwhER0XA8c2l9axvQcP9VYTcds3/+WSffv2rThdp/RAloP7I1mohpSHQe6G619fXH/DG4rf/+xni2VSxT/xi5+Qylj93iXJWgLqP8wLlVR9Kb7/5MHiGyvPLJ7RLsKLBZjBeRNf0g9r7tKaPUn8BXPXFxeNbyxuWP5BSeqhsEvs3LlDSN5bqci6WTWWCWgkX/7KV4pf/uVfjl0BiIqnFXhZQNyBg4cmfueP/nvb8O6kdbR3P1csO7Cn2L/9qci6rnu4WN9l3G8u7ciKMyLgXw7PlwFyhzSPtdq5uCzaiT1kz5F1TRlgADeuTvX0Xf6GmAOkOO3ExgH0SVPbt29PsW/v3lhSse7HNsNWKkzdgBEXppAzBP1oafH2f/WOsPfslw0nxqCz69Czm7bc6nytXBN+PU7aRqfw6JWcoRgZHrlTW9JvLtMwILYdzApGYMKr9+FEvrsNFfHrU9mXb3z5y++SSre3r7fvfvZ8IXoTf944MwDCDh48+IpdO7aszw1wNgyx9oMpsEbE2g0x7t8nZNu7J9bZfX1sxx2UJOkL9f6Sl72sOE3rwmuvfX2h64jjkBCqPOtrPsy5+Us3F3/1//51NAV19dd+5VeKiy97ed608EP8/DZk3XVCjtT+Y1978cjAcNG7h18MSPBCmMPl+pm8dyxqLy7t0Y8VavluIyY/rcjZhR07thfPPvusCOFwqMAYDL1kYalx113f0dj0FP/+Vz8VbXUbTPAYTPHX3V/46EeLOU89UOTEvmS1pPFu8FzLMPmB5fOHiv2H5xRD82PFJQ1gXXGgb1NxYNmqMJj2TZwZhsRdW38U6flj+wEGxRtWripeceamYmzgvuLZpyaKl/+739dx6tOlffHbm12xZEL7Q3M7ePBQdZ6CcugrgAEw2QLSe76LwrIA2wsa3Twdc96yeXOMm35bbKuWh/dGAdkfCB9NshUDgPiddJ4MwKetPe3t/CDM088885psd2HWMAETn9t8ol3XXxG/GtCpT0uflqFnJR+70KBD+w/OXbJ86aCk8EidCeQMgLQP3n/fJc89+8y5cHgMZauEPKiIWqfJwt1Y+5GW9ffefftCUhyQxNd2UiUtfvVTn9Ix3pWhqvdIYvTI2AYBsGXmvWzO5//B739WEieth7u654ah7mMf/7i2Da9uInrqmw4o22cRUL/vPTRcbB7vDMbw4HDCKRP65QtC44ziIp/y5gADgNEdVlvZVnzm6aeLHdIAIABU3PXrtQ2nk4MT27Vr8eTdRdtlHyoO9fYWH/jXH6xU/1YEbwawa/fe8U/++ufaMfZdPvFwsa5Tlv99W6IJZgAmfrcLJrC9uLqY6Lqg2DpxerFlYZr6s/Y+Ecxg7t4fFPcvOr84OHp+Kqc0HF48tL9Y37a9WLrsrmLo2e3FjrG1xZ4Lri0+8pGf11o+jQvGUvqG9hd2ltLWgjEWmNMzJ8aDpQO7Mj16B2B+PozF9ucGGWxZBkmQhBEXm0BbR8+UTCAKyf7kxO9glqfrzz7jOhma+THgqxUOd5w1moAJ0O090S71NxG/3nu0nrtDFt7lSH8alDMA3nOoMwDivvmNf3nl9q2b17IGXLEibcF5vYxUx3Ku8/6VZHZ5SGqQ6U1velMc/UWt7BVhoBLOEeLMlZWcD2GwPhOG0QmGcMuXbylu/cY3ohh+xtywZNHC4t98/Bel3k69tofoc+InrwnN5cAQWLsjzUmvX6ZMHwPp0It//cppIXy0DSSiljTFJh1R3qStRBgcwNJl4/i+ouPrqb2E7dEhnff86eeqHQDCAGsA6U0nDUVkn/rM38jYKqNdafxb03G02NC+W0xgWzHn8CMh7Z2+7sIADpxxbvHw00siasVAEq4bDj1SbB3pLLYNd4c2QeRzS15fdC49t7ih+4EoF62CNMDcc99WdG88q3jzm99SzcMwZxzE0NnRwSj43DOPFWeefXHxtA5SnXf++SEEdEBIGlBPzNugtnQx+HHOgnlEA9iwYUPYdB5//PEoC4HRiglMJfmxO8EEhBNLli1degjcNAMYOjLIUuC9aj5r1JwJnNSlQKWuMLAnGFoxH9rTuWnTlrs3XHD+L+iT00t1gu0xpD9MQNI/lgJ5O1GzcibAJLzm2uvu+eFjj2x84P7vnbtPiX1klnxsFwFYiXMNgTC28NaevlYIpG/wR8dD+g/r+O342GBIFp0sKxZpD36hrPdsmbEOZe/8Z3/2A3Fuf7MI7dDBfbGu7R+aW6yU/1P/dAtFF6/Umfvf+Ou/iYNEEZD98dIiC2K/Ll4xFMKEdFAl7efrzC0/iAIDon6kIGowBiw0E9L39w+EVrNly+aQZNgvfMJw3aa7i469SWNxfRe9+91Rtn5dAzN7pQXkjOg//Z9/XHzxS//oLGIAywqs/Cu0Pj/Qta+YJy1gbWvTQXF49bnFSI80jp3nFQM7Ti/O08Zs+9CTBSwJyf+P+zplIxkqzl4j7aS0Gyyf+1SxbWBf8ZTiEyRUHVx5SXFwsWTGpi3FY4/cU5x97qWS6NrC1NOuOdmzZ1exTGt6jl7rNFBxwQZ9ZLVnb3wXwU83cQw6P4UJ8fNBlj6Iil0cnSgMZn94INlTqFu/B72+u2vOcxgFp1P/NQFYNg8JN4L4wc0zzjnzEuFxsW3z5r9I/Yi7qtMaqQw4mU4rIjxR7XHduQaAbtbzB3/wB2985zvf+bfLVqycWLxoYZsMaI8cGTjypJYEK0cnJpqxVxnqDMAdOHjg4PJvfPWW1x8Wt58/d161h068P6aBEeBnKw8D1I03vqk4+6yzQiXGmg+CcHhnWNZ7EAdkW7VqjQ7SLI98Tzz5dPELH/tgVLly/6SmuSnhzp2XVM/f+JM/KV75ujdUcTAA6siBswj6batYt9MG0phRQJgB0gzQStBOANqLsZFlDV8awpCwbUD8fEcwOjZSvHbz7cUZE0kCk6fnEz+vrbKVxTnXvD0YHweBkKIwNjSPm7/4ueKzf/RHJC1WLekq9hxK7cz9Eak/ZwwOF68UCSzXtxDLFqWDRqj8AGq/AZV+9eP/XPStGSjW/fLfFOukFfg0JGnoJwCTy4Fx6JWh7/COp4sjvTpx2bW0eHbnvuKHP/hh9PV/+sjPiakvFjEvizK0FCzOOvscqfmcrEztRu3HGAp4iYBt5A1v/Blpi8uDmWLjwS5SXzL2zFvoa9qEB8nyn7cvXwJgBDydzzgFaGQf/tCHrv/CF77whF7RAFibeBlwUjUAE6Hac1KA+icxgPe9730XfOYzn7mNFiHZ5ml/Xfu+YbGXevU1bAFuLcRvLaBSwcqzAN7ye+bJx1/xne/cKZEgiSNi4es5ADuBwUbA3/zN3xLisV2XjpCi9oJ4rC9Rqw8dOhin8C699LLiFhkC/++//rNiKsIfOH1dfMhDHYPfT5/2uj7cG69/b/FL/+WzEYTkBjiAxEk7jJTUzU+ecZgHooDwfbSV7UMhYUitMEwqLe1DpWXdz5o/J34MZBfueKCSsDABiL//sZuLhZe+qzj7Ne8PzQbthzH/3E3/ufj8F/9HsXjt4mJR1zlF38iz4S47XduWT2lMupIm1bujN9LgGlasvLDoObIgVHjCdo31RFS+XFjw9o8Ekz1dYzRvXk+hOxdklzhNZysWNBH+3l3bi92PfUfbqnujjEXnXlOMSIju2L5Vxs0dxaZNm+NjJjSvvVq7Y5D9dzqvcMGlG7VsGtSx6+3SoLT9KeYNQ0Gl75LGhOGPpQJjiwawdOmSIH4YIOPMoS9Oa6IVAvogSfaAOY8vWrzo+7y3YgBsA65bt1YfXc0LoicdwDxKazwLrx6OV86aJcCsZAAaoO4HHnrkoTndnZP2yVhj6wDOBD8kLMR/fMfWHbv6Bgcr0VtnAioL4g2RaUbAqTqs/B+RxEhIkdRLdgdY97OOTup1VxDeiJAACc0ak201DE1fuvnm6gjvyOOPUk0FqLOW9l0XXlaF28PHPQt08s9w9ppFxSf/5K+L09afFVtY6axAMuRx6s3Sn/MDED7vSVXVKUC1FS2GNtV3NSgfhgBxrNm3ubLWzznnimL5ueeEVZ00MIA1l78dr4ymK0MT+MWPfyyMmxAVsHDOYLjWAM6/eEVx1llnRtgj3039h1kYjh7Udw1lXpYLHGVetvCVxfs/dHnYM3zcGVvM2rVr1J8FcSIRK3zvM48Ue7b90EUV8864XFrFGWK+vbG0gQmz7+9tPvexd7CBzhsvPKv47c/8TjFfywGONDNvBuwoiamOx/0KPgLteBjA17721WCkDkMIrVl7RpNBME6UXrhh+dz589Y5nbWz3H7Sd6iX9f9HlOaIntwGYEEWws1lnEi3MWInstZGXa4fUQwV8iAu5t3+7e/87epVK66VPz4GSa4mTpNjPy4EPEfq/cL588Shu8b7+3qf0snNJw709sJlKzATcMDihQuvXr/utPW8d0jidZYWZV9YwXcAXOixUzYD9sqtLrJ//uijjxY/+MGjkiIyCpan/ygHxsKOQOxt74TR6+RZxgA4PViH2EF49qkUfM75xa9+6jdCIkHk9LUu9QnHBoEhEq2Eh5NsWPEPaA8bq/aAbADe0Xju2fuKs3Q+wFt1o+/5eLH6wCNF19HdVVNGr/yV4nXXXhtSEkYy1N9b/N3v/W7x5IQ+btLJv8VzJ4qepYcLiHrLzs2RjyXAxmuamduGNRcWS1ecXuw/cKS49bv/rdizY0GknbP0yuJ977mmeNNVZxd33fu9WIcTcfY558QdBrt3a92uJdgPH7mjGD6QNInl69YXl7/6Bg52xdLGfaV9/mwaLYmj0mg67PWzxBmUrYQxZW4Y71dcfnmc12C8jDvskvj0J5oX2gHA8m6nvqr8y7/8q8puhNH4rLPOji3hJctWjC9ZvLCdnRTmoRWx+wBVzgC+8fWv//7b3/72v1IVLzGAGOnGHzMAlgEQfsUA/uq//teffe01r0mLzzI9X4X5fHgQB5wcIsGFhypepmupeBjIONsuy73U5NGJtonhocEnhw4P7uR7d5hBfhS4LD457R2IB7crwlDh+OJuv9bWX/ziF+Ob+717dHZdX71xYhAYGUmqYryUf2AIrL1N+PkOQZ7OfpAb7QA19pOf+KXoF6cJ6SOSH+DTYgCCQOXngxfW/F4yxHpXR2BhBjvvuq1aky96278pFm/+pybCpxyMc3cv/XjxGx++otpRePa7Xym+8fnvFU+Ory62HFY9OtPfPvJ0sWrtQDABMz40CxjDuH6GG0Z49YbTCgjXsH/b1qJba/KLN7y8WCnt5t777g+jpI9Oo1FhfX/4/luLhfMWFU7/sldcVwwODqnPIk5JbtbQdTiq3RrA5/7xs+fvLylhgowNYBsQcwHAKFatXF1cfMnFxctedqmWl4uLL2sn59577gnN8MafuTFcmAaEjk1E+FVJac5aUI4/nMqJ3QyAeIeX6//nFGQGgDpihEGiVWXLf0KhCdFPaM2pMtdvO0AYARU1T3aAs3/v9z57GwRdAQQu4IOW5DYzBMKCGZRaAnmxmMOxQVA+eLG1nLRI/dhGE9GPj420WfqzDofoQDy+AkRdRGW9XefM7/vefYW/B6AMAITKwcwABmCA+Ft9U+B4yoCpIGEXLzkrpO77f/ZfF7p5N4jfJxtpS078SH6MWFr6hPRnOfDcc5uLQw/cXbzpg+8Jglz8TGW7cnXhdiy4qvj8/GeL0zf8h+J/veqcGAsk49//7X8pnt3SV3BAZ+2idOSXco9MbI41NzaBq668qrjyla8s5mgM+UKSn8seZzmicdu7Z19IdJhW58jhYvmadcL2Cf3G+21RLzYYPozavunhIPz+I33hnnb2pepb+kDKS4S8wZzn4JsJCJ17FdnTB3zYCz8EizEUrY20LBNgirt27g7bz2WXXVZcqAM/Z+q2JOwNLAdk74n5H9FFJKPDR1laUlTgCviChohL/SmiIw5c5QwgJ/yUKDEAlo7q7wWaM7jRrDIA0k4ToNt8MlzaYAbACAcTkJFl4XfvuY/bZ5dy4ou79vjuHtCc6Ul+JtzQRPxlYFrjd4V6hx9wHjh0p8LY/zeY6HknDqbhid+pO/u4t4+vzjCyoX6CWBAvRA/BW8pjbLTkz7UApFEqu7EcID+qaytAWn7wwx+WlXxRGCNBfh2UCunGV4T5p66o/z/4/vejDUjjc46mfXakPCo/rmF4w1uLf9nz5Xhd0fbJ4pffc0lY4vsP7S9u/99+tXh02XnxuS8f7fCV3oo5O+KzYwxmc3SOAmMahJcDEpuwPVt+WCxbe34wXQxu3V36LFl5RFnFYz98PM4lzDu6P7QDlgwrV5+VDvCo/V5qYXgFsMUY0GpgHvlJT3YPOOUZDF7aHuPOFi1E7ANR3DCkOYfIdUNRwht9HJGKbdPRZ2Ef2AWeGSBoiD5nAMSBCzaUgj+ks+u8lvy8Z+v/ugHQUuOkSX/a1+gxbycH3AZmGkrk4bO6bv2Axn+SZfU9npg2TZO5s5lAviRQntAAcIGwD8jIZES18Yc4GArg46RMNGXDHHhAQAxsri8SR3qu9UrIQfmBIMIgLMowDCPMBEsJQUcx0dYvewJlLZCdAuMVZWOV/853vhMHdFBVrZ66HlwbHPG/613v1J72BRXxIyEhllz682FP+/x9xXVCtfUvW0O2SXDGFe8o/q9nGvv5a/deVKy78HXFB956dSD2bd/6pj6KeiLybdWR2KEHbm8qY5dO6L3i8iviPsNFstz7QBWJsMojmflK0sw2z8x+uL6NLB64/7awFbBXH1qCmEmHxq+rW0u2Od1pa3PegvH5c7oZWBHv+IRU/TalnRgfHW4flYaW5oZDVOnyj7we/PpOBBEcwfo2R/7E/CH2VmCiNeGTxnNpP1eaoSXafoAGALivdS2AMmfz+p+2m/jwnyxwG5iaJjuAzgNc++73vO9vzQBg0HUp3yZNABWUcP0kRRiN6AjvZgAQJ4D67Ft7mVwIOaVLBByJ9MeE7HczAVyYCIABCC2ELwAjvGQeefvsd37yUa+XJm5Dg5GAtDCRJI2MUJGHzAL7UUtbwfiYtg5hXKXNYETvhk5Jx/7DfcX48FDRLmID5s1ZinSemCd7Cervjp17pC3xnT2aT6qDdiD5qNOMjbz++hG/+qohkXQdH20PRisGSz/MaO0ieeG9OTP3+DBegN/jRX8c7vfc9RziAh7TtnZJasaR4BbEbw2SPHylCbEy7zr0I5ad+koc4DGvM4CoS/kYn5yBkMfvs3n9TzsTZeCbHYC1p2rTgw8+uPXd736PghL3ToiTJsRIMgGXFyEmRECdS0yAd9Jg4e3qaiCj1X/np9vh176/J3o8k+jEG4ivyE7IQj4EvZmBmQt4jJ2CeOojH34/ilWbdRGJOoSNgjQwATSDQCoQCqQ0a1QDKNNIC0FUzE4EhWqtwqKZHW2NpcToeIP4IzLZxKIPpG7n7r6hg8XI2OE2rSaK8RH1r31CxkVuDBajKG0tJi7KULuiVbTN4TFuZTixhEdYpE+dcL/KMnBaAmOUQ0789TjS5eXm+cAJ/dcwgx/SEjRiFA3jySG1VRH8V7s1DccFwAnm9ctf/vIOFchEsObIrZmqsdYYBZxoqPD5RFec1dc8I2mQGKxhBu/I0NGDlfTIMhnBCELDy6FDSBzAOk+Tz0QgrXJkIj4RKuHJj1qJgQ31mrU2BJk/GATzx9pAqiz9TQiZ1EKIOn/QROIBKQE0CLiagDYCIDnthNiTP7WtTvyk7WobKbrax4su5enSGJj4JxE+iZ8Hxof1laP6PDoq67u0hvEJ4Wyb2qRHF4nF08a4lmEQkvSBIMD2NrnUrz8mflweoE6kjb7QV+iAPubLO/qfzj8wBn4iYfknn/80XuWcV2V594SzE+k7AWwCgQfSBCf05NDeIWYuXAHawBnVm4Olv8OiT+U8WtpbY3Ma3Tlxp/ApbVdMJn4nO6luJW1PaitS5Yy4+S8z0cPg6TjsbVo7ogYkdU48HGDSmQSQA4AJSBYqIl6rP0lNFULryzrSK1sTJESzPSYRIsRIuSBOLomTqp6kc16IkTgPa+WHSILe0VoyoA0Qi133KSF5AxEjXr3sRJpNNNpMUeNFQ/J3tncX0zGB8c60dSmdN1rBwaPxkYk4gRhllZIfPyfhOAVXDw81QlpMu9LquICIhqF3utTmIBL1q7k/UZT6msY4vU1mAFFfOVcwFpfFGBgIy8smzu95GsY2CYnGWHapTJhcuxrfwRKB4Sz76by41AHxG7z+r95LJud3uzCFe++7947yvZnbONEscGcTA2A4EINuE4PW/d07b79ZywAxAH4k00xACFcSDBM0Uam/FKDs4uBoAWPjmlghGkwAVb2OHFQI5IhjBIMJ2G9JRn4zBCNkPX96528DyG9kRWpOBZRPOoA8fkfdjzD9QUoZjdsktSdkJ8uJH8KHAbRiAhXhq5wxGGKUKtwf4gtCCDLVUxG9pGQQjlzpPpoBbfnJ7RbhF1o+jIqIRD4ifkpCW0hMmWLd37KKcBhT94+AfD5iDjw0pfZT5VX5/hoz8onQvexibikHvuBlF2lcD+M4WUNUnqrw1h5v/Tk2N/4RZqnv+Nx13E033XRrHi4/OM0gM2DP1wQlmXlI4nTm63m+GtwOBgYx4vMAOovRtephgbfXkKIGE2i7OPnYOFkTsCcNcIoMBhAgLo8Ez8H5QSCr4I6vEBIVVRKbek3Iuev0dTcvm7goT/XYH57sD2UCOeLynhiZkLhc1lhNJW5iOJ02xA+Y+HMXoueWfNa3Xd0wxcRfJ4ou6RF8ZIQkxDiqcdIYpn5D+IkaTfDDMo5B+Hapb0zjEtKTFxndNEAxVh6fCM4YGu85VGNkwndkuSzIGQQMYJ4OXdmgm6vbLMXMEPiGIwzDJQ5QJBpTHdo7y/GGhQkv2kvpH98CZEZA5sPqv6U/YSbyHKfyNrGcWrly5azd//d4WNr6/WS5oEA+S1AwbevUYA8Oj4xtkwRf590ANxJEC4Rt61aQ1nWdktCjDUZQET8ZxAgmhMDO4zJauf4AB9tDoktJx+QJAg0EyN5dhonY77mbEwXhOXLzTpmGVuUg6TD2wUJYy0506DcJRnW8VcSiH9gqRoIzzpVBT7YB8bnBEZgd++HSH8qRHRvGkxjiaKxFVC8akkYfQvZSJlT+co2M1FeNSerLl0NF/HlgEC/aSx4of8lE89CyCeXSLospl0iMiceJeYa420ttj9SOg+AN4xnhEwbxY8EwEzDhEyf2NYn4CX8hAOMxM8jz6aTirF//097ZwgA8dsykxTRMIOwAe/btv+eMdae/j0QgTa4FEGaA+EHmqQCEgWObwCyBSA+yoQWEwahU/1O6ZI0nLe+UwUN653F9xBPm8nO/0wTDKtvhsKlc0iYbBsiOiq7O6dGdX6JjEUeJ+In4FVUypcExjlEkUFXBJHmLsSnV/FFpFJ2KTOzAbCHlaQ+OAXNJmgCS3iAlIiDCxDwmMQHSlu0goYlUwxJzF5lLZpDPYz5mkab8w3x53jQcMR4seqzl5WnTciD1SLMcUaHwSKIDED9Eb8ilt8NwvcXK/LWCVgRfT3cqrP9p82xjALSJGXS7YALdD9x39y1nnP4eMQCparmeoEgQB0JhOzCXBKH+Kx5oCI0SOYRU5AFALhMlxO8dB8plvW+tw+kjU5nP/nBJK0ljZAV5OIwCLeSIRDzhSFuXmcIa0o7y3Ca7qS4QWExBdQWUrmRQei//Uqf7IZ8Yg3LBCTKA+C3xs+DoMyp1Umd1bKck+JRG40dZLB9UCYd6QvUnkvHi8E0LiP4rn+cuJ/bc76wO87vHaaLc1mQW8+VdbIMqTCeGYiSsqpN/XMRv6c+7AeK32k+YD/VwBiCHVuq/43OV32G4MIhTYf0fbc0bPgv8YDLYDSZhB2A2erADPPjI9x/q1IhXhzsUgd+IbkLJCdpLADMA0gTiavINSP3YihNBOi+8AeLIGYDTT+XmSJ7XEYiWSRKXbZuD2+1y4x2iK9vj8GNxySvSjbykpw+QREX8pfR3XL1M+jsdGOFNoCB6ELXyqZZqfQ6TMLFTntMzRnU/ddaZpNO4LYwV27EccqpDmye3jKAlEuHxxkG99rbGUWJrAJb8ZgA58efGPxM/hZmpmJmnvifGiz/SBBPkLomRvcuXLXstm1gKn3Xn/6Ox5Z9mdpfHnHy/7QBI0EGdUtuuk2zrolnlya60hkxSvVVzIQaxiIiCOJIdQAcMhFA5QPgGkA+ExF5AlmOFkOIl4lEX2gRg10gd7cgKjT78GMSeFVExQcL0OYue1PAkvctOvAjih/Cj/QxMCbxD5Jb6aDWtCNzpHZcTvMfEWhZp60wIJjY0pGPZOvbscxdmRC7bVn4TqcOPxX0hxE95Mc8ZQ6/XAbPlLku2sMu4hAjppYFo9Ywn6X02MgAGySKawWuyA8QZ73KwoLeJiYSUGQ2L6CFwIW1J/JHcuwFlXomTQCjpBCpE6jDKtVyggaQNhDfnz5mFi6pc8tek6LTpq4wv3GMiN5Ny2x2er/ddutP4fSq3IngSlERvYvXYBAOAEEppz7slK9nwm5jNN8wEcoJPVSQJ2iW7xEhGIkj+Izq6PDwspqYJNt21lXNOXh1NDjaPvw659M/jKDdvK3F1yZ+nn85v6e80vP/TV//5tvI9J36Qko5mPXSuk+fORgbAaDBYbhuDGHaAtWvf+14dPAmq9DqaxCBau07EIbUhOA6kxJHgUtDDEDip6uUCeQIg1pKCYBYW+BCKkZd0IK4ZgF3qaUXcSHQk41SS3flTA5JEsf9YXYg8J3wTvQPj3QlU6HSEH2MnCg0Ct6s87j/Ea+KPtpcMjsM5AGGON9E/H8FHRv1xHX4f0Y4FbWWeRoYH46JQn8gz8berX8Gu3Q7GOth3ao/LQv3Prf+E2wBYJ37nscvn4PnhH4dP5+Zayb989avfq6XNGUEt6uS+No/ayW2La6dNcMrWdoDOTu1W67S+OH/SABKxgjg+FBKEKVXYhiIfcKECkEu/Iy9P4g6xh1xqAUG0SgMBgdRGbtxAfvLDJQJJTywjT0SdUN0E7bV9xQDUtBcCJty0nlVOEZX7bclOeU7nMcjf8UPIx0L0dYLP22rCH9cnxUf4FWa500Fu2EMLAPKlAN8AcLej05GG03/AhD5R7tQcYgPwEqC9g63kxNDCLZdzcV8E4ZndKPACnKDvJWcyA9BR8VNm/U8/LWXxz0aAc0YbNeiDh/v7ty1eunRtvgwwUk2S7soYh2hE56TPmQBn13PyxYDFLgLbSzkTCFURzcEjg9TJiJ975sPo5vjMNZESlBOIiSdL2uTNidlC3GWluFKtKXPl6QmifKzzUnLDHZVW1KlDPPFeEgDUWrWDfhuJ5RIeCC4XcJzTe7zL6CqN43P13mlJRB94CHN/HM84Q/hDRweTuk/H0aQQAyW0lXYfv+ej4GQse2ACnhUEwhgHwLS0gEWMaHLb9D0G/jG+FlQlPjeBpsEyAOFBn6fSAjwebkfdPZXW/7R9tjIAaC6xdTQ5+TGq7Ni58zbd3/5zQiNNkrbSWOVrUtuF4FOCDgfpzGoVbdIHn8IsUBK1E4AU3k+HuNghaFNC/G1ZPSZ+I7Pz46a0iYDSO8ifGEHElUprc57WfSB9HQLJS04FcQdwEq8CfVuvLre16deQyjDckFIwAXW+QbBpbPJ3+yHQOqG7CqfJCd5xdluPTWov84bBEiMpp+aw8lcf6KhSJGvec+Y6h5wQ85HD36GvGkc7mS/whDkMFh84w5ZhCteNT9Il+cBpRKtKlpBAzgR456yFtQDegaQxNaR/Ck3hp9L6n3bnWON+zBYXJt/Uvsd/+NitF1x44YeFsqJ+DsYk9E7IlKNL6sJYm3hHRvyEaqoh8ZSAv0imHLlAmkStSpWQDpoJyVDiIEhC49ANTCDQo1EWBtKKcFVK1Jta2mjDpLQmeheuXFZvY8tS4Y01eBqiXEKa0CHOZA9BoCbpHg3QHxOQCdnSmPis2kjnNHVir2tdwYytZaicVvPCOCLxR2Tt04+5igFkslyMKbQERtLTCYMGGF+Nu6Hkf5qDsv/6DByYEE5MdMrmU+7nqzrlc2Epd+Tlj+azvPIgImLpKB/zeyzAEszMgPS8n0rrf9pcojTeWQW0i1mAwqvzAFwTdv9Djz7erQMBtDaWAqV130QUk9ihAz36UAZIJ+m0h9z0rUCGdKQZRYsQOQvrXE5kbvUHqQg21iCYiJhJQIm9MJCKKbQYatcVEp0iy3wm7loV1WuOdF57UsaE1q0m+HSxSAORTcR1qV4VOoUHonPenAHUiZ/s7o+LYi7oEsuvIHo+ONIR48QYGgywYj7WxkqizwneZZrg/V655SB26rSfplPLnhTD0n28rWtcn01rqDQeeugHuJNc+TX3rgsXBoBbPwdAGGMPkCf3E3aqrf9pc5OEJWAWQmUHoG3YAToXL1qfIyPhIGkdAQnXeVcxA01mtwh8WIQuVa92dkSGIO0TgDWCnPt735lwJjy9J2ZB2CQQckUapQWSfpL8oVUQmDEHE5YCy7WrJHuJuCQtcU19SyURBtAW/VF8WbaQOdR64spMLnsqgiedJV4Uqj9l01SfQ5LLuFJeYqbNcbzl5eQED9Gj4uNWc0NZqrsi+npxLYjfRM+9BFNCeWRxVPMIE8ihfUJrApTG0gaRx9X9+fzX4/we4++X0qVPB3fvvf1U2f9382czA0BEGPMn2QHcAVwIoUJOSX9Duz6V5cJeiB/AHRkCmbuEDA0rM5/CcpV4qUxEWmgu9htMFeSvU0akbP2nwTBSvAkysRmV5WxZ+RXxqx4jYktiJ2/JFEIqle1yfq1ag4izol1buIRX45XFmECId1dz4nZS4kkLURNvAofYLd2D4BVvwo/+q1BLTZcVbkn0DqNPuspdUjyNFoQPEzAjcLpWbmdMYgOtxXsCWAaExtgqk8Lohy9/rSfJdwDqcX5H/f/arV/39l8DCTXUSsN0N1QeZ5oFbmOkZkFjWjSBwWtqI3aA8zds+Ei3qVGEkFv4XQbEH8uATBigAXTN0c9BsTxQPu7cByB+gEsqfDxU8jWmrbQNRXz+J9cO8nAT/lTxEEJs35nC8sz2Q1h6TPzpfjtF1ogegofYDexuKluAXV7qRGyiTCnr8SXFOFKuCd0u5eWETn/y/sIcAKS8pykC1CiaZ0I0w4m42h++UowbCEomQLQZAUQ+qrFIxF7LyKu+GsyhQ3NeQbQtzTdLORv4rDmRDn+u/jsvzMv9MSPzO2nK9X9+Xrm5IS5oFrkZecyiVqWm0Dawm9lqsgM8/PAjT5grc+NrQsaE+eNtafwhcpgAt90YxrQU6OhmSZDSmgE4PndhAB1C2LGcUG15yhLmiJ8FBzNBdW8VHwwgT5zXAeXq3RpDhWCsWSGGkgkQnyNtXlzd34oBRFdKQm0VTzMs5esETvkmcvxcu1CzsxEcDACXPrg/7hvfKEjnknhE0quAmhZAPgP9pI096rsOBetTJC4mGVF+/XBH6Tpt3Z3bMVYc7eyOk3+YjvKzAN1WmXQGgDq89qcMMwDwzOPsA0Qm/khH2wVDg0PbVq9Z/QYtAWb9+f9ocPknY4158Kzzw0mrth461PfIkqVLNqL6Q/xASFawtgSIvwm0GwDxA0ggtAaIvGlHQHFtbBkJmyF+wG68aK7LpWa88gfkbkXkXrfXpa0op8pbeQgrEckE4riqbG7libyThYrkmPrCbkZznykyJ+5oS6v6VVlI9zIuJ+6qHSJAEwJhlGvCtMvV3nle+0NLEYFD8PSfuoKxqYFB/BRYMqOKEZTvYeeQxGc5oB94CiRAE+iYEBuQ5T/daySNQNZ/iH1wrNkdEWH3jA6Lb/ao9iT5g5CtCYj44ybgbOxy4qdpQE70MMT8nXj9cvXdp9r6n3ZXRMXLLAQoNs1adh5g5+7dwQDy9prQ2ie6JZEaWgC/Ahsqv0op77gI4idvMqKlpQCEDyAUJpNYRFXxdSYQ+YRQ1We6jeTVWh5Cpo0gTq4B1BEJwmhVfhQJRaseeF6UxbuApYv+yJcYXArjvQFmJCZKG+H83kjZ7MsZiIm+OUXjrV6W6wgm6WRlm3lNc8ZWasa4SsInPm3RJWbRJoIvxo/GiTzYHZeTmuh1nWET8ZMXZgCMjuiXofTrw4ClP36Wenwq3FWeo7D0b0X8pM/BcxZMTBG8n4rrf/pk4sr7N1v8CbtTa2BUYAlul46Jbrrh+us/GsSkgAyn9KZ3ZE0c/pA0txVI4RB8Ihyt5YQ0IBJkogthJZHSQ/5AMDxTAGlz8kL7YMtvOiC91WBcP/U8z1cOnaXVScNITCXKYBB4BOg2pAmmo/e8reOSol73kpb3sB3QJz+ScPz2Vc6oSCtuFmG0PX4bS0GRTvkgBgg5NKuyHXl/GTO3j6IMKQ0tVLsiUUrGcsf528UUdKAxqejZOJMGhm1Nno+J6kBYmz4MsIEvV+Or3z0olwAmfsowkYdf/bFR1u919zXXXPNrYpB9Cse6DCdGCrlBDc6swNkEGeudTc2KtnjweLFQhq3HdeHDnAPK1uQgixEG4xBqaXsXxzubL4TgfDiPgZtjno/gndZuSGiQtURYh0/nQiBTPcI2MG667FUcgl6kEu8QnPuMBkC7IH6AS0AMGiqFp4cw+3FbgsbHA57HO8xagV3oDgWK8vJlQq4RwHgrhhLaSl5yOZQi9CB8elgykRgzrcNN5ORqY2snc/lJMgDX/gjQnzb9XFid+G3oJQ3fALjNHP81cO1aK8gZg+MHDx/ZpjIa1zI38JUkjUKdYRa5DSyZRY2aoinGv4g+0t/3WKt0EDfaIoTRXZ4U5EMQfxRCnvxizUmHAloVqrAgLghMTwDMJ2NAUxJ3mbyVE9Ie456YVUBJ0BUvNIicAABAAElEQVRR1zLV6QZp66VAE2KWuxsm9InsmDCDmA+k3+uuqx7NmGWE+d3EIhfihwmoJxr3BkrlfpeHy0EnS+46H/U4Oj3pTIw5ozYTMMHbzYnYZUSd2cc8eXirS0byeM9NtYSSdlSHU3X9Tz8as1Xv1ex5z0c88BRjy/79e74ikRKtzOhQaoFU3zF98FFeDsm6j8dMwIyAz0UDSoTOkauMaXJyCdQUUb6AIH7y+AqhRegSN01PhVwY1EIFltZSao0wAT+UVyd+pGse70s+STsqhsKnsDnh48/fTfCkr0MMjX5iHOLvLAk994uCoeL0lJlhArTJUt/ET//dTtxJ46iORbjanKvZFEteEz8uZk4TPvEmdruRR+3Kr0mPdNJNOKvQCtAArMlE2hKnnDaPc1juwnj/7nN/9+UyjGE1UOGsp6/ZbANgIIVWFTTZAfQBycHrrnv9R1GdE4JV6SRRhXQiqvjiq5xQPuThTgBeWT9j/UfwBkNXuLBN8SpLkky++GdhzxHbNiEqyVjzqoD0OIGqzpGclsAMCIv7+Eg/DZCuWgJU/VEbFF5JePnTeyK0hvqf7AA0JdqvdJZWUWUmkXk3gUbcFH+CrUL4GMrUXx4gTjOW/ipr9u5e5sRPOtqN1E83EKelkPMzB9gk2mDYtbaSj3E3MBY5wpa/RqZsKrOTS0MSveH6rAHrf+YcZmuGyzj5M2DfaERdgG0EbP/BdJwuZ05OG67adOONN/6uGMUpt/6nv7OdQzVmP2mucNjKDsAHJTnYiOSwboxErB/1sAnFw3ow7obTaUBujGnX79tX2gAZLdnkggQQP4D7fGcCIqH+xPpb0j5fhzsukNyILteIGchZCydtU3xZiAnMZQahCKm9xraW43i73KSLTWQSSNpPAv1AqMFaQIyNA2uu7W9uG9LbEJeHWiPTnED0AFt7MOAJrd35bJfdDD/Ex+3HeGowIvbMk8P4aIt+lQmYcxN2ngc/S4B8GeCblivNQw2EYTQxVeVz/4aGhh7XHJ2S63/6P9sZAG1sCSwDBgcHH8kjbRTksgcufcjBjMDMAKTg0YdFwQi69Tv0MAMeLpPwg5GwCWpr/6Y4vVREXyPmipBVHEIz3mtFhzAt84XqWWov1GFVFCZXZ3RuA4g6wvkGU5gj5E5J/KTJiD3LEt4ebamG+l+PyN4h/jYRcyvi9+fKSPlq90HaBZZ9A/3Rj4CHhmHm1aF4iNEESVr8JnyH8z7qu8qVZqIjzTtbg9MBv6BsqC8DGuHJPmPGSnh9bLds2f4kuFjmaRSq5rqc2eyeKgwgH0wGOQY6twN4kEGmUSEbTMCA1dcPYWYG+K0VYCNAOvL0hNagoUEqiZhQJ0P51Lt0wiByEzpuPMo3bukayN4g8txGQZ3TAUyAB+lfBzORCG8RD6J2qa0wAdwccslvv908nf3IU54xnavIZSsME2joVCzBCMNSrx/uKCU/hJ8Tf2TSHzMwMzGf93c8hM1SAxuG72UgLMLLPufET74JnfCEEYxpbuK9ZAL8bBraXl3689PpnAGItLVrwCOw9qdO9Hn0V77ypS+U7znxnxLrf9rdjCV5z2aPP5eTzBrYhtuFHeCGG274qGQPIoj/elJykIcHo5onkImHEUyUFMma0tBEpEK0JK0wO6m6eNdam3oEoXJTj5iD/bzykCYI2AVH+uylhZe6aUn+pGSpfZb+tNdtTm1xjlQvbcmJv6625lVbGlsy113/xgKGQ/xB+LL0hXIhF1qDx0LwHgPQCb8Jn/ooF4IlHGDI7Y+AKf5MOL1c/PGuzONibFXbNGIxK2Va5tkj0iYmrtlWe9Ep0rjx/QBGyMABtStWd3IZ06ofmjyXSXmNvmXzrja7D29+85s/LQMjS4BTav/fw57Yud9mp5uoILXN0r+yAxytrV+9DCC5tQAInwsnmXj8aAjHAqAOUheAEeA3QhgB6uXUib8e7/cmhuNAXKjKTxnuNuTJIo0CkL7e+yc+l/y5vylv+dLq13WIwiDXoR//5DH4FCU/uAqxTJS/N1ZnMpAd15ABJn5cgyW/31u5lvz1OMKxEZi52w1tQWPmrdo8H3YErgIzMPfgAu3mOjAgN/ClkMZfM99GSMPH+r/xFr5cC2iooLVEs+m1MTOzqVWt25IPaAw0a69Dvb13QAzwbc9zzgQoCsJnWxDiN0y1LVTFC2dGkP41yNXwnBlMxRBq2avXKRmFEJRLS+Pi0jJ1HQnr/WOBkq+pXYnXrjAyM4PcP6czHZElPeHhahlREb6Ih/B4l+S1ep/31WGRWX+4e7CzNJwSZuK36q9bM5y0pTsV8TsxRsNJD+Ml5mBmlA5EYQCWdFe7ue8xdoQ06DBMJDvtbslYXVHpOg3prUnav2nTFp//t2By7hxXHTYr3ckYPiub2bJRwQQG+g/d6VgTlaUM3N4cnzQwghyQDH7ycPyoikZ0kMDE7nQ5IzCSsNZ0OrtO73fcOrjdDjexmBHkdZHG8U4fSK1yk6VCCqwoJCd+0vFuIufdftweffOMFI0wEQxXqgMmbu/dY+ugDoB+IDnzfgWBuH/YQzJGEJn4gxamcecHTVtBdXFKGQmxAzEW8qNclApG5cZXkilZYgK1AU3jl4ifOerU4zkrs71gh/H853/+ym0tMjJ4pwxdNVNEi97MkqA61WADqOwA6TwAxJcQ00QWuwFCaAAJyfflSAOOCod0R9WeBsCjfN3PWtFlO5vfQSgkNa7X6aQxgaT0tAWioRyRG9JIfiCIWh0I6aJ3QsmrKqMN7lursq0huH0MVipVHoOkObYMACImHqMdv6XAP77Ko7+JdpCQSq40xmRcMwR5WwONFKQyaL9e6EDpRF/0Sl/j8I7GCoAh0FfcSF4WAPEHsdtVeZx+bFcg9x6zDKFNHiPKorbUTdBD5Ud8Kj/ao8ic+KnX/Yq5Ut2htejnqBWlYwIJfyIuCqDIpG285S1vmWr9D2Kljsszm8HzO5vbSNvqg4n0h9PGdwFDw6MHScTZFYjAKjJqn08EhuFHBM/7MF+9CLgi2iDS5WiQX2M9ybulKxFGHBCgDtTreLuQGk96x6UbNLvZny6wTIhGuZQPDcQDQuKZBlIbkzQOBhFUVMsAI4QJCJBeIdUnklGsShmUVBtq99VulbiFJ88fS65Q0iYlZH7Gg2GmuQorf6kRJD+EnrL50mPcaL7aHBejlod+WPz5eLbIvEVd0obULm5A4hnV7cPcQKz7+1SHdhf0+MdH8swacik0CR/MIIi3f/DIkaYtaEW17mxe6Cz0NzB+FjauRZNKtIiYGHDsADqL/ZiwOgItDZM0SacBiRgusQTiN1MIQtN5gbgrXuoqOG5GAFE1H21NyGWkIK+fqLj2h3TpGqpEUL6Z1uEkt9/pXDZxlG0I+i+lt8PoH3YMHqRifBcA1QjZgwlALeUzwcczQT3KTdogCEnQMj15nK9iIIQBdTeFVn9JH3mqEHlE/AZv2fndbhC65szxftc6LaS70zEpaAE8Hm+WGpwsxGVs4hLVcnwgULHMpvGjrGw4q6Ltmc4ISBrbFpwed+vW7Q+Ce/LS2UaHlZz4UwUaIvBUaXFzOxn47qeefuIrq1etuNZRIGROQA63i2Zg8sqRFwI0AyEtTCB/j/MAQtp6Opdbd8mbE3U9Pn93PZPKVn2jIb3Shz+RpyRK2t4KOX3cNy9zzGb8slLqgSfSR0s1tycfv3x8KmaghPV6c0mi0wBRlOeAeuI0oCsoXRsIgwloTlimQT3SS4JinR+SRroHjStNAshcUBJ+eKFy5jDqS6mq8PLV2hR9ZgyOZX7q4wPjeZ71vxvZaMQs9TX0zlnawKxZplkHwbx4unq6u49ce/0bfl4yW9u/DeSzxDBBkJEw6CdHbAiFdx4QIo9zGclt5GuVhvJzIM+xgushvcumXUnCp/a1Ksv5cNWtAPxoBAaX53dcwtJjXE35KYMntAoRWyOd01viO2VyUx3s9+esII33VMSPUZGFNt/hx1Ah1TVkPDDbvG8pgRrmBHIhRMDp7GcOCTOBp7KSxhXhNWNtxQxy3CnLcNl5XZwifOtb35qv//n2H2EUQ1e6NGfWQ/Nsze7mMrg5MOBgebof4OjQISKxA+SQE38e3soPY/BtuUYe0hEOMZpR4LaCCllA0imgntfvuH4gJhO+45uKy8pHErd1NDQBSyvaf6xAHYwTWoIft+VYXNfjX2r2e+VKuqPa5w8HeiBmCMvXp+H6qfLKQx8Tg8lCK+aUmFkWUxE/YWlOpFWE5R8GM/24oJVUhr/aGHpsjxw+cmep/ufVnpL+6UdjdnYpp75YezEZ/QOHb3NzWxmDHIeLGghi1MFBELyNRvghDkvCep5jfXd9dcLk3UTuOlBhE+HJk4HLUGMqgmjYMflyDWIxX2xkNDNz3XYbKVJ9+Xvu96/32M3jpvInwktjbCOdBj0lL13S0OcgcCn0uKTFtZ/3mE/NAWNSMcZyjCYx+BqBm+DTnIvZ1Ih6qva3GiPSkr+3r/eWMh+DHThYvue4WQbNbudUZACMaEO/LSfgqSd/dI/ukm+imHEsRyUYEfyeu1NNdiL8qdXvvIy6vyLWMgLEBeoSlfCK8K0FSBITBp3Qbn6/LohF8dyFV2jfPkn9hHtj5eGaymW9rKGgX1P1rWzWMTmdtfMTZHLZefke4+h7anwqvyLs9Foa/CsmRl+x7PMEkWd+jxdxpAM8Xqm01n9pQ962PJVVfodNlY54S32nxf30pz99f/5e+pngBsK1SDAbg065BmeDmHNe38meRcur/f4cgphKCTGdFgDuGox0fgchnw/qCNoqT55GuN3EGPLyg44wign4sKUOY2NDYgqy8udPmSidN2jiiRVRmIDz8qYjBNK1YgQe006pHz4YlJdZcrEIos8h0TOCN+F7PKZym8osX0hbB3YFjhXor3cAsENMBbnWcHRoqPjyl7+8Q2lZ94NguRYAckxulAJnK0zd69nZ4vrgMvhMQtgB+NkwNxuikphMElRE72WBXaerS2rCW+CVk0/rGnnzRDnx22/E5T3CpN7Wod4utIAmwKoP0WcwXv7aUQe3ImdxiRGw1k5PlqWS5A4zY3g+l/SUh4Q0EVXEnnNQF1y69X7Volu+5nnsx+WhnQ5rb6yHqnJoCml8NqSKmMqjA0BE5USfJz06dDRf/+dCaPIk5hlnqf9UYwAexnywYxKwA/T299/r8wBOCMGb6JnUeMu0AKebyjVyTRVvojdR5+lM8A6LtOyRo5mIgNn2anrUPliVIa+7rTy8FHEY67KlJ4Rv4rfrMurupDVzmSARST6szXYB9wWibwnHSPStxqlleWVgPgYEkb8elucnjr4A+Fsxc8fn+fCHAVB8pVU8jA78ufe+e+8o8+XEXy/qlHmfYjZPifbn+n1MxgP33W3jjCazuQ/e/w0mUCIIKbwUqE+6CSVHWBNBXrI23MRUGs/EuH7nvnzadQAnfziAFD80EupJXkoyfPmwTjABtAI9iX1p/dts3qgytyL4KizTAmw2mZKAYywa6EBf8zHhRz9yaFUOBNfqIV893GXVw3ln287bgE7nsBSeNBnaR3oAfz7nKY54DaNGEW3QgsBlPp/bav1/00033VrLB+6Bi43BqyWYza81MpnNTa3a5jYz4CyOq58N6+rqWvXgI99/KP/5cAjcxF+VIA+n4Ezk6dRgMkCRxlIjGQEbErliAJltgbsHK9AepEqtXu3x9VK8+645x+UuPyzCTTj+gZH4wY8ywXh5g80YWJ5pABWx5wXJz3VncT1yGc4WXU60IDeW9mOBOiGY6CJvSYD4EzG/cDpwEU3lHkvDyjSo/gwL+SF8f5QVTKFkEhEWTCKliXR6ByKdbADeAuQdQWGpH2lU9qn489/RwWn+NLP1aRLOoiiw1kyAZsGBox+auEH/fDjXfbUCIz4TzLEziBwGkdMx+VoSf0n4QfT1AwfKkxN/B2v0EppOWymcd19t5TS4vhjT12G5kzAon5qjoYgbLjrPlwF5OUH8eYBSQ2R1Qq7W7k5rSvT7FO5UhGoGQzFTpamKlETOv+ITxVVRTR40oQzMhCFS58mJv2W9qiuHXLuJcvLIFn6PW19v3/0sNcskjQkOTGqR8RQIak0lp0DD1UQwwzPLZPTYDrB48aL1rbrgiXRczgQIAxlADhA4F46BdCL+StrXiN9XVbsxLt+ur63G0mx/q7QprFzZ6OCKz8m7HNxgBFpiACIh/U3f9A+XX81UjIEfRwiA0BIrmUQcCq+HQUwmLMfFuluaTa5C5+PTkuBLorP2FeWWLWo4qV2N9xa+jDEkw64keLnF6dS0k7nLXZiRw5zObp3o/YtAaGeRB+ZSA3DlwQcfeLAMzom/lvLUep3c01Or/SW1RKNjUmwHyI/C1rtkgsjDQVQjfB6ub83yV6nfzRLJxJ8ngsjzJxCs3GaabrupqQx9sRagPf52MaX86US9r0G3zjzwADCBDohEnxzTV5A3mF2i1PBzZBq1OH6kA7d8uCabccjHIvwiKIfjMl5+8rQQEA+/2swD4Zv48/zP5691L72irdWIv2U6BdJVQ7s/KXTAMbgeNydlyfiTtv6nb9kwuaunjEvbwXgEZ2UH6OnpWXj/Q48+jh0gGY4SotKrnPDz9a/tAbktAKmP5JtO+o9Kne9qseanLkv6nOAdRvx0MKK6u0REuFPBqCz/MALcJuAaLzSE7DovjuDCAGzQDAKGQjC2iag4k59D9DkPOEZ/JVnL8nLCb1UE6amr7pLWbXW+kP5eDiDdS8yNvqgcXCCt/3lHkeHyD5hZYoDMP3XxsPwJf7n2J2+uAZgBGGd+Etf/0Wf+/ARAk0o20Nv7qPt0zPu/zlC6Xs/GKxZkIUwAaryInsdnxlnP8+RSvyymCvN77po5TEfoefrcD/HPGesPJkC4tYJOWUjsj+VCSfwgctf/z957wOt1lHf+c3vvV+2qd8kF2XI3Nm5gME4Ag1lCCuWfBLIkZPef7JL8P5vdJCRZCEk2kJBks5CekEIJNSZgio17kS0L27LVpaurdnvv7//3fc553jv36L2SYF2uZD3Se2fOnDlz5syZ3/M888wzcyTl+dl8ucABAX6YQJ6iRUT5tDOMGOPQ9bRGMdOd2EE0dMopzg9tKtao3DvSi3fG4+AH9KRNygA6rbL8PLMj+Xh6MdcAaAM7vM0AnvqBEE/B7/fKhoXAH+c5F8f/PN/ZbAOg/lk7QAl2AP98uGPWjGguMrhKRIeItYAkdeavzxDQkaY1nYfmz5QclAofpWttfXqEJjCBGp26/M6UlMRgDgA+1gI8jrSP6XTSH+APFleG0ZI6u8wB7yGbbViJqPk+FtfzQg74OLQTnBPIbJwfzXJIjOalq+eLQwcrbUOc6crcGFJd7Qvo8gsBxAtUFnv02WtL3wfbrceMYJakT2/EtKhLYl4478TJ40h+NL5kaJMygjSfgz+fl/bhp/Y5U5pj/M/4kEIiDnqmJc6PfGfeAvOjvoVqkRmkh7DzmR13kzFrBzgV4LMF2445SnSJ5X0FZsIXgsylNZWiXDuWWsUm0o6dleocO+DJnz1PGhSnx4wB0Ds58P2YENBL3w3T+rBJEZuc8BP4rfOnzI7nd2nvoSoVJlU3fXLdpkYBo7vn5n3zYXR67kI/q4Mksm0uIkbJdCXGy8mJ0TA9PmIhcXbgGR8fDlMT41IKdC+FtEeOIQwMRz+T6ql0J+4/e760fX3mILYhuP0C8CfpCgE4DIhfCnjU/uzMB1qSa3L2LHP8Scf/uP86zdI6PfFsC12YnW319vpSf5jYGdsBuJBOETODrA0gJ0noPuswAF8i7AwFIKgAK8Pn6rObWXIfIxnxXKLHgAbofuzxGPx+OaGP9+O0fFygN1VfCe4/wPNBSPm8qixgFSKT4AKXCeoUZLPypWX5GHvWufTAHJcUhwehKtnsRTpTUVWZCyOjJ3ez4pJ0R2IDrRhXXq+auUNeu0hBzBnATF3i0CV/kq57wfjEiGPwc62DP5b+MQMgHWYCOQMhPjoy2r5o8aJbpFzChZkG1AIMm35G+NCwiWqoyNlGZ/sQIG5vOHL+ebADNLc0bSGDAVahdwrSskyANMgAI+AwzjTwaw/5JD1RNCjLLAB65Q5+Ydk6cF6q2hXJH/YinIiYAKkO/Hy2NA8SXKIyn+wRV+39mLC4LJkJsG2vdWzSXcCYhDepQokqnxgIJfeVQ8Yw11QjoJ8EfADPeUKuSkM7sGPKmqH8aSQ3MJBWpMWK0kiS+o2MzjZSMl3JbMX0lHBkBkuVl1rpk3rOLtvub8MYnU0B6nUyIOt+Xgfeb2LwS+o+U9KZgR/QQx769Wfz57/9GeYK84CZK8NZkA4HTth2wpUL2gHoHJADxQ4K/KFTTbOYJt10skAWS6JPTk8qL6BSv0E7SL+VoT0HQYKSTQIrrjJj6e5xl/x50BcAvxUU/4FJpJSMp9MDAYFZCaQmt3cbRnL2ZKZi6c4IHEFKTACXACkt2QKyOPDi9HxcDcK9zRaA27BxRfFkgXxK6v2Mh+N0gAkwVWkGwqkZ0PEFZ8gMsGo7WtGNsbyX+P7ZY66DORezVNoYHikvDH397m88mpYUq/00KpVPKv3C3OolL2WmN73kt35BbhizepgZL4SwTGPa/TfffMt71Mvyncg7UBzSyYq1Dz4aMulTpuEprkVhOYnHXLHeubQAA5Y6GNLfKd5Nlo7qKqt/ygrlgc9b21XUlE6cgo4xO/FJ3TOb5uUXCune1JMddag7vymVAwMy4JOW3qPQ9bPSElRbeZRp06Yy4pn6a9I0uRfn+J2KuCe3hcEmobW8beZJ49ImUyxoAuRyUmKvhuTjJ2pbfxYDfVIP7pVIeFYbJvfnGMqr6Wm9qJrX0b7hqDx+TOjtgdbncT4pbnnSMuP8xCE//+prr/2v0qrm+vw3r+CspaRFz9rqW//32sOd+cGZk23CsG6dhny8bNlmLZ5hTJ0wfMBVXK7OE4H/NMXmT+O7b/77aYpth512OjPcKZ1wUn0OZx9CftBccSSdGetkWJvQmNs2DYU5Gfhm+mO2Uxc6Tu5EZ0+6Amo4AM7pmb08Dz0vIWnUw392DVw0PUchdp3UIoA/KY2K9QhT+ox3oa3DXO0uFTOAHPxqPgOiK3kOTvJ4nYnbkMciTDXq3mpLmNGptv/GVkGzcVmWvF9o++/Tff674PXZ8ubr8dnOAOZsV6YDe7o676ODngn5YjfmyZ3KysotCvinx/U1mwIbcnjegqFUciOMiurJE5KuSaej4838/FpnCAwdnAlwzuN8c4/rxsGZ5tVtlxzncSBQ5CBHmgMQpGcCJlm7xWgSQKFO+y/hNsnaB6zyyY9mw5Zg9gTFKd7BTshxInnttnYdMUDvBAATjSDlaH5CoWlHPGfKDDnFx0nmIt9W3c/znD7M4ZYwLJgi98dvgPjkpJ4lZQR+XRy68ZQ003oK3P9s//x3/LyF4ucKA4glvWsCwT8bZuqxOm0hZqDZ+0LtYmkTmqpygglMYGFLqURDgzKNx51dmJaQSi/PY5v0ceDcRdFYG5hKP00NqPmm3gQqshx3CP0Dm4T87Jt7Up89JM0krPX+FI0qfwb0AF/lGdAjVV51NGOoMQblmRtz1NzImcLJYcIYkvRE4qtSluiMwEEKI/HPd1NHvjqMF0VMlldtAZFf6JUE1xsSkMfGJ5MPegjc9mGPFOTJu03An4A+cRhyvo9m4NI8vpfH9fzCfmEYwBTO9s9/+3POFXr/nev82ZAed+FZdgA+H/6aG29+NzBQb9KzpFnTowTOSMKk0+U7K51YxFiTDuZUUioJrM4I+Kem2QJLvIZPfNHb1KkhOj7SNw79eg/dRqDBv8oXGNJrk+s9l8qK0u0cwFH52SkzU4tT6ZVgfua10rldK0j8/FVV5U0kaGIfseutbZLn9ho4iP04G3J+Vp702KS+MtOegH1az6kDGtSkPFLfv0CsmElfyqYFk7pQj4RhW4xy9R5g4LZZa8rME+1ihon4cyUh6xESrcfT+SYgZMcgn3aI2od7eV5C6ny2f/7bHvgUfwqzvlNcMA9P8d6cXPrn7QAadA75yWyIZCiy7+PBNyCWFMwmHwZ4qoHeDxTyxWEcbuha/AA/ROhaAfHszzLpD8ZGmzfzBIWAio6pgvMdks5sX8CJ8nln9STrtBHTMMmm47yES6Wr5/fQrtMBYP2hCHDrx+g7JtR8mADS3jcUcSarFslnRU1nCpXndolOaNfiUmznEqCb9gJDSSm5Zua+/gxZzSbfBn5hgdD6g9rdNYahoaFTff5rhvMUKOtsSZp5C2dLjeeuZ/xCYAQBO0BXd882OlAyfk2kSDwUkId8vsSSEinYJWXmL59PzESKNa3FD3sAWgDz/EwJwgQgdR8JdgFXP2cGcRE5UCHSWjk7T2hxlcemn+bKWiawK1sCaIvIkIa1X2mRxKIck8BIK5F3/uRADEh14FlRo3WhJRf6Y2XohIPT8ziDOVVoedP7A2KkMoR6D8hR+5H2Xna+jimjJK97XRJ3yjIlrkuGNDR2OoxJ28jtA0k9edTkXbhhMAZ/HJfPQlJZv2kmPFc+/5V5rFmHc/eKWdnOygNjAnw27KTtwiMVkieD45cUp55pJGiayhfOcJjXApinF+DdFhAbBcE/zKAEEDtIBQEd5X+UZVt8wyDY6huIIHHIr7iT7f+nOiHFbLlugm8/rWv12gAdeaTWwmgcMOAvAX5iwLOxtK40Bqj76uLkpzQHA9cCLkCWB2j+bnNHnHFYDpWB5yGOQFAJTj4ORMb7YgRskoL6D8tzcvB7/T2dMF8XB7/SkuXASdsk9abO1D/TlZ0h61yWWAPgBkBre13rUj/Oe5rPf8VZz9r4TK87ax/BKp59y+j05g/AZ8Nu5LNh6iWJcJoZ99Jx+EHWiTSex7uPvpSbVtPINuASzewB6YxRMgNvlxlAZbJTB9JyIN1AXVG9FDsAnVJFCJCmjtp9dD/Oc1/CDOXPqUrJeD0BJJf6ajWdsbo6WK3+DlxlnJQUNgckAWBKkpc49bK66TxtYD8rh/iMEPS24B46k6ndyYcz+ZNzPKddBSMQwahKAK8SYXE8UxLqvO4L+BPmRf6Z+1GugZ9yFGdJL9OT5rOvY+6T3BvGl8Q5TsrimOFS0mZJWUmbAXI7VgFJfnpFmkbIC6PeimNQzHz+Cw0TqzAVJT5TYR2crZQ88dla+5l6Z18G0j9vBxhPPxtGdlf/PZwpAlUUA1+iCZjhPtIESvXK0QRKcuIrkvT2y1/MNWO6NlFNATI/O0ba6GcbZHBp9AMA/iMdJsPP9rhTZ0Q62S+aRTDGwrGBg7IZSnCv8jRvuSRxwteLS5I4x/bDsp5Sok7PXO/p2dCZRzYkX8w8/Dp/HgcT6cT9Z+eFHW3XoPrOyB8vKwEkz50An2dDo7H6qiwHf9I2MHPOqXwDMgxF1xpT+OG6tmsCo6Oj2xlC+nMpNI0yOj4noupJ5xTRw71X8cJsm7DOru5vrVi29E6elI7kxjljAkgKdR5oSlNrEEwA1bmklClCubKqJABUJhsB+B+XM0sxn+BSB56QsQr3U/IhGLDqe2dVwqy4WfxNeHAGAOlamIkIhsH+/WZQsJTZf+jYThrBqNzkulw6lcg57Blu0ZjQNFmWvAzqB0icpDOoPJgjVnu0hCw/TXJyLiY/zqvq0cmZ2kaJMK3866G+GP6S8wmAk6tsvwIlJ885U1e/nz8HxzPx5Fp0sNm1jO5fIApjMs0gal+yPfjgg19Ns9OQcWPOcNE0w9kcFHxPZ/MDqe4z83bpi/vYxz7yD/5MhSS/nyP0DlUmfAEofghcjHNQkU4Ul0uiSxtA7S8TEOMfzMB/doH++HFx8biYizYFTX+sIPQ4k3sQ21clYIhUU3V0T5uykY2YB8OVCPx2cfqHZyiTSpH9FWHg1DLhErPKJ9qGgSiDGHAeDzH83vE9iHueJM5zAsj0h2aS/viikGsq3r6+rNc0mbQs4pZXZZjGJHXB3YBNMwKs+nl9vKzkeObdzcVEqWeeTmMAPBe3/8o/exTJvProzNkZ5XlAKuJx9jZhj217vLSsrNalMxLU4z5f7B0qfnSkohvScBoy5z65DOcmZvjMtByE4u250QqyNJ0uj/V0FsP5ilhPY3YBLQGifvLyjSiR+CSYsS064/WLkgpGeb4y01aS00h6rvUwLmcuLSAuGOA5Ubap6tEUnYPb8xDGDBgmYDMUSve2T7bwolyeF+0qZbzpvfxYJ1NGAENIrjcLjOIwCx+CIN25hh/G2VLtl4BdxG0qsQbgmqDU/52LFi16s0YA59zyX9otpqR145SzP+6wyattjOV27dn7hfjRHPwexufoKP6j47DFOCHaQDlSyaYKy00b4Dqm79AIYq1gto0gLj2JA/5yGz6ozIhhJMOJNI/ejndeproAvvr2LBCTcybPya/T6i9QlOqjokzTYdzi56BP7nTyX5eyc4bSIig7mZIjBGQR2LBlAMzMD4An+XTCrkmkPullcrSakfQYUhNpP5MWS38YeAL+xGCqclVkVvqThx/vkxAyYyJZ02MP7aT+fPbzn/9L+oyi57T6z/PSZOcS+fPwprHMYeMgrNZHQxqf2vH0k0nnS6Qp8Vj600mcvLNw7JKR1X9F9vltySZcUSXBiiac3yhNwMJZFxqXx6DZCeyInX5mtAK0AaR96eRoKCqvCbnxoTBZerITUmJXSAtIDXvpkZhBWf7Guu9MxT1DGgJ8QDwX4AtJesbeBUkMBNDGZKBTwknagNrK29Dbz6X6rOujNvcy/DrP5+nJkMNTE8bnUt9S1UaM1CxNFYulP8Mhn/4rJP25nvy8w+bm5o0pAxhWsjMCNwjS7nO1EMWcVXSuGQF5Mc4EXAPgGcfVqUaOnej8wpJFC97KGwL8RcWy7dMB9ZN7bpFv/KlpPvn1lE1r6ypp4qiLUm+ljycdszRRmzX+LhEzmEoZAmXmcOCZ0Lp8LS+ukK0gN5GAZVJrCrAT5ElxQA/4IUI33nGcXqaY+pr2HEjsD0mf8+WumrFQf2WdANVmK9CEpGLrWM7NhFYBbw7PkYQxyGYAmpyTMJ6DomfI5IjL41R8PBOnmokUz+bh2CkGPGmu4hMH3NAM40lATxrn7A7pSe7Lzxl7VvXnGqZ5/amI79q1569IFzHlB9eOtYBzCvw8pD878XOF6CH0A57NxVUxUnJwoO/5m2665T2cpmNI44QLCDBJjxF+i6QRFKFyqjPliBeVlMolPzViqWdyHWvJkRZIwxIZ1cQhVJ5umILcNuJUAhtkFol5lGj3HnVFUz1N+qj8kvIqMRKZ9DR0mMawqLE/3oRs61UqSSvdJAnTMvUsRjAK6sC9DRjEVH/VJ1mSkKJHgdCg0qgvGdPriJPmarWnkzbXz6+Z6zzp5Mn+uAcUp5Pm5SUhx8m9xZBVh6Qcy0e5VrbOU77OkxetDb8AGCNvLmEKPFdyLXn9+Vz1h4lbPaK6WuX0h/o7rVmz+r1aQ4L7OBKfaSBAz29GhdPBuULnmgYQvxd/YTwjcdsj4Md/8t1/u3nTxndhjJrSgNu356IzCeyJ9quepK4LzqHkryJsw4W0VNeTCp4Uz0o+bjA9lWgGNvc+JWu/0mwDDEIxAubpncank1WGZWVVlmTSP4nada45yErgl2RC9Uf9dxBwEoOh5tbzdbULNEsB4YbrnTwBI2CwUwaUJE9ynMTp71wHHz01UV5MXEOaX1vovtTbpgBpyRR7fg1M0MvIl6y05OMezJAkF5SKezt5Ph7f70fokp98Lv39GtYfwFycSZH+7W9963d03YiiWenvfen0DeI3OEtCb7uzpLpnXE1/LhAAPvnlbQEPPfLYd2prqpvKyiuny2QV1tSTJH9pTl/TkUgpySFIuJN3JjqKE50MVZEOBLn67B8AtXX+qfl+5lwCeC/DQ+wIMcEoYsK+wHACYq8S/9pXZt8SMTIeT3WZfbmloRR7PUjIP1MGuJxz0BKHjNnp2bNhcvbUf+N7ktO0KLUqdXQfBrYEMyOieVwmbZxMg860d3Lt7OMsn/Nn8pBrYsnPMQzA3y35eKeU42G08SeSPx7785J8zXiBFqb0s5ccKGfvExSuuT8XPQdhDEKwsunDQRV1191wwwUf//gff6GyvKyovKJiWlKgSC7DOWkBtlOUdxDlt05C6OQdJmYCnMN45OSd374bgOEsQ9gTYlBxelpaQyFyJmFaBL78KbEK0WkylYouHX3hDdtuZQGFugxTSNRm1JvT9+mUn/ntziwE3E7oUyJUdCezv+hAZjfNb5TkG4njLMDtGuw12oQxBrmmXzRwSuZNc7asUi/bVQpdJF1CfAeeXmYPCWP3613y8z6h+rq6a7WRSIeivAiYwDmz86+eZU5K3sycp8/qE/Q2792JiFQfFFAnO9rb+w93tO+86YYbbpfqWVTKOF6dhfXiCAXUUX7eEekkHCNVKJAf50pkWbZ8SqdD8TMGkR7bNXKGsXGr7dOvzqgxfZKuplcN3ciILQH1151nVJAkcjIPTqdN3Iy5PvmpCqG0PBkiiG/pvKCs68vMnVn1Z6pS2g3uy8Wl5dM6b7YNAJLUNRkGMMVp9eZ507LjUJUy4ALeH+intnB7B2N2G08VlepbIcWysBbDbK08IVRsSqAvUWgITTQwPYu2PZT9BUuK4jyvCgHR4l0W0o5AmnQ7HYOfBE3X6lVh41E+EbmTS5L3maQVhXf91E/dtGvXruPqG6j/LvG55zkr+Xl26FxmADyfMwHrADo27GIQ3Ltnz8FDhw/vvO76G98ocJkhsET9SsixDmMdxfqXOmrScyiPHmQdW+i0eDl2gTRu5wSifAigAEIKrDyTSMtgjTzMwVRiumqazxlDzBTo5KjR+V86XPBrCPGvN+9A5eNeQn4OuwDSXk5QWneguJ6vTNyAsFxDH2NqisMgxDRmhZR5Kubg90YSc2dmTiykvYirXSPAJgAP02NqE2fI1qZ6KN5LnlRfO2ZIQpz70Kb8nBSfOVBiFvwM6cir9Hw+Ekr0zDZ80+Ucv/mOO+64+xvf2JtO+yH5+bnlHzWGbFaWwnOO8o1zzj3ZzAPxjPFQgM5n9gDGA2IGLQ8/+vjXGhvqm6pramT5L83JIQVKOr9CyJiA4m48ssToD/PMED74xN0X3/MTwiic4mPikA8dPI/bFexcNJSIvzBU6Dq/XrvtztzQE88wLJr2Tc6FBqYz5iCBVBJ8GgdoewiASRrZs88TFyG/jIHR0bG6ysqKAW3xVacdf8bELHgvauOZeHwN76QQOTM4EybA9czuEN5x55133HfPPc8UmPOHAQB+nmnOZ9e5s55eKQyAFzUnE1BHrfqXz33xExds3nBDdU3tdGVFBV5p0kA1LYXkUcfzMAa0jx+zvcDzkJ4HdwT+OL+fJ80ZxKy0lDkk59UZI2BicHQyPwWp1TFzQApzXt8L+L9+z/51JMpzgBN3KgT2+BrPB0gLpfv5MwljoDtTcCbA9fH5QprAgQMHD195xWU/omtHTgN+ijvPAGiFs5wcAIWYQCmGQZiAOnY12kBzU0NTZVV1ToZBkxamBqdMgHYoBPwY9OSZBeII/HOlx9cAJlnL87MRxDkPMUwmzKfBECTpBSqbxdDUYyKJNa6e4IMBpyCA6ID08BTZ88A/U7BT1tT0lIYVJUWE2bI1FBnXtuZzzXVms+ePKc8PYrCfihnACMbGx4ve/Z53vfXe73xnXwp+fP2R9lm13znrSXX2+54rYb4hz5UHOsVzxM8aTw/6kEATApoSSBnBffc/9M8LFrQsZ1gAI5BBDWebPPiJQzEziJlAIaDHaVwbS3zK8fPa59+ckAjJFxNAj48LxV3i0+k9Tr4Y8PF1hSS6ny8Edi/L83hYCOScm9SMBav8CKE4bgmn+ON54zCbPWYInHOmAENgeKJPs4389M/+zI9HwAfwbu338T6hG/0o5rTtTKaznWJQnO3Pcqb192cuqA2okFmM4Ld+56PveePtP/Iz0grCXEMDv3EMYo8DcphFDHbPT1go3TWAOB/xU4EfoGcB79cDfKdCYJ8L5H5NlnGcSqo7yP1aynbJnI17Hj/Psec5XejXEsrQWeJ7PHg6x08/s/Nv/8Odd/xvDQ+GU4nvwC8k9bmUhnpFAN/bycHgx6+UMH7u0zICGoXhAczg1ltf9/bW1taGcvkQxJqBN5xrBKcCved18Fv5kSEQQGSlfxb8MeBPB/zTgT4LcK+fh3NJds5nAe/XAODTka7VFKxWVCg8Xd65zjPFNzGR2DimJsbC49ue+OIvfOD9HwP0XHMa4JPlFSf1eWinH7rhvYCzPIyfP8sIGBowTUqY1wp4XpjBDTffvOjnP/DBO9avXfPW+sbGamcItn5AAIZ8mGAHmT8x+Dnl6j/xLPhJcwYQA5/0mLJAduAXAiOWdq3fN6u7l6HJQUv7QQGfLV/HSQOo4Hhxkt9nrpDpWT+XLdPT5V+tD6do1aUcp0Ynx0J/18A9n/yTT3z8nm9/+1gMevKnBr5Cqj6n3VPpFSf1eXinGACe9koM43aIGQFtAQM4iRlwQp3UPPhhCBz/4n/65bdc/5rXXLVh44brqiori6qqKhIX43TaCQNerO5yDRqDJNgZjfkd/FxHnNAJ8EMOeuIxiPw86U7/t0CnHAc7QPdys2FxbmpYXs21nh7Xi7Ts1Gk+n0AOY9Sm4rbBafexnp0PPvDAl//oE3/wRc8Tgz4FPKcAfazmk8axg57jVzTwaQBoVidKkl7xf+M2OS0zoLUwHhI6QyDuTIE4Q4dNmzZvXLdm1WWVVVW1cvCxNQjqvPLOyws9zTjMLOvluh8E8FlQxYD/Qcfs3DsmQD6XJD+t1E4ldpm8FJHcWUKS+14J7GI8NTwa+gZGdu7eveveu7/1jbuQ7H5NDHbSCgCeGwB0flAh0JM+J7Pi5CuJ4s7+SnruM3nWbNs4M+BaNAIo1g782Ka1CjEFMsSMgeM3veUtl15xxdVbYBBLFi9aU1pesVxOMtOMjdkgBy9BXGQh95+3g+hPTj7yfqg1bjMom2ZvcxgTG5Wkq4rsWEchJ//aJC8A9+uT/LOluYN8FpNhVo/ZuBTgs65P1zXEwOY84NZ2W2FsImcAf/TRh7Z/+YtffCK+lrgDnThjeMIM2ElycMdg9/hMGySSnvznQU8rZCjbyTOnzx9GLZBtq7kYApc4g3AbAmmzGAMJAlS6CJijk5lDkjrzF2bB0aqVa1dqmLFh0aLFTVrAsqi2tqamvKZ8OZt9ztp4RHlZnjwXFVqA5KD1a5DKeRrTnojpoiftoHxocHBoqKurcyfnn3/u+ef3H9hzgHghUJPuFAOcNAc58TmAzimX7sS9Uh4WAjz5zoOeVjgFZTv1KbKeP5VpgWzbuRQ1qZvmdUbgIcnEPU+cnkeqaw9pGfkgyzA4gUYBoLKaRf6iFzHi980CmlvGoPYqROAmiXG6UxbIfuwh+Tweg510n244D3Za4wekbCf+AS8/nz3TAoXasxBj4LIY/HE8PueMIk4jDuUZRnKY/IV5ONA8PhdDia87VTxb3hx5Y0CTxQFLPAZtnB7HT3UN56DzYE/a4QX7W6jDvmCFny8o3wKnamdnEGSOAc9xljGQBs2Vnpw9/XnPF4fcOwZqfC6OZ0F7Juey15zqPg5yyj0v1ePWfRHip+qYL8Ltzhc5Rwuc6XuImUVcVJZxxOdeyDjAje91KiBn7xsD28+dB7i3xMsUnmnHe5mqd/62p2iB+fbuzoP5FC/r/KnzLXC+Bc63wPkWON8C51vgfAucb4HzLXC+Bc63wPkWON8CagHG8vNtPH/+xbxCWuB8x3vpX/QP0ubnDWsv/ft5Rd3xB+mMr6iGeREetlBbzzWt57f3qbPzjMBb5Hz4grbA6TrgC3qzV2hhAD8GP23Or+yRRx656tiRI/9TcebW458OjTxvtgw//3KGXqe5wpezbufvfYYtEHfMM7zkfLYzaIFC7QqY8040Av/lF1544f3sBzA0MPi9e79373so94JNm64m3HzhhZ8jFMXONvNBIyj0bElN5/57XoOZu21e1jM/zMt8WSs8T28+VzsCesiB7y68pSeOn/hqfWPD9Zxkhd342BjbDnFIfGdjY+NldjDjUz8fGEH8nNlnS6s7i2GRNh+YltftfJhpAX+JmeTzh2fQAoDBf3F22tR/rtYDfAP/s08//Zbvfu+Bv6qrr7t+cmIi8GPXG+0BYGUQ1/cINpEvLdSv9bJI9vL9/jEw08te8MDv4fe2+uzevfsXh4aGxrQyuF3Dmd/6/Oc/v1J3PlVdX/CKnS/wh28Bf6k/fAmvrCsLtReAcKLjOxngBYjFW7Zseev4yPjt41PjN2rZbmhoaAiLFi3yfAVDhgYwB625v6uvp/vvNSTwbbB8YU0hjYCyXgx125+bZ/VnLBXgf33BokUf4qZDw7YHZ5iamGx/ftfz77jyyiufVHK2rue1ARprHpG/2HlUpXlXlUJt5KB3MFDpPOAZx49NTL22vr7uWm35tXFwcJDNP0zV12ewwvJly0Jdff0ZPSgfJWG7PYYIA30Dnz1y7MjH5wAX5b0YAPPnd/Dbc0ryf2DNmjW/58D3h2EjVIY0I0NDH1u0ZMlvpukwgizDejEYlVfjfHiGLeAv9wyzv2KyFWqXOUEvg94lzc3N105MTr+ztqZqK60E0JHi01OTYWho2BgAaYDjwosuyu8YTJ54R+C4hTkHcT6O9/X139Xefui3XwJG4O0wC/w875ZLLrmfbyBmGUBc/4H+gbsefPCBX3jb2952VOnntYG4ceZJ3F/wPKnOy16NbHsUBL2r9RVlFVeWVFTcql1vG6YEUghJz29yUgxAX/RlD7zx8XEDPuAv11eG1q9fb3kd1HZwmj8wgezGu6cBGCX+sBpB3A4Ofsor5dlf/4Y3PFRdVbVgLvDHDCszJKCMWBv4YetHOefpBWiB+EW/AMWdlUUUaoO40/NQqL01Unvf3VDf8KZcbvr6KX222r+gC/gBPWQMQCo743c28xwYGGTbLjEE7XgrBiDrfli+fLnljaX/qZgBgEJ7YBgAlfLRUj7/LWKbcdTt+x544M/nkLRkc6AR/0EobgfaoLS3t/fh+vr6TRQyFwPwGzgjYNvz7q7OD61bt+7/6BwMIGYCZPf6nR8W0BovIRXq/C/h7V/WW2WfPSvt82NdQK+x/PXUFiBDDvgY/JMCuJ2T2s+23PxGR0csL+Cf0rWtCxaEtrY2yxf/yTKA4aHB0N3dExplMOReR48dM01C4As1NTX2CXLAX6xtxUtLy0JpWdmJo0eOfEwg+9O03CzI4tvNimO5F/M4MCsxOXAbh4G/o+PIP2n24raa6upTgh/gZwlbRndn92eXr1z+nvRc0pAztoH4ovOMINuAL9JxFgQv0m3mVbHZZz4J+Ixxlyxa8p+bWptvlCRfQO2nBWA+x83nt7Pg92NUdMb8Dv7hkRG+5mtMQ/vqiwFMANSwZs1qDQUqBOgxaQqT4dChg8ozFfbv3y/Qd4V77rkn1NXVh4e33R+qKuvDj739HTquM+1h1arVoaqqim8RmCYAsNAIGI/Xijn09vTG426q7kAjXohKMeiJcfxRgZPGBJWOxf+/ZS3+5C8EdtoD5uTt4uXShtrmvP1Ix+FfycxqZA2EfondIj44H39hWyALhhe29PlVWvZZs+ptYO69dcHCn6uorLgeiYzEhui4ENKfTh2P90knzcFvQFd+xv7Hjx8PCxcuNPUdTUBACytWrLShAOePHj1ioN6+/cmwbt16HR81Kf/cc88L+JXhqWd2UHxoW9QWrr76qrB48WLTCOobGk0LgAlgU4AJeAgzqKmusam4upqaVRHQrKz4D8/bNzjy0YaG+hObN20wDSc+r7gxAOX7kY2bN/8D55jRiCkL8vhclglgFymWXQRGqv/f27d/36+mhkwum0tjiTWDuPjzWkLcGj9k3Dn8D3n5WXFZIeBTcdRb7+BvWbJ06X+XM86mrKHNn5BOG5N3fAe/S0LG+6j7Bw8eDH29vXZJb2+PAf/Rhx+20MupqqwKGlObNkBavaR8Z1dXWJoOERY0NYUTPT1hYnwkdHd1h5aWlnCiszP09vUp3ip/gvpQWVktbaDc7gkjwFYgRrJMhsf7i6QRSHJ/jLKPnei2cXtpUZHNP06F3OW9/f21TbrHxMTY8md2PPM18jlN5nL9ixY0257/ZZVV/9WnMv28hw7yuULPB/gh16IUvX7F8uX3Hzpw6LOPPPbIr6b2C96HayyuFbiGxuUxzX4h8ZkXxxdi9h3OkaNziQFkgZ59Rd6RZgF/4eLFH6qqrt5CZgdxNu4FAXYf8zsDsLG9AL9v375w+HC7LPxrQ3//oEn3CVn/N23eHHbseCqsXLk6LF+5MhzXWH6hnIB27Xo+tC1pE7jHw2blgQBvbW2tSfPBwQHF640BwAQOHNgvV+EyGQInLITBQOTXFKQ5FjVKM9BnwCy9QXE0hoHBkZ9UWUfLymUnKC1ZMzjQ39jf36epySEbdqxYscI0lcq66ks0DNlrF+uP/BLrx6fC5XW1VW0Obs553ENvh7lCrnENKonP4LakrGJa5bxdswo3SjvCfoGRcC5yhuDn/X36cRzO3CROPc8YZreGjs4FBnAS8GXUWp1KFD4r5R0lb9BijL961eqPVlZXXY/UhAB/LP3zHVrgjqSWqfsAdfuTT4RXbdkS+gXOeknRBTLu7Xr+OSurrW1p6OrsCkeOHBEz6De1Xv4xYdPGjeGxxx8LVwm0jOmhMqnwjN8LEXaAkdF+swNUVVeFo0eOhQoBOYTk2vb29rBwwSKlHw3btj0ZKqUJQNdee03YuGGjxUtKS2oOth/e21BfX95QV7OV5xobG5d20iftocKGEmSU9lNx+Mix/XZR+qehsXEtUW+LOB6npdltluJr//a1cNsbbgv6BqInW/vZtekHTP1EbmrC3s3w4OACaT+/13H0+K+JWzxw/PiJo7W11c90d3c/cOjQIXwIQvo+ibqGQDymmEH4O4/PEy/EGF7RQ4nCPS/bbPP3OAY/L31K4F/zmutf89eKv1Y/0vLAZw77ysuv/GjLwta3K31OijUBz0SHH9D4F3W8qqZe/vpl4at/92fhxrf8uGXBMt/SXB1OHO/UmLw8yI5gRj40hNUrF4WRkWGbAbjtttulrk8ijfPjadT6xYuXCOgJaNAKoOqaIQM/Q4GR4ZEwNt4fNsiHoLauNnR2dhv4iWNMdO3g+IljVjcHaHVluUYK9eViHFv5ECmSmyED4NenxUw6YzcYHxutWtjY2Hy8t7ebey9a0LqKa4mfCcnGmXvo4Yf5GnL417ufCK+//uLp5sa6YlR/DKcQMxYe9/rFZetlNSjPba0LF2qmNXdkSVvN/7ukbbmep6RGzMGy1pWUHxmYGNkn/4LB8vLSfWIS2weGhvZnbAlxscSdORRiDFmm8IpiCGcrA4iBzwu2Fwv4b33NLV8bnhz5MomiPPjxW6+qqfkQVvgsZaV/9jyd9oSA1djUEr7x718Pt73x9rB69WqTnv/y2c+Fd7/7vSYlm1uXm/FvYGDApPpVV11jBrqNm7eaARFmgDo8LAmM1X9KPxYBoVHAEAAihB0AgB872pGvytj4RFi6ZLEBfVC+BUj75pbm/Hki/f1DYZncjJltAOhODfV1y8rLipdpPj4xGJaVh5raurBUeSHqoK//6PlaN4gBPFRZUVHa2tp6iV8fq/Ce5qH7IzzzzGP2TtZI8xidHAv//r0dxRtW14YL167P18XBbz4UWkjlwwgvixDGUJSbLhobG2kbn5jeNjY6up10zR60YL8YralaIo7WNjwdFmiI8uqGlgXv1y/AIGLmMDYx9ggaRMoYaNisihyz3wAAQABJREFU5jAXU3hFMYSzlQHQJyDv5SUC/zLAP1E8tUlOMbfrXPxspcUlpddk59oLSXpjBuqEEB3/nnu+G1796uvCszufC9dcc61JeYmnMC6rPuPnN7/pzXkVGfAxU9DefsikMKAG9IDLjYNIfyQwBFDJP6zx+L69e8PO53ZpMU3i7ANAV65cJak/ETqOdYTVy6V1lEmrkA1gSPYB7AlY/iFChgH19TWyCdTZvcekRfjy4qaGuq1MSUL6jp9NReJPgAEQh6UKzTgMScMoKwltgH9ha/PGstLi4rmAHxtEpyenw67dz4Udjz8QLtx6RRBIQ3VFXdiwWh8M3TcYvv749nD7FfXhgrU2mkgAnqyitLjPqFjl0j/JbEtOn0sv2lpaW7NmZHj0Xn3huEt6Udd4/8C+/v6BfHYYg+wby6qqKjefyI1tkJazQcrIWGlZ+Y8sW7FqfOBE75HR3MQJTc8+KE3hRMQU8mWkkUIMgY4QC5tzTjuIQZJtkPl67C8kD35VtPTGG2/8QnFl2aahzoHPRuNFf75Sppw2bdp0f47BeETxuD9KDruffVrfrZ8ITLkdP3I4TE9K/RdYNm54lUA9HWo0PgfUnFegzjxl57GWS2rli8IwB1Mpl3qPj8DkZImp7GTQSr/QLsNhR8dROQctDtdf/2pdK98A5YMp9EuTqDgwo7HAHLrlILRi5Yo8+CkHV2OGAhgEiVcK0CMCPA47SOgJMRmYFoTUFTO06UekLedZwMO83IgYlWYgLq+qLG3Lgj8GPeW4NH9y+/bwla98NaxatSL0dk+EmubRUMGXyMWommXE3Ns9GD71T/tC29r+8NZrF4XVyxZzeX4q1Q4yfxg2wDhNi8lNNZaVlbwpFJXltYE4O4xhdHRq6+TEWJVrFLq2CttOSclgVU8u1xBKytfr3LVVFeXjMAXtxfDU6MjoUR9CPLNz50PqM+1RuYWYAaeTRkwynhPMwAESPfu8js5GbzqVh3qvF74FgA4O9rNsNn4u4iVSBffpxX8Pw99cTxhL/1XrN4S7v/FlGfquCN9/+tlwzatfG/r7EoMfDADQu2QvUr/ISRlBlTYJLwDinecEoCtV8wl16p6ebmkI7XIRHjAbAKC9+OIL7VryY6EH+ACZYYAkmZhHouozNEAriEkSO2EAKgdCG9DXwo0ZIUnLBQT3UPTrSuSdJLuAH8qwWG4zBvgqVFRVtVF3ZwBzAZ+L9xzsMPCPl68Pz9to5flQvfACA34IFaF7sF+5igX+ltB5vDR88u+eUfxYeOeNy8Oi1ka7fyFbwPh4wrBoYwjmpebbqsOF0oi2VZaXdSVnkr8yauYmithHIWlzGC3thx8Hba97lGD4VB+pGpOto6V10fXNzU29WsdRVNfUUn/LLbc8Jy/HfSVFYceBQ4e+lA4b4iFDliHEmsFZzQiygIrbdT7Gvb5If8b3gNt21wHYLDxpbGrcqjR7YRoWNPrSXEmoa7Rab8Pw0EBxs+bTY/KhQNwZAQBGv/3Pbg/lkvZty1abVMKBZ2pSymgREiYxMaAJQMNaE4/Vnyk21PtRSXOMbdDR9qfDynXJJj+M/5HSqPLkGVDIjAHMgNkBjIDMDhB2HOkI371/W2ioyoUlS5aFxUsWCeQVlpdy6ej8WvVMMA6m/i666OJQLelPeTgUDUkbABTYAHhGpKuvR+A5uR7pyZAB6VtVVW0zImNiCJBLezvgWGX0aBbhT//0TwPghwYnmsPRQ8+GdWsWhfoVSZqd0B/AH9P4wLFw3RVN4cYr1olZzcwWkAcmTvsVItq5qKhkUMG3kfzk6R0YbKgsL79ibHRweZkYAEMcG25JI6OelIVWRkjZbmDl2oVLVuxcvnzZ8/VVVfWVsi1UVtcs1uX1tcXlz/UOD+zAjrB9+/YvzKEdUAR0VmsFDqjkUeb3X69rDH5qXCoD0EEs1qyXx6kE0GtufEtNff171cEX9PUPdTAXfuzY8UFJu4aLLr5gSyzts48NKJB8LONFOqJCI0XHZcmfykulnHUoru2SZGYa8HFN8WmMafPy7q/P+LqpqTlR2eUFx5h7dHQ4HJM/AE5AdEgkO4SNwZkC0ntEADxwYL8xgNaGSjMCYhuI1X38BdAKKGeyc3/YfO3rzWGI+wNqvAUBPD+AQbloKtQXyjIA7UZkQwKGRlpkZHksn8DkhH3hk3/8x6HzaEMoX9Ro4O8+cVh2kf4wPNgTqmubwuLlm8U4T73pSfnos+GSS9aG116zKZTLAAExdIHJzkX+DJOTue1FJSUHRsfGJutqa246dqS9Cc3FmTLPS1syzKIdaR/lnVUsjK+hoWnP2g2bnljY2nJ1bW1NLfYGbAqypSwW815cVlLcoVWXD6JZpl6Vrhm4VuBlzjTQWeRv4KDyh5ivYVzPmAGwPLVVKtseJKcWzzy3cNHCZnWSBb29AwnoT3Tu7+7qmuW/uvXSLW9vaWmuKsQEAAQEA0DyAX5UfYAD0TmRJID++MFnLG3J6osNdKQ5YQAEgCwJ7ug4rDH+Uiunq6vTpBGLfOicWP4pG9V/9Zo1xhhgIpBL923bnrD5f6R8kyz/GPpQ9VH/zY9AgO4/8HRYtOGCsHb9q6xMpD/ErAfSn2ELEpEf9yNsW7LEpuZYj0BdmbvHUGo2AV3L1CXnIDfWcd3f//3fhbsfGdbQJBlGAHyIY2cCHJ8JI0AbqC3rDjdfd2m4fMvSMKB2ALxO3C8mGID+27AgFJUeUgM/UVNVefO+fXuSB1Zm175Gx0bNlgLwaUvIQzuw4zEx7JZwyy2vlfZVG8aGx7976NixTs6b74RmUGAGpWUlS6rKyw9LK/qM3s/2M2QG8354EAPL22Q+hdn6nQx+zesXlxa/vV6AGhgc7pP77Z6+/oH2vv7+8aHBweEaeZT4Aw0PDS+orqk+UV9Ts37z5g1bDKDq8Fmf/yz4AcWOp54KTzyxzcblGOMu2bo1XHDBplBVUWUGPjrWnj27VZbWAajj4akHSHH+gWAkuO9CMAeGARqPmsHQjYEtAjj+AJB3YhYH3f/AgwJioubDACA6LUwP3wHGuTCAy157h2YC6g3ccuKxfIDf1ycAHIyVaBXf+fZ3wlY9A85MXIOmEzMAnzEZ1JAGiQ8Bxm9/+1vhm9+8OxwbWWNpLvGdGZDoDIFzUGPrytC8YOkpNQJnBG94dVtYImYJAfY8RXHSfTajtKyivaK8dBlDKLwsYW60B0waO4szUXwlIJgnwyTO79jxtBjEoDFX7V4U3vzmt2gqtHlk/8H2u8T085yHfrRuzZpGNAOco+S4VSeuen9ff9+X09WXhbSC/PW6bfQgVo158ycLsHlTMVUkrpsDn/qV4sm3cvnyN6Pij4yMtUg9K26RO2z7gfbv4sjiL0/qL9cheeOXQVK4+KILX+eOLjACyKWNDwEwHD3y8EPhq1/9Wl5NJx/eeHjmMe23aeNmk96kMwYH9AsXLDRHIPb9Y7yOlMfyDhjpoHRG7epj7sLeSblujXwLsE9w38TWMGF+/3j5Da49GNourQqliypDSWV3mBpNGMHm6S3h2PQebj8nLSpeG9q6N4bWpqWS5MmWXX/4h38QXvWqLVbXtqVL1R4XFWQAFAoTwI7w5LbHw+//0f856T7TZetOSiuUcCpGAAOA3I5w+w0rba1DoXJ4X6j7MK7yisre4qJcI8O1hx56MK/qx8Bn6pRh0xKBvM6mSTU9KwaM7eX+++8zhyrugzPVT/zET8k+U/Xcrr37k5VYmQrQp3CYqmtq2uBDBLSCdPuzUzGCeckEYpBlHvVlPYzrlQc/c/3XXXvt+wG+OsGCrq7ejoGenudlDrxx4cIFYffeg19mTEjNG+tq+zAQxU8RMwK0gI0b1toaAHdm8bwwgPvuvz98/d++Fno156xpKD+luenE0gwT8PE4qvlyrfJj3A3F6j9jUlRa3dtUf0kTYwIwBWkneUlPZ2SzEB8qoB30r9pl4O6rPZi/fzayYfgmS9px4uFQtKR31umRoWQBDomV/QvCW6p/ycb/AAiwIAG3br3ENJOrr7rqJAbgQwGu75XL8/t/7udCZ592OBrtNhdl0lmunCWftZiLMWySB+J485q8RgD4sRcQ7n32gTA58Ei4YOMm87+AycYzKrRnudqetQ4wzbKyhLlqmGdS/Tvf+XYq9aX2p8BnOMRwCfIhk9eZ1Zn9fb02nct7gzFfc+1103v27vsqWqTny4aFGEFnV+cnTqMRzDsmEAMt+4wvx3Fcn0Qsp9Z+lq6ycKe2rm7L8eNdBnx3W22oq72yprpymRjC9rHJyVni0BmBg58X5/H1a1ZdXF9fazo6y1SRGo88+qipuK4aZhsBZxwITzw3xqGKL2htNVdfzrUfas+D3PJqJgB/Ad2bQzEE7Ap4A07YEICZAIYNuNEuX7cufKX1gnCgaL/l5U/F9P6wLvclk/z5xCjSMLgidJcOhqlic5u3M1nw31b602I2pXZfMgCkz/zDP4QLLrzANI8rr7rahhRuA3BjWnQbLWDaFT7ykY+EAx1dxgQ4B9j7evfH2fJxmIMzgzU1S8Liss5wdCIZBu0er8kPDfwCwD9w4u7wujdeFjYs3hTaj42Eh54pCW+8rlGLrDYYY62oqDQjq6S0ngFNQHshyM5x/ESn3KWHwsPS2HCKgpDoaGAwCjtOQ2cGpGF3aW1dkDhEafZDW51hN+nVKsth+Qc8YBcW+BP3I2wF+E/UaCYhNz311J49ez4QTSXOGDSkZKqoecUEYsAVeMyXNCmuS17qqwa2/XRjS8uHRjTNduTo8W2M8akZ88Gj4xMtTOPkinM3Mg0nFdcMQ64JZJ/Awe/2AEn7q0aH+pZ3dHSYJx7AHxjQAhyp+PX1TXa5L7LxsuhYN9xwk63iQ5qOazzphjbyoL4PajjgK/NKimUQFOAwCE5oLB8zAGwBh/tKw2FpEfevuNBvccqwrezusLHir4xhFcoYg5/zd479RshNFWk40TvLSYlnxsB44403hldfd52A0GpGQMAfS//4HsdOdE3/f7/yX4thAjHFWkGcTvxKKU1bVibA57h9smkWI8BYONrzqDGSy7ZcGa6+bH043jMd7n1SDlGyI6wrH9JsQ7HVc4M0A1nrjYEl2kC5MQFmVA4fPhz2799nmg12Gpi1v7u5mAH1eevb7rSl27xLmEWuuGxKviX3j01MJAsQyFSAFrW2DBzr7KqDGXCa9ROahr60Qi6MmnXwXZGzw4J5xQRi0BV4xJc0yevi4LcNKFnYwxd0RoaG2w8eOrwtW6PhdMpIquD146PDTajbdN7xydy9Mm2bNdevcfD7MSGM4KknH73h4IGDUmtHTd1HzWeJLQtraiQV3PDm8++33fbGIK/CuJhZcTQJmAAbgvRp7T4ONjABpKstjpExDQ3g3smGcLS+LexpTBjNrEJOc9BSsTtcOPWJWVKfS2LwryjbHF5f8f4wNV5km42g6qJtxPTAgw+ZFyIGMNRt6jjX6kS/bnhkNPfn//tPi/BgHOxZEA4OjRlQiyd257WBFSPjYXF9InmXlY+H5WWToXHRjD9Ax1SbMYLvHj2WH06sv6A1XLD6Naa6b9tTaWVyTxgA2gP05v/yq/ZukPxoALwTgMs8/7FjR0PniRO2Z8Ku53fZ++NdMlxzZgAjgFwzAPBoF6yg1CpEY9KUizbZPzS0yzJn/jjwPdkZAMfSLkpXrFh+A/al4eGRu9raltyp5HnLBBx0/iwvV+j1yIOfishz7+strS3Xy7lm58GDh58nramhfqynr78CRxD8wJ0B5KamVk5NjW8RyMmmF1ym1XR1GIp65fZZqnn8XoFOTmXTNscmy3afnFny6th3vvXvF3Z2drayGQedpEcbcEBYhyG88m6+6YZw0823hN/57d+2hTjvf9/7ZTU+PXiZAUB7weCHo9D3n+8J31yw8ocCvlUm+oM2wPAA9d/BX1u1PGAcvLnlncbUmFY8IocilgC7lZzhBjMF2CS+8tWvhl/8xV/UrMYFecka3SIfhTk4dff05n7jY/9cNH4ssTsUl+8Lzd3HQ9fhBDMOes8fh8OtK+zw34dqwuhIh2wPbeHCC19lPhT4RBwfXhZnNwZw66LkPv1bb7F3gNqPBuDDKtYHdHYeNwbgc/9My8Kk0OqcqmuwB8i7MhoesBHLj/zom2wmpEttRbnyKuzdu//g3X5doTAGfnweJsCQgL0UJsYnvrdg4YI3pOdhBD4kmBeawAxLjp/gpY1nwW93104xf+3gl4PPDgHf0gE/EcBvCekfnEKGB4a3IHkx5qAJjGshjZa5asq9gfy1WgaDoWj5sMaK/TL29HZrTHr0qO2247NMgGVySmsApP7jn+/0wQ/+Qlim3Xwp92ff977w3vd9MHz13+8J/+kD/0+4885Tri42iVomizVW66WyuK9bPxkWd4+GL/dPhm3j/3evoGPitaFDK5+NEVR9yYB/Y8M7NI5l1kHz/nowmCLecBganSo1lsYbkbbCW1D79BkDiEFO3uyxX9/c1Fi0oW0sPLlzj0nnZaWa8tOjbKhJvhDk+ZD6LTWjoWuo0pKQ/ND9RUukyoewYOEis8KzsvH7hxeG3uEDdj7+U9q0NrSs7QlTg4+E9keeC0FM2ImZG5y0GFr5LA7TgBDTqoAbPwCe36cCMQ7GxPmDBw+E9evWixlVq8/IYDs40FhRVrbwdMOAuByA78eHOzoeS5nA9biqRx9JwaLsTIC+nxdCfu1LGc6Yt1/Ku86+lzMAQkSMTfOtXb/uD9RxT4ibPyOpPQ7wqyorpvSy8o1MMUhXp+GRseoTx4+Y5d/m9lUiEi9xLS0ylZFOAogTA1xifBvXeHFMnWJSy1gl2WzxzqpVK20/vzdocwvtWGMGMspkhgAVubK8KDz66P3hgQcfCA/cd1/YoI0+F8i6nSXqVyLJGYdcv7KmPNzaXBHurJkIlTpfLKZT3dUXmsXA+PWkewNkyyt0vLV8Mvx4w5rwpobX59bXXyLM6mMj+sdzwhDZkozhCIuUkG54xWnvw9RLcNLWJyB5r331q2WInOkSDn4cpszFNhP+2Z/9Wag49EhoGTsaWsd6Qtlwn6n5o0MIN83EpCp/teo3MlEaRmu2yG+iJnSVbAwVI51hurHZDHD9uZWhvW9J6Os+LCt9lf0mxhNvQOwDlzW2hCWNei/jh8PgsSOh/iIMlvWqkxhUarxl2AUTYPWkeUWm/YL3lUzD4rpN1ymS9C/PzyKwYEo7QimPphUlKLSqUMuwj6pNejSMnJJtuPiwPUz0B8mvts2pXO+7dlaMaFppeTVpamKiQxrHejHZ6952553/9qlPfQqbgkt+rn1ZwU+lZ4GJhJeYvAFj1b902bLlvwZI+nv7dnp9XPX3Y0LU/2pJLx8G6EU++szTzyxfrHXzbLcFCeLJ8lis7HrJvGCIOB1DXNvGg9rl3qb4Ghu1C4+WxrLm/s5fuDNUqnNASFKTNrLcE77u1teHhx96WMt4HwknHrovfOgd94XbP/hL4V3vea+twgPwPAO/LMVMizHoO2Qfe0erNuVoKw3bBqfD3+3uy14y6xjAb6ktDyuLFVYmYKNfaVOOWYT3HsCAAbK6EEnHj1kLpD+EVX2dJB+aEMzBycHPscfj8J7vftdmAxrk5LOsf2ao3HtshiFzrUv/yjVLw2gy1U9y6Ky9KqxaOWXtPLzz8XC0fFnejZjz+AxAK9q0VmAgF0YnDoTGY3uUsibs3bvXpks57++FOMManmtUsy7uDEQ6xHvmxzQgzlO8e200Yv4ACAkcr2gn1lDwnKkmuXx4NOzFeSwphfZKZpGy6j/SX207++F1kTwbd8kOsJFdpnX4M16OwnmhBcy88ahmL2HUGQAhTMB+v/nhD39UlvWakZFR87UVI2iorq0pllFlGkbgWgDgcvBTZ7jv6MhIafuhQ81dUu/p7GVaTYfk5gZ4xPFi+fHSWRVHHpMeAkqRDHU4iOjlhg/8/M/LhlBtqqOmdoxhUIbGdOahxr35xNfjjz8TxjWulKUw7D9wKNyrLb1xAGKvgFjqUz9XUYlnyaXs0sqScPvSmrBe4F6tOJrBkalEqAD898vk8M6WkrC5QouDpIVQJ/eYk0Tk0I5N+uvZsI4f6TiixTu99pyAnPoxLCo6si30PvGvoWql1vFrTH3RRRebpKeMQhLf63j02InpX/u9bxYVlbWGLSUnwuKGMpP+XIfURwNw6Y/khxrGT4SBsCzkyjaGvtLVYbC5KgyMVYcl7GA02RHWnPhW6AHro9Vq41Ex3kbzHlwn28nyosOhpvzZMHB8RLygLhwqrtMKyleZRsdKQbz6eF4YHu/V3IUVJwTwaAv0A4YEGHdZm4EPBsY+8jM8wEjLORgIaRhv0aTKyitKxBDyWgCSn+dRX7O2Jg4h/WECyVHy196/lozUNtStkhdhyUc/+tFP6wwce95oAbMqHFf+JY7DiKiL/+iAea7b2NI0IiOcDdzcBlCofnDgJUtXtD/88ANrGcNj6eXl4ghiL1fSDq4fS2Wcb5AISGLmj6cmyiTdbw2LBRJWA3I9K/oqZbVnrIy7LAt6YBp0qJ/52feFP/mTP8lXhynEP/xffxCuuvrq8F60AUnbuQhAOSHJOLZQSL6qsTxcobQ7mxLfAc7ptiINYbQJB53TjtIyOIbBoPLSgXHfxfAIA8Cd2MfAuMLSHkP3fTmUfOObwVyXvvMbYeNv/a6VF/+JJT7pHMvjLvc/P/4Fu/mKmorQXLwqNIsZVpZ02KVI/JY1WiA1NDP2dy2Am/Us0bqDChnlOuvCCkl2aEPvdnnoKCJNon38QDgq6T822qQ1AlVh+djhUDTxXBjdeziYdiFtiTE8rtarV69WO2gxdtoGDAcw/k5NVZpEb2hM/MDQ9mB82DvQeJJhg9ywpRXC8CHeMYZFhoZoS2gEll46vTwXSh62A/1xDcCPLc8c0p/pac5rFWZO9cJmlcVbchMyvUw0HzQAOlP8K/vQhz70Y5o3r5YNwHpVb1dPVXNr8zQaQLadYnUaDUALOsb6+/rq+nq66kbVUao1nw9h3OsSEHjJ2m/OOjNjczoP6iAScZV22Vm6dJktDGE9AJoCvgXM1ZtUUTmMkZGWMAKuwwUZKXTwwP70XLnN+R+Rb/oD99+nqbXlyco7gVkZZ/9Unquw1COBg9IAMgwh/SFqGL/jasxUJSBPmIWaw4pMhifkB/y0CavqGMce0Vz/MaYjNQPAgiD8GxYfedrAr2KNjq9dG6756V8yqY/kN1JZ+XiSYkzvVz78N0W797LvoJhrTo5FRZWhLFeioZjG0RPHAuN9qFCIBjDS0Byefk4rFIdGQ3HPvdqEpDe0aGxwSJpC+3h5YPagbUyuzmVq16nqsKZoR6gc2m7gJ8+IDJn1FWtCV9GYGMAaa1Laa1LPxnug9ixn7jxxUEunV4TD7e3mAszHVHh3LHkmP/YCaZjm88FxfX2D2Xyo+8FDh6x9ZTjWEW+luM61ADSArPrv0h8BRB+UFtoo4+PooOZcZUisaGppWj06PLLt937v9z6rwuA49GPvy/7alfTSk3Hyl/62p76jgL5PQF0gSS3dWmplqgH4nvZzXc0L4HfBRVt2YQyCGMsjrZHw/A4IqHv37TXJmJ0TBzhs2Q3A8Qx09RIrOowAUAFANABUTphJvTSId/74T5gmkK0XhrUPf/jD4R//8TMJ0AUqA3wUcg1pDFP8R4f0OEAH/D0y5PlYnunEQUkp/N+tProW4POcSH78Dvr13Cc0J55If+07wPoC/ZbtemQW+Ln/5jvuIDiJuLcTz/u//uD3w1NP7bSxOgt+fNGP5zlVOLQIS/6a0HRkthBk2vDBoerwWDLNb/4C+Ays7r0ntA4+HHY9NwN+ym+RbedQ9UTYv/+gTau6+s853u+UdnGC6VdUNpIUNJNk7cC7YohHO/H+kvUWWiasOOfQDqTui3lU2dSoXZz+KSmaXh4fZ+M+/vchgIRDb3dPTyMaQFFZaT2GRjmKPZi9bj4cv5wMYNYYKm0MGwKwLXQKsNXeSIBf36XLDws8HSOgk7+Apuamrssuv2IP8790emcCCSNIVGoYAZttsAUXaqD/6AgAEA0g2ZlG4zt1EsA/oOW7MALKIY8TYP3mN75hh709mlrseCrs2rsrDD71eOjf9kj42//x38Kvv/Pts+aj/VrCGGhxOnGmrgC/mKLd14AshgAzSNbOJ0wJ4PMjPx8k7e2RxqOlxzCh/n6ptJrpsC3F+k9qQrsl7c0PZlSIPvHxPwxfvetr5qePrz6ee3jpTfbsCd39+qTZHOVSFuCfqFgUOg+WhUM5TYNWN4QNxcfsNkj8o3K51zr8WbcF6E5Ifie8CN0de/9ereZTu0DM7aPep0N0W1SFetAqDc2JYRJTwMY8pUnw7iHWcPAr19QpRuKWVo0zMsSUIEmFhgAIHWMCkvx+GUyAj6ssX77UmIc0UI1zjGYexjO/jOG8qoy3Q3v7wQ62hl7Q2rxM7rRd4tYdgF8bMyxQnmTxeZo5NgLmXwRawMVbtu/c+WxDX19P65RUQgjffSfsA0hHwMxYHjsAG0eg7kOoh4wR0YhRrXHfZXwIIWkYAgCWYjGCv/iLT4e77v6KnVvQlVSPOyVd05L1PYDHw4duuy786l9/Lixbs+6UoEfFh2BCOLigqeQBqvtNTSbquan7AgcTFYxrOWbYgh0C8DPsgfkBfNeIqjsPquR8P7X71C290Jgcms+kyk80oBn+/On/8+fhH/75byyv/0m+V9AdnpAmgM//xVP9MppK/ddqRcb8EMAvqb3Stm7qEfhjWlbaHlo19h9esiL8yn//lbD50qvs9KB2JR89ti+cGJ4MVQePSGO7OIw/vSNUnXjahghFzQsDb3Ndi7SvfU+HDkl62mfdurW6f629P7ePYMNhBeCUjIz79spfQUwFxolGhbbn/gJdR58zuwi2oeKSspyuKdq9a8Zngvc+lRtdrWHpcWmD09khQF4DEOhR/wE/0p8Vg3xGnQf71y99ye1cycu1p7U/CGGkSWHOO5PvRYnNSwbw1FNPPScDj3x7inPa0tpW7A1PTD5fqAXiaUB/EZ7vplte//Q37/ryDUPa2AJyN1CLiwEkRsJkzTgr+pCWWJf1Eq2jkI9xMD8kPmojIQtGajRDQEd75vnd4a/+7lNkDQ5+OyjwB0n3n9/6pvArn/hEuPz6W2blcNB7IuBH40DKu5prElr3d40BwKOyyshklwF+DH898mVw4x8agxMOTlmqeP+7w8Dhp8MCfbmIZ2NHIBxhGCvjUv2vn/u78Gef/qRdtrBRi256kzKSeMLsYAaPKMdRqfeX63TLkjWhuV79XkI+150AH8nvhFV/4Z49oX9xabjoF38nLFux1u6HKm4rJJcslbUgqI1mm6hohz55+w117A7DfSfCUFmTPn3WGTT1G/71X78QfvKn3iVjb/I1ZVYLAvrJyQW2SAunMLQ3KB76cc+J8SZ9sHWtGB+7IYWiRfqe4+5dSXdzgRAmh5dXVNeZMTDLBBA8VrD+AH7igH8pH3UUwWy2bdt2iHgBetnAT11mt3CB2r2ISS5iCOGC/GBIpdquuuy66254z/jYSNF0rmhaWz4t0cspkQagWXJN7UeEBsCiIBmBTMxjkPHTAujQipVrTux4avsqgIDKB+ht/jdVeTlGbUZ9Zj3AjTfdlKjCNqnOmF3sWcBIFvAIYNIMmD5iU00Ywy//8i+Fnr7OguAv0maYVWMyzKUAJeT38LfuDmX1ZWHjRVuN7ZtRL1W9AT5OPIzvkVZZ8JOXRUZILzQTtBGIfMkwZVBfJeq0nYUGB/T5L+VnGnNMz7hQm5picOsrGrXf2FptRlpUGnLVJWGBVkZj9IK5wARGxTTvk4PT337uj0LLgmr7VdZJuqfxsYGxUKNpyqHRfHOHPi2b7qqpDLXlS8JE2YrQP10fBiQ9Hx9vDt3SQvg1l1WGxsnHwlTFoTD15l/TF41bNT6eUEfUNuB6F8w0YJylDoQwWeKd2hr90KPfDMf3PRGGS5tCUfOKcOjIcbPn4MOAtH/m6e+Hyy/aFFat2yjOLfuP2hA7Dv4BO3Y8JVfhE8a8HdQsKloo562Vq1bZrI8xPd0PDYiFUjgU0R7+06faOjXVOOTDABn8kj48Pd2q6UPtFD/SqGtHWQvQtnjhAoBP34He+573fEQMHG6MBgDD4ARS/2WR/Lqv0cupAfDgSQOmlYkDGWyt8fp7u4u15TOeWxsXL2vbqEb+uk8Jev7ELVh77flYLOLIOHFcd931e+6773trMZihFjMlCKERuHsovv5ISCQCU0vCtk0dlQhA06oMXmRMJzGdxs6+LJz5/Oc/F/Yc2FkQ/JQ/MFoVKte0BeYhRr6/gyQjxrt/8Zu/Gw7duzv8xz/6bUsD+BDuuzCjPPjFgehE/OiIgJ84ndinnRnG8EPC4fXH9mKDGuPC9Fz9x7kJNdqm23SfFTktT37tdWFgxxd19BZjOHWaHmTVIvf57Gf+LHz2C/8SGtoaQn3ZmtA/sdfC5qVavbhLe+mZbKPGfZanr6OPA6MDAvlR7cKGCy90dCqxuywuGQvFo5Ks6nWl7/j9MKi5dhhqUZGGUmI+FVW1oUbtz5Sd04mjh8OxHfdpKJPYLurXXhv0GZXQcfhQYuDUEIf3yheb+H7CX/7jZ8MHG1rCxou32MxOe/thzen3hvUbNlq5ZuzTe8Q/wDdpYSoY6Q/BgHh+bAK8a58mRGic6OxaWN9Qf/IwQIvO1PfkX7Gwdtmyttdg9AP8TrwbaQ0uuGZOeIaXMZxp6ZenEjAAfrM0gD179oy/733v+yDcP+nsWhY6PCojbol2dKlZrymWmiHpx4LMGA3tjW1TMBH44dDiurnmltajk+PjjUc62usYt7MpZ0lpebj55pvDZZddnniVmco9HK666kpjADQHQhkJohF3Aj4xA7bmwuhHOf/0T/8YBgXmBjF2Nqbgh4SvqtbUmOI1ug8OQlD1slVB2xaFSU3JOe3Z/2zY88hD4aLX3IhxKQ9+FrPAmKYAvyQ9DkzUA6nv4Lcy1dHQApBUfPgDpqEFTab+u/R3m0DniZ1hhSzkDSUJo6m99Ue19v77oWRqKFQs2hRKGpebpxzSjzL/+m/+WR/2kHHvhJZYj8mNWBK/v+9oGJ3SNGSJ5vFFuEuvWXFl2P3MU3YMsyDf8HBnGJjoCLVTVaG4qtmmC8lQq+8FtBQNhYt+7M5wUNKbKVTqXqp3wcdSKlJ/DEDY03U87Ln/a+HYnsfVFsOy3ywILRfeGEa0ZXhX1wn9ujSt2y0DaZ+87bQUWCsBoROyfezbvz9ccYm2OtO2aTXMCKht3f8BTz+cfWAyMHrOtWqmAOZOf7OpTz0/n27HfZo254eGVV5R3alPvtkLpF+5BsAOQevXrbl46dIlG7me/mHlWI3YIm2s/aO/+9G/1CFMAA2Al8DvFa0B6PkLE9xyfHK6p7K8uImXAqEmam6/iJ1etRPP0mUrly0TGHZ2HOo42j8ykgxG0+JiW4Cra5dfdc0DGlpcapqApCFjvk2bNltHaNMKQJb32vSZjG6MrdEE+DkhoXAEwoAG0D79qU+ZYY1h3oQs/06AHwlPCOHb7mR+7luvNElVqw+CQBgHf/sdt4Wf+8Snw5LlqyR1UPuleqoTAn4LJfV9/I/kJ461mk5GXceVH4MlnyO3KcJU+lM+0lFbpoVV7A6UjBaklVzGqVA2dsxCi+tZ+ez4QpOMJWZIXLFkpX2deEA2vbrKxKS565nkWVm6Cw3nDljof2AC0FhPTdjZJY1DPzYFYVfjUHd52PJjt4VutQ9bc/GsfEMRTUZDPbW/fAP0vvv2bA/H25/xIkP1iq2haYk+PCKwY9tAMgNIJ76dwPcT+0aQJSHsPnA8/P4ffjz8j1//DXPEqpNWRTs4wbx9/wY2ZzVHIQ0zYmLXZhyCnKqlEWjIUOPHhBWlpWs3b9rQUlVTvYxjBBHaQ5ZU133ZtPT45MxzZHyxkmc/9Yt1l1OXSyO4JoJ6ZPqiuObBqoqypqlQIjeTqSKAAGl1H7+i4UGbs93EsEDaqD5se/w4G4XE4LcLoj9s/4ynIEt/ZeltTaRAwmCwAFeoM9RUa7GKJAvjStbPs603HQTJQX6Adkgrx44caVeHSFYoVrzqMgNZ0NQf5OCPbp2PsjZ99fKVYZBlxMoPs9h3tD/8xnvfFX72I78f1q5bbwAH2An40zFoOkSgIOrBjjhoBraoSao+oGAWw4YD6Wo31H80FoYfy1ZU2hw71xdde2OY2vM5okZIV4AAA2AsjYFzouyIztUYsCqa9AWjvTNMgIsYFiTCTG6+Kehvvfr1nArPH90ZDuu+Dki2Buvs2x1ufV1jWCID27flLs2+iUjWthbtU5jutd4j6X33Xd8MddX1YZlZdLR3/5Zb9Q6qbTaEKTyzyKczNdyL6T8Y86TeRVl5whQYDuw/sC/88Sf/KPzyL/0Xm7GJP0wKUBnWwUSxAbnaj3YFA9q7d2949tlnKd5sLMwS4T7dpKFCn3wwVixbuobpPQkIWTolxnWdzQgxbixAfIWoQPK8SHLgvVyV8RajHj4MICz7qXe9+43iuCsZjqMpuUrljACAIDGGhgaLJian6uV6tUQbaq7XPu5F8uvu0Weu8tzVhwI8pHyyhzds3Lx/5YpVdU3q9ZSbGJ2SplBeAwALZjA+sX4fyXr8+DFTrzEkPvfc8/axUL4GBBAZmrCMtFSf6mZdwGjlWCgdTDpjSesibmsE+J3oeOXSHnLqVGWS0NC2ffvClksuNbWUzsiPzhWP+5H+CCLu6WP/QU37IfnxX8cQSDrGP9qn4/D2sFq2jvqSKVP/J9/6s6HpmJyBpPo79S6+QVtjrRZjkaedru/Rs7Z/6+HQq68c9wxqHcSkNkhpk5fdZEvoG0zG+gwLFi9Pno0xNL+W2tbQ1Lo0VJUuDrs7HgpDA4nKUdWwNvzYT7w9vOE1l4XHn9wuwCdbrbO8GrsDm3gwxn5q23fCeLeeQW0+WdUU1l3+Wj17kZ5NzC19LobSzHTw/mkLVnKiPTBjgU2H9mL4hLbFe9Oy3LD10kt1nHho8k7xCGQYgFaHtqVNYazNGBJgM/j0p/9CGsKUytMGr2JYq1atNg9RGQzr1q9bfaGGDEvUdjXk4R2pc1pTIqK8n3rICa0Y/eJnPvOZbYrSKeIhQL6Pku/loPnIANBKyt70ljsuXdDaYlOASWMmGgBxZwIOEuZ1MYApLBLAW5sbGzY2Nbe01VRVVDsziJkAuwCp4zW1Njc1MFZHOkB4/0Hco0IdhrXkbPiBpZ2xIuv52UBj2xNPCFyabhM4GutSUcX16lgwkOqa1gTcTSYgrMwY/CSgtjrjqGxbHqbkIota+uyzz4VL1WH9OVngwlbeENN9JrVUFzp/4s6qsb9UaQx/2AHYy5+JEBjAEY1ji/e3h011GntrDrz+rT9j4I9Vf8p9dO3bw5WLG+yZkYYd+54Pzz/2uFTaltAVtIGnlvrW1GmsXqXddUsXsNuS1F8xYNkDhk5Uh27t0AYD6BrsDDXFWh8giblU8/VH+5415vHm128Nt996vW1Jpk+I23OzrTmLcgA0zPbJ73wlTI0kdrIWuU+vvfAq7D768WUfpkOHzZEKYybv3YY+YnQ8KytwYag1Yr4w4gaVh0ROhj8D4emnvx8u3LA+1Mjd14YYYpTPPbdTXo3btX/jIWMOTKfu2LEjfPKTmvKUtqVNPPQetobLL78ibL7gAnPnlrYhc2XiGObA59iHqbRl0ldnQtLuf+CBr37ly19+WtGYARCHcyQdW5GXg+bDEIDnpofPqktX1/Hvh7A+3yY4umA3Afw0OC/AQzJJCxAg5KknEAyU9mv8Xt5QWVXZsHBB88bm1pac5mieHx0aOZJuJHpCkqJRYF2ev4FHiksYIBpLB8yoxsz744fP9No3n3pKZSd8c+mCunx8YiIBKcCOCQccB3/2XJwPqzMyGQ/CT33qz8PPvf8/2rNi/IMY+7vqnxwnG4vC/NjoAyt0THhBTux8KlyuobqB//afDtUHvjZr3E9+PPTGdvWE4ivX2OWo0zUTPZrHXxU6pxeFatvua104rrn3hW2Dlge7B/XF61G+vWF6qDUc2x/CVRu07bbU98nhAQtv3HB1KNe8/AXrVopJ9YUntyfGQg3TbDcihlh8B+HJx+4OgL6r/ZDlX7lxq2k0NrzR87mRl5ujKfCDcN5iRsftARgUeU9oQOzYjL0Iam9vD7/7+39gNhuOme1ZKG2NDVFZAcmz/Mu//LOWdz9kKzxvfd2tFmIQhdFi1GNBD9dCiVaquqgfonkUIjQRmCn073fd9WgmD0PdeUGzQDcvapRWgka77tpXi0cmjQhXzhLc1pkAW2CIJRj3ZiKAToFRb1jSWyu9ijS239jYUL9xwZLF03IEkavxhAbhBSgFfzwtRy4kBMyFfQLZZ47v9NGRiCfTh7OVKWcIWfA78/DrPPSa4FTz+PZHwnfv2Ryu00adJmnSk9ghmLqaYIZA2gIAiZ1a6PgQ03/sU7BKe/JVrX1daHnNuoLgJ+9XGqeCeESe0Ep2HuuxYz77tbXogMorkYaxUFOBcq3WFl4htIVdmnLDlnHT1deFC7a8JixdrPl8YUSWC5PKRQJAuFyvQyCY1Bj/xKH95rCDNyYLb3g3rEoE/DANBz9lMZyB4ZHHp+G8goCfYRgaD8ZbPgjK1J4TDBFbBtO93XJCYuUgzIBjPCRZGuzg37z5AnMSOqLNRG+//UfCf/zAz+cBzzBiSHsfFqk/0MekJRa5hjgtIeFMwO97qvBLX/oSDcrLSaTE7Mx5xjI7+aU5mk8MAK44d33ECOC1MFxUYgc+zeTxEp3L5VKGoXTZyIwRwCgw5KV5iyXA28oEXEBuL1UvVLMM9oJJs6201IGRPKjSqOYAbeezM5ZpygK8hcjBnz3n4Pd0v96ZAMYrqKFxVfjevd+1L9isWbPGwMAyV30Ew+qC+otXIp2d7cSRfDHhBlx5ZChseedbTbI2RAa/OB9uurJEhor1s3nhwHB/6F4oq73az7Y+14rm+voaWfynwv5wQAbARQLMj4YrLr88VGoIhQ2vt29ADEAzEmq3E8c7bRgAiEullbUsXhYaFy+VK+5iuz3Gvwa58B7Y/biBn/uhAfCJNWZY7LnU3jxnTGhkzALwvIAfAy1kVnwxrnxc75otzxhaMF2ITwTENxRf9apXhU2bN4eV2q8Br0OGCvIxsfcvI3JucnxMvCsRNupXBn67WH/0BSqPnhTOpQmwjkTlJC82uSp+qJOl2kklv7gJcwPuxb2vlw73y+pQcMnxr3zlKx2//uHfycnXauY82oAA6uNjBz6Fedy1AtKIQwwbkJZ0oGRMnbQ78dTbTD4GGlakWgb5nGAAeOWxso7FQ4UoD+B0GEAerO9ZgjFkmYDn4RxWc6beysqTjn3Xv/1beOc732lgMmOjHodPjMfS39VfL4eFPyxGuuxH3yZ1uj6cCvzdi1bIXW6vDQECvCClmqeHQmNOnnwCfkd/S2ir77J59ObyreGaS94s8C0OJRoNT01JEutLyW7F53JAzzcQ7EMdAvCaVStMC5BxVnskXBL27emwqb/D+5+0uyH5+abhkqVrZcTsN6mPhIdJxIQ3HwwB7YDdnvxT4DH4PT8aAQt6WNnJ149wC160eHFO9p4i1HnTnMancmPd3UXjsquIBHTrK/m+VgjQLjDORPq7+q8NZNq9XvMxnOnp86N2vI18nTTd11taVSVfAHzxcccRcO0VwYlnM08HPtzbgc8jJVN9yZ78LlEY10G8JCzCTAE613epz3nO1Uj1x6i0bKkkmL4px/Qg36CDGbCY6OgRfY9OQwEADOh9nI+q6ep/bAfwdQmU78T1XItNIUusMnznT/yEGId81iVNsDy79Pfnia85drQjLG57VVhWmwvLjnzDTjHOx/BH6DS0+sLwreNf8UMZ2TQ3r+cc6O2yDTh6m2VhP9pgX/Dp0CRFa2WHSVAu6JalHpC5VuWFJExW6wX00dTmtvWhWWPr8Qne04hAXxUaa5p1jRx89uwJ1WN9Nt4H/AsWrTLwY2BFo4GcsbkvBgzBvTgZBrBhB3XAKIqTD+N4DLW+aQvvk7aijYT1nPzGTMpPajMVIz6UAPGZd3UnhH7KBCy50B80Q9cCEDhzkYOf83qOffi1pHljrja7A89V2Iucngfbi3yfH6p4fAFkgDP9lJejEX8eqBQYGwQ5jpkAx7wkOml5eamBHXUP4wyGNRhKsUJ/I65BIPFRMXlndB6GEVqGIABqPC1msGHDhnChrMLk99kDFt8wPq+qrrGxIVIKVbZZMwgY6B6Xsw/zyKtWrTYDFV5m+Nnv3rXbmAd1ZaOOrIbAMQuUvv71u7Tz8H+wOk+o4wO0hFHJ1pGq/4z/cWfG6edCTV81HxwOEytmAB8zgZar3x3+eudfcts8wQShdrnYbltcrWW/iQZT3r03jJY8H7SJevja958JV199lTFCAMc12TE44K9pXi4wJloM9WWajuFBlYC6VtONWhijLwfLfqgpw+bW5eZvwRDLhjORgw/18e3Lia9bt05GvxbNsmj5rt6Fbe4BQ5SRgdkAMfbcwNBwcZ/WQDBM5B1D6juAXUM8/qb2gjQsLgKHM2B2yW/vNwNyf9+64IxpPvsA8BDzhQHwFtySAyYr4JrpZ75sKpDKOiWafaIFZJkAeXh5ENIf8LukMgaQqvmcTzqIQJ4UqONk3h1GwrWUjTGLdByA+FG2M5pEu9BiFYkQ+zrQ4ICYf/JhjYamZo2Y5TxTW1d02eVXGjNhaIHDDtoBBj6ou6vbrNVoCW4TsBP6AwMg/fsC3rp1T4SNGzbmpX9sHGMGAAbA1tY47SwbHwo1K5LxtpdFCBNYcdmbwp9E4G87sTks2zRtU53kOXqsKyysu0gVD9qlRwbAx79rL6ZL51r0+969I+HSrZeZGo4xD08+Zx4soGlrW25GN8piOJCE7gMrF2zZwdasqDfwo/aTh2lDfBAY+lRVJoy2srp2ukauoOKygDsnDaBIeXPTk+PF7P6jreHs/SmB95RIc8Hbbqg/zPEL8nYYAx9pP5vSPJYpOeP9hyOGiIWIPsE5+hQdl9CZB0IGQhPYvmP783ag5kjDeRXMFwaQbRQaq3ywr/tZfW4pf45BAC+HxgezanMDI0AtRACV6UO/hjyA3oCtc4S8Kx8S8BJ5aeUCv/nE66RPw/l13BvilsRdVfU6kJZ2IN0+qS+dkfqyOg0tAgkmt+Twk+9aL95TxLJn1WEaRlDE9N2wDHxDsnIzjWVz4LKGcx+uo34wnpLSGUce6sOS2IG+PeGyIu3R11z4tTZvuCY8uu8A2fPE/PmGNnO+NJX5sccfM5Ua34e169eHcW2TduhAwghYzsy+fd+Xkw3XmXtsqaSxJDuLkC66+GL5TmizTfn2w+xohyQ+M9SazBXl1m28oGhK9nUqAbB5drWfcIPaPqEtFrReY3iweJA9A3PT8gLlx7uSV1BK3t5+TFtD3NPjHOsrXeokiWxBi/SeMhMjV0IAmOvT9+fJJx37CZ/q89DTPSSdpe1+HIVwRjhL0pmiEy91tHBPealrkdyPRplVHxpv7boN+drApAGek71sSfSplDFoGYZ5jnGeDpL8EqcY0rDmW5iClI7ieaaj2QPKLdXPiY7p18EkIF4uzIHOax1XxzASrvX8lO3n7CL9sc4lpsSOxIqrzyXaBukAu7ZOq+8adG7xorxEsWvSAjyelUxv0/ftoGktTLLFSqn0ndCxU6ms5i1D/eHa8deG4tTQWF3ZxPhc4kvbZckC/va3/7jUd02taciDgw0EMEpL/4dJvBx+Einx7E56VjWJjqcni2kjQAzgCA3QGgqwRyMfKRSP0xvLCegz7apy8u7enh6V7dGTQn+HJ58AyHl+Yadj0PPunBIfk+TdaPsvMQnez8z793ynC2MtAEYNFdgHYF5pArMAd7oHfKnP03h33PFW3Tbh4OpLpuMBgmwnSToCBp2ECXBMHrznfLKOjggoIc45mMhTknZYzvGGaBjOZzuCdwuGDTCNIjpMygi8Xg58ZwiU6fVNQnU+XUs+78CESf5EW+CauSh9BCwilkVz1bOyTudwMjuZMDRaW9QkaxjKxHAESNkRRov6tBU3TK26usK0jZHhwXydvc3SEg1VvAsIjFF3UR5t3q5pOueM5krnpN/D2ym5YibdjwnjPF5mfN4BnQA+Xy0JB+WygdlM7phJUC5NElOh8uPzp4vPZx8A6j7fGIBj73Ttmj+fS6cG8wmZCC/VpRGn4s4TxwHfjA+BpoqUl8aJ83C9k3UMyobTq2PRcbzDO0DJW4gJeBmACA2VeyD9/V6UQydOcJXkpkyXWsY4pO2MoxGrQ5vTTSqNS4pmppwnp2ekv9/TQhnap0tldCwWO0gNnKRPTyTbijHcYGzuLsj+XOTJg0u3Jh1MWVvwHoinz0OaMzhL17GX42XYBfrDc3HOmYCnZ0Nvn2y6l+vpVp4O1KxJ3XK4J6liolgL4DjJo4w8SBb9dn5GUJD/TAlmmr77mRcy2w7AHV92coH2clYklSWzqoBIM18A74SzzurAOXOROk/iAFSoGDoX89WJRfhUHQyAodLyQyNguPD/s/duQbdlV33f+a6nu3VphAApBOXB2GBIqnARnIvB4IiUSTlxymAXqfJDTCrJQ+LYeUgqeUviXCp5xGWbypXCeUnwLVZhG9shqEREGVBwIRRsIQGSmkZCLbWalrrPOd89/9+Y8z/XWHPPtfc+fVrqvXdnft/a8zbmdc3/GGNe1loMOF/sDsShoBS2WqcyhwS4zPupI8BmgOLOV6RV3V0n286TQVxATxscWtpjn96Ydu/suIyjuzMtRi2A/+SBd6FKyod6/XZvAD/9VPqqaDWm4XzfkcoKc6Rbw1VN3IcE/gxGu6Gxm2RmZM6jhKVGBs1cW1sCP+H0Xe6/4i/95/vHW5G47LftOpzRl530d9ySbTXfdqYjbNfPAFDfXWAAud9wz+ZIjy6vX0KCL5nQABSZBxjACIN0lKTE5AESARGGBCgDpdDwkEn9ykwwgwIIQAFHx8T8VgPOdgTWn1xPmIDr1AAAUCpYnM5Nox6YUp9S/wyU7HabjiXZMGdKe35dJBx+JP/p8QTyG6n12TytN/GuGOUV7dT0QWvukpjKO4EdRtyYgBIjSTl+jRHryFOBYHRuexAs/BQmNwlC94HvFX6H9VkE46FsAW1kzAScB7YZKkKDK6fVA+exdkJeR/RFvR/Omy1AHwUmLKdtNNz0ahgvu34GgKqOe8+t2AGbswCuRppvOmhmjwYdj5PmQTBLIE+ReKj/RWVj4ECPJsA5AF8xWLtB0eeV/Qy2RWNG0DEDD3zS2Z1B4rCc79GlHn89VZ112MYG8MMEeslP/Mt6R2FveKqQ9uobrPFQleNjK80e2VkbE3XEHCVtYFQ/iAr4vCg7MTfofUGDcR72R+Dgx/G2TYLfV2Fok3bAmonXTaA/OdUDVtpGPNW939bwrMQ2Bg3gM7/9QnmoYpsEbxDNLq0BcBfcu4i2xbMAAJ2BAhf2AGAaUN8rMe9KFn0C1GVbqgdmzoOEzg/3nV5F4rk44ajzHB4azWGhzwZhsFKWwizxMy1tyczLdaBuOZw0SH8da1I+ZaHvTlvl52ICoyHcS37SP3tfaTX/D6P5vw3g5olKjIGOzXkDP32X42AOserNgTq9Ziyek9V2G20Z1Z8wS80RyHO6Fl95Vd0xib5wXFQ06loAT97E9f0FHWGMj2zOJE1udc5CMRF8xInF1B+mJd91B4C892/6bD/33Cc+Uv0zrTbTvNHuXWIAfV/QaStnAUzETe0HQzABlu6kwjENkNcr0DUAAEAASURBVOYeBi0Axm1AIlV7kwcPbqRHMWVrjwFfJAqgLFtcOQ/vMHjwuywGkAd/2FVtzowAN+q06TyI7Xc5Vv1R0K263enQTy/TvfhnTcDpsRv4a+DtZUnNS0civtq4A/xx5l9vQs7rxTq0E0xClaDupzfqXDEBbdcLbQKMauQ25H6lPdnk++c43oOojJSv8oJ5i6mg+XGugGPB5d6gznNN5eR8cZvZ9OH2S8dTPScG6HDbK7s/nbZmunX2rp8BoO67xgAYhbM6xVmAb/zdCkbi6MZ2nDw4v9TXoeEASGgAXuEf0zGQPADzoLSbOC40AIBdpPsknXN66mHw43a+uLPp22HAQEN+2W/gE+d5P27U/3vnb7kHE7DJ838zAsdl8OtL9sFEjvWF4atHOlKcVv0t9XngBwP4tQTa7HPO02sHQfqBwnRfWHOBXWtLVPyu3KfK+CKD9JOBmfsm+rAUV6i5bxjZt7dlAdWvQEfzYI4NEyhTF9z0dVnkKwnLL/3YS/+TunC6iQnkupJbVv9HC38u13G7fgYg2uRK76qdO7EHDXVmEB1JIt2dFnDPbrYHkegyiBhsvta1m7y5PJfEZpGwhJVwwvBjGIRcxV0kVPG9/r+An3l/Bj+lZKnvhUCAn8EP3dlThc8C/pv6XAzhmAB+PTQF8DHnOregh6bDvuQQn94wzNkJmABg5I8+1fZJ9AfMGmkeYZFD+XGfus8IhQb6JVP6W/mKDlDGA1zSBjjizQNAMAaOfKMhEDYDrrRBG85L9GcmHHd3Zp3KIcv2LP9lsns80apoVlwrN5sRr2nxjO7L6tm+1V/Waswyn+6YOo+DFG1OOugybsbx3aXeVzdoSj0CSu5MA2yQCjMJi0ivxnEMSgYwAG+23BEeoK8DvTIJBqkX7KyeOs9s53Jz+JIbxpXrbjrm/r3pJT5+gK+ndrUlVdqI5OfS2Z+Q/ICfVf/oY0laHpLTMd1Z1gA/QK9QbJgBRo/gBBPQ6T+Ev5Aspuh7JDd+TM8EIrCGL4G/B1kwDjGlvB3rfJC4rMtwpJpbeSpJQb+Z6ffA58UlvTlJbb7Tx1IwmUn19PZb2tuPDUPknQadyeO6dExH8EZ4Z+r2G1GBWiZ3JI86Oou66UTu8cPru5NbnS+VM5Moge52AaoGrgYAWzvZeB2Awa0xItoycJ0u0+ImLwMUieSjw3LpDxVQmoYGvdObts+nH7yOJ5xBtU26XBfSo+qy56+NCR0iVB2uNLw1JTlX2zAGP/aV3sev9S0ZLfbJ9sNOj4IJ6J0EWuwS/EPyx5YfzBHwhySeg6PM/ad+NfjJHcZAMSeJ0RKOoZ3RXmXHbSPvkbHkZyvRZ/ZZRyCce2YQkp4+8C7xVCNoxnnfA8hibqyZsHCKOa4f8wk3ExgWh57AAPaeCeBXvT/6BNl+xZLuCgNY22A9Afbpe6dPfcMSEVMArVjFNCBrAmXAMJCmm2zwjvIyMJH45SAQDKFQEhcMQTfXQCYmg93pPWhzGS7X9D2Nw0njfHJ63IAfwwbA7Ym+HXB5fO+hxv6Z6nt1nL4/cKKHh8QEMIA/eEH4yg/z/WsBXjlJe0KSc4goUCqwSnoCijoFcDIDP2sArANMPVspgwkrL0AcTEB1AJ/yd/y7hCuqhM9zijClyabvM/sz47+VNtKMwA/wYQDrzHFd/eeNwa+HgSnoENDneKC15pcqFbLk9Sjmdcnj9Wnx61KVcSZ0op6pflGvcV5kAKRkYedG04CmghLoRUDcMgwYn8zDn6WSQR2Sps7zodHwLFZd2fagM2DtD0oxB4PX8TVxWD1tjsPttDk81P+6lqEJiM7mSBvRFOTeDVKyyMGrhCyk4ZUk/xWAriZW5+VGKWZwYk6OpQHcamejahCZSRzHtIDFswIcvRUvpD3pzAhwxxQgS3+kPOUm4LrNIc2NwxSfqk6WK32Q+5HsnV8c5okU1EOOOtc30yeKKUFsl1aV3tLfq/+W/rdiiGYCkY7KPoFBA/itT3/m12oWGfxPkOuXJ+mTtfT1r1PW7FrHffHVVz/uokKa2JPsm5BoKQBnWgTsYoZewB8vARFIMnNIWIp0DEIPROh8EeZ0jgfUvhhwHtAOy3ZfqcirtqGscEsNNoh6YvlD1degvxbTOtKclgvwu34BfgCv64L+quAfZCWpzLv+JiYCE+CyyW6H9eBv4QMHwM/gdz/QP/kywzZYg8cw9ZCk95XBX/pJrI57VF73NSvd4M+BGfyz8AVG0Kv8OQ1u+vl5feK+D99F/3RHd7F2dcL33Kf0itsq7fKgocoMHIyPBIdHP/3CGdNHA8E0vZ0B7Hx7muyHPjMk8jfwGcTkkQdzcZd5p8NzfnYTZ0O9eatuzHNVGCvhBuPdsdYk8iXgS8cpElhgnU4LVCnpTGWfBrMqC5cOZvESQxlckqHhzz8BfB4ikmlzf9UhjMoUuynupV/R+B4a9GaMBjvhvZvsvMgK8MtrOBSmOiL1uQA9GhLXJmOG0uj0ERSMFwAd3h8CyvfGNCN7wxmAUZI3JGwXpwBoo7N6fe6z9cuPr7GLPB4ApwdXSFflZ9AHmAMUUyGsLHvh0fSOhT4Gg0ATTCANbNP4xGAeNKQr04qiLRDnvErcvE4TU0G1FkNhQTLA6VJEXxe4iLfMzgtjt6qbGeiUanIZ+FMI0pndATSOfCu06q9pwbUWU/UaE5HrVjXwF01HlczZhDvan/qHe4AhPLsjUD8OM0hb31c1H7roq2BYan30qcKYGsnk/fpbaTJeAIzI9JMlP+8BWDI8A5DzNN3SKUA0hLx9XembRuv0u2BPomYXarNQBzqTN8kQXcC2KplyUkuHWZgGMwPJV+QVgC9Sm4U/GwPXfmwPyhzGIPSVAWbQjoCV0+POoMdP2th61GCOxchURpSleACfL9LZ5PatlG+VPy2KOh12bjfgJ70HOTZv3tIzc5Ek6lnn1koY/YPOYQO442KBtoKfPuQi3BLebhZquQgnjJN/7aoqgyV9SHu9SDUkfgf+KL9y/KU1PTMW1/Velf74fQKQOjyJWXMGAE4zddSTFPI6pH2yVr4OFUhZ5E4xt4Slx1kA6JpUq/NiS2cPqn4akPKOgVfySKCNPJlPquiq/mp8vmYT2QDQOih9SMhMAlvRASzHzQAP6DPwtRLvvFypJu0dkOwAv3QEyrDJC38OY+6/whxa5OSAIVjbwNZn1iLSQMYG/G3RL0CMtCzPXQAisMtZft+jMfBhCOUyQ8hlcF9LH3L2olwsiHIh9S35qRyTEBXYGrEk/RuBHJb+Vv8N/v4JQKfxQqr92Ub66ynAz+UwuT2eu+A33pv1uze+NvMa0GlRP92QhzeXl0enegFlmG51f55s7AMcPZigJNzgx18GHsxmGkSEbzLkneeLka8SWZrn9H1dwg9omU5U5pHpt3G7PLcFgM/Aj/QHwNYCtsk00aABBOAJ67hkCRfwZAyeRlvD7CceN8zF2TjMDJ18rI3QjlsdPeZVZxYAJ5nDQVxNBn+W/vkcgGlt560/S3/i8r0cqf9O39swB30MhHeo7oXZZQYw60AppM8r4BtaoJhA0drLvK+FJweLYCjLNsfe1pLUyGYEOgbnwjjLSYduwAjwszEAGlBrZLThCYBPNgYGbgDjMmwTHqaCfyT9DTiTZruB34hVJO1BuuuLKkEKiA1kArI7+w38HD8CfmRafy55TgEm4BuS6gFJO/6dJD9nJswEttECeulPvvn5f/yYYPRq6zrz4MHDnf4WQK77LjIAEOtJPlpAPBasr8b8BmcB8qEeGuLB0wvOcgpwAj+0zaBG1sWqBhIBJ4wkE6YANunSEbrhhzxqelMa4OEnzjQuz4Svwc7AJ7nBv7JVuAb464ptwIeogi5LbzM1VH7UfMwc2FNfGviZxveuZz68neeqHt2+1uvOr+MT4OVecBKTnQ5OIWL0ktKw84+Bn8OyG+a8sg6QCR7D7S1B+srmhRc+99vVzfjdabOLDGDYYRdXVy/0ETB8C4Ucx9m3fhtwdiaAuSpMIBIj6iU1Y+6sQSV3GZDTsWDyZmBjRtpCRCz9ZKDbbXspzRbhMC7GnNufpXo8Fg1eKvC3yG5GsgR8iAzk0ADoEzG1iv0ZqMpc3wygZG/mYODPCpXHwDf4Aeqji4c6m6HZPlOskl0Dv9OH6m+PbJ+YPNfDQdsa1H/f46z+b5uesRT9pnw+9Isf+seDdKiEtKBKmgHFGxBUu/QNKHn7IoOLchbgWr3MIhCGQSQIVLDOM2MXoL0WTFGrh2ErfZLWeaqQwQQlA8OX33dfc1hrmVlg2702wSDSg3IQFeCnrvQJDGHSZjQYOybTtynnB6gZvFwAISQ73EVXkfiln0sflDD3B5IfsLue2AY/WbC4N4WxA6DAZLL0N/B5S0+A/+Grkv4F/NTFxicg8cPsYN/ZoAEc66OlvVHN+qCZ/7XeIzKh7zAwqifdto6MvkI/q730FSp4QzGgfFa33KmFCWjQae+WWx8DWEeBedhn6SYy8HqVeUMdFqM92EdlofIzxsv6hOw0N8HttM48xzss26P4AvZMVd1VHSC+zZcVtQ34DfqwlcbAZFw7LOpemeZI5c/Ap0bQk7YHPXEYl1F85Ze6Xmq7kS8lX+oDKfFuPvKo6zfHtY05Te8u8389BLRwBoB6jozvTXwDMK0njGiXwvbpDABt2GUNYDZ/ygcr2jqAFgIZRGgCvYkpQBxW6WMYeNMCXRn4UzdYiq4bZ4B7BMwIC4nMqvXqFh41GaVbreFySLzdVuUDlHLBAmUWwE8UfWSw2W071NaCctEAVms7BbiW7t7aC4kvDmc6pzEdWRFXmEHJgzrYuFzXx+FIfdoDk77VsxgXDy/kr9peBT+0LMyVbzIUTdrbgCEKfOJLdFdrdnHQLm58gMkVkL10b/I2YyIfOtEA9uUMAA2YRv6wOV/xwDqaZ+UyCqazAPeOJRTKwGASwKDBtmHwMSjzFGCKK4yCd/1h4uCK0mdD2iJhAdiUr2lCsndpHIfNIFoaSKYzzSY602NHnVD1qXUtn8EmT6idpc6lvlbne5t9/BxmdR+pBbBb36HSa2HP0x3AnFV9gzvCQ/2fTwEKUxiDP7cpu68BdgX/g4cPJPkL/7fkN62f7Msr9CwEsvAJE1gCa14svNNLQ2A4m0yeamyidbzeV7A3ZwCo80zNdiN2yOYuRR01OB/qcb9X9Xnmt6JiY7wGMNIAiI+dAD3xpvGtwZWlvtziAQiYyEO/PdQD/AIagAugaoCGUVgP3BGjKMT6FT2A6HcHWvwGh+sBWTAfhnliTNmds/IUAMBnE3VRAID2TgjuWMyjo9TO8CodDAHjNLj7MPttG/zQ2iDti7biBVbHFDtLfhb9+PQ3vA1Tq1A89ZdjyNzNvm0zInk4MaiGagDN+wA6NIAQAH2iJ/TrRSB78RCQm7nrDMD1DPvVBw9fOtWH7HTjQ4Rb8mPno7yzRFWNzGEa2pIUZYQ5jxj4gkQBWaEmz3h/HMUhdc0EamahjiuvkbGUJk7LZk1LyWAap5tYkXE+2VPcKG2UJeACjxvVGVtHqO+daq0k3t5nzika6lE6saj8pAXEBcDFdpjzLbaZAr5inA5fnu/3an4lbxqM4y35Af/lJVMBACvWm7q23nJnEXbP5NACYgck2HYhjamgthVp65XuJ++OiHbXnHhxCh8EYa0BjQjmTnuW1gHM6GryFevi4vLT+/AeAFd8VxkA6PR9QguIswCPHj36xLO3N+9BcrEOYMk/XNzjnHp6eMQNxpaiGK8KKO/CK6CCERheZWVZIUIeDOEouAJVKkwj8qjqeLhhDmtMYQYCVTCYYnOAhvwN7lHyzERC0kMs4IaJtK6x1KT6MAtAn8DOgzCYsv6Nqm+pic1DQh7QI+ATZpC62MiOHCs6obHJ4Ccs1980OQw3965s92nBL634kz3al+vr9JvsG+rMJ870rYTSJoRDubu8KgyAE846IK85hcfcaPHYvJH8zQTCrTrm6QZh3vvH3Zt9OgNA3XeVAfT9ChM4/8JLL33yXe/6uu/OkQwgdgMCoznC4Odloel9gYDfBhXQT8oVZsKQC/ZgkgApwGeHIQ92E+QBPYWVwWc/dtAlDaJnWqN8SroJ5KpAZOl9/hjIgnuYuqhlrgmAeqkZYAr9vnyuDNoeyPjdTsCf8N1oKc80PeijLlv80H6YGu9fuNRhH75HeJdOT3InMJbyvfblegdNUNYfdZf7IAcXOphvMdHfGgrl247TmEAL2MbQlz0jwL9PZwBo574wgLgnL774wv97e+/36l045R4bRB5MwxsX4OcjGqh3CUyJ2JoEQWgXNgzywhAUXlVDx2EvgbbETWUZLKYvMdOgy3n2aVXIJPUVCfhnpgLf4zaD3tLzSI/08lRvtMeENRPXrQc+0Qa/wWZa4jLw6dfsJz6bvt9Ds1J/lo+Qlq8vxXf7at8DwdB4ZEeZYkT4i7vAO99KyqaMM00d+ETB9ASzjourm5H2GPofplbyLh0ZC6mVgHuM2ZYJBHH62bczAFR9lxkAom1Wv3wWIFbwylJAugVz580RigPDiRNhvBiUgWJIV9pQ3ydUhSpYQccggyEQFgMuaMsAYb4ZtDUb4skZwzaVBzB+uzOACLdxvP1KUJwgsKLQwC/rEuxylDobzyvAVzztLYeiVAfVLpffg9qqPgUb+LgzY+hBbuA73H7SYfD3bQNkqPxX+hLPjR7w4ZRfMUXbAKSW/oSHO7pjmo7UBGFphSOWZ475LDz0WvS9d6LHoPyoMmG1P91GGD51g9+cnm4n8XOZdgP4PK0iPG9XVzoG4c6a1976r0yTZp1H52oAzes8WOSbVS2+F1A+o0V4ngLgD1VOoWGsoicEGOQGNyBCLS3SaAJV0NV00BKfLwDsgUg8bl+l8PqbwV+DDH68GfiAH+Bz0Q4uPnmlnZIAv5+vZ8GDLTvAzFXc+EuYVX2qzzUyBnmOIwwg+SLObmy3l3BW5JH4qPtay5FdjvgSl41BGip/vR+u94xOwAf8NgA/wK+AG9SAZOJeJX846TPf3D4u+fP8n3pk008BGBfdGYA8flEv6kDLubyx7nmL3ti6uPRRJ8Hc43PhJoKBL5njs6rKoQum+T/0fqYdt8ZAXBHO4O/uz2x8VGQgPWxMb2YQ4ZXODAObC03CAzG7XXakreAH5IDel8uzzcDrgQ8twL894cMYGtx6ycVxuAvozYwm0E9gXwK9y8MG0NkY6DnMDM12kfYF+HyA9JFO9vGiXKQ+cSHtq1YV+TQGXIblNsBv5Vcuecprv6ubOLT726MzKWXlvmXtoqV9DEcP+pxUberPAOTonXRPLHQnqxeVgotO9dRZgNtTbQVqqEcsKO61gBvI6yewQwMQjb6BxxtwMwMgfewExOAoA5yB0kBeB6cHT5RXmQTSNhao6qCN4RV10cAmTGnLMC4DjyOsAD+bKV+VncAPjVX9TI8b6W1DHZiHIMmoPeCGo2VJRRmW8E73Wu2eCQD06K9ad/IF2BiCOHsRp+6k8rPYh9tpgkg/TeJHIqU1EzBBtbO076KKtz4CyTc/4AHXcsAMOBF4/5Rlo3nfD/NQ4Dbz/+j3QQacAUhbgAOK3QuagLV7dRvWiLMAz56dvZWBH6aCfzYQT8QzPLUUER/A5Bt49y4Bh+aeZYxG8pMToCPy+rWYfHMzYBvgRWuaYpf0kYl+yDrgCDiTgSoYQgVLzhsypH4dw5EKvzXOPL8nMsqN+FKGgR9xNZGZyxL4YRIGaxSoH+O4q3oDLeDtDWE5H0iWgB9pIahMqc8rg3/GxCr/156B+nbDkKUTBfzQBFQAH1OBJ8E4vQi4Uu7rEIBm8OCVBx9LWfXqf4raHeeG3nxDKwqWLO7ozOkswLNvf8/j1CzArwRoAMf6hpzEQ0uOBmAmcKKBAyPIAEfKZ39LuMFBmtAQKl1jUBVdPTNgmpA011hcMojJogc9i3uBdbQO4uXJ9JasPZiDWD8GbcVjA7/jbUNH3hn8TptpzAhC4rMuoGchQuJXjcC0i3aS/Bn8pr8Soz9LC3sOzzagR/Jnw84iShN1ydpTprF7VK7jtrEvri5+odJNA2ybhG8gTdFS38AKPEbR0amcBejThArMSMYg/as57r6fxwcx+Rjm0dH0nDjg50s5gB9lwmAHV7jtd55PYmeARj6NGcwHLRLS5UYdeKQ2Lewh8WMwV3UfN3nDQLiy1He35HoT5otw0yAhsz88+gHw+YIeP6Dywp4X93QSTk/yMc/XiT7RwBga81Adh3PoBH6XyQlGGyT/WWV0DhvZofb300ERwgQKg1we7j1Ty/lv80ow2vr+D3xgqvSUgUqvs8EpbGdcu6wB0El03qyOnAW4d++bpw7UwMjn/Ing82DHes07C0/ZXF9olTyYwLQrQDzfysuaQE5jIOawLNlzOG7oiV+iAagMFkDekNdnkv1V1fd2Xqzo62gri1kGPasNUuinbyNUgDsbA9x+bIM9h83DJ7CY1lLedhy/FtOIF3bIDrCrbbTd/UCeuImLthPHWsUaAxjPdANv9J3DE27kBnMq0F9rHNjW+edI0aYB+vIveVK+NSMImKpxK47FNbP0tzvvALgKo/HguL/3kz/5Ibv3xZ6Bax8qzVkAXgxyvuZOtE+FaxegnAUoLePEILfdC4H+LBaxaAL5E1EaEoVvd6vf0ObBjf81myUmIJAYROSdwW9/Bj9hMAHEj5KuGAPYEYAxm7H0g/cWY8Dbhr5s881BP9FLO1ARgM23ifpG5bCroV4ZkA4HgID/Fg2nvlCXub/XAAx0bBu7bUd45fO39S3FUVZoORMDOl147n/0VqCsvdjt9rkesrPUmdTRRLBLzonN71Kt5nWZdSJnAU755pVMSP40CG6PLvUmnEliZPDf6EOaXgsoH8ZkgE5TAaYB+UMRrkKelztsk82g8LVICwi5AEQCRaYPLUIM6I435MC71FaDEFuMMPqAfvDCm93Zhjbm5tKFsQuAiwqfw7M7jujq01qo9mzdodpj49dbb+OJPa/sm5EC6J7ZRBtyo2hGTxegLESAn/oh+XWfY9EPv4ENE7jShf8qvSegK6J4eSuQLqYANmhKSP68c0SZlvqm6+2gWbhP0NJ39QxAePWTx23cPSJ2zUzseNdqJlioSjAo2PVTutBW7su87Zd+6cO/6nnZqRBapJFuqgxMIOxLyUStAeRpAEzg5FzSS7sCGA/c8HQ/aAC8fpqHS5oZaAOjAQ59+Yw4by4uZbU8iEPvzCaXAb38ITFF0yQMe/sM+DoXJn7ToHURACibYCJUqwJvFE81ClBhQEWtn+cx5cn7O/nKcG8s3WmD2+O28cpyHufCPqX9g3UA50c7qeN9tR3wn2koCP5Kf9Zs0/b20zoVeHF6HmsmPAzEQSkM7wQ4N3fnzITKYAvQfYoGwBSAceYwLyJa+pOP748+YPv8u979ru8Vk/ySgvkq8CNdMAHYz84ygH2ZAtCRra4PHl289MzTT7/DJ9EUF8YaQNgV/CdpGgD4YQJ+RyBTgRETONKI9pdx8zvokyBxkTEARiDvt+6cYAX8RJhJBBASWJxIdrxhSHY8+KQWsGtwszSXBkyA2zbpKxMA/BgDPDzhTwhWZGESpp3AbvpsA37yN1BPpHJbysMEon9kG2/kbXfLx/VVgAFn0F9Iw8ngb2nkgAmMzKmOBl+f3L/3UMOGss5YIwlZUqhPg/lJtgj8fBiENZV1JoN+RKfF0Pwq8EyyvvMy5RvgbqB6A8p+zUVePrp4DgaQMwiJKPXfGgBxZ2fnkwbAiUAdCoIJaGRowKIKp0FfMwP8d4zotArtchhIea/e4etsA24dTcQB/oHJzIW8bkCuTDAjg1qDV3pQGsSFVfHqMwOfNFGXmh5/NsS5rF6Nh84Adxr7bRMO2CVEA/ymIy9rAopwsOoKfZX+Dq1MIOocANVCrrQehDYSH02BKQDm5E56gEBuc61zYUj7hzoG3Oyji3tXqtCZnjuQ7mjSzduBSfq3RMkBE+8ZgjQAvwocyqz+p5S759x1BgD39IoNnRpnAS6ur39d7m/L3RkDWOr/0d19YfdCn4ZmJPLEd10I7B4LJm3RIArn1/DXc/8FhKcV/Kj/ngZYE3gcJgCg8iutbgdMxSpxbkt2t1OJBKo+AY5KEIAOt4FgW4EJbJCY1iAnzOorYSPQQ4NxmdE9WoxAQoe0V3dZWmPfaO/f0h/Q5zzjLcWqP23ozbXKj2mAI4JplCag6WBiGiFt7k4PeHEPjtN7HYmHGVyJNDMFws8AawI/YRjWe/hw6BnSvztfkFf/3b6Sqvz24Cf0wx/5sA8B7Q34qXcZ/bj2w0TnfuHFF18ZVZcFwFjgUaSfB2AdgGkAlw3S36Yco53Az+C6rotLDfQdmJw22/5QRQ4rc9/ysI5VzH7wtLlxTUgaQO8r54e7TmED0KRt6aljugB8vgC5gW5g4mcKZH9fFn6DP+Iq+B1u1b/5xQScFzZMILQC1TPeuxCZTD+lflrgy0yhSn6a4gf1DP57dxcF/OofDFL/rPIT7OxHC8CgEdh4/m+/7SMERWVqXv33GtOs/U5QbeqF4Z5+5rOffbkGZ4tK7DTGdrpytScntNaAchYg9/PkzlMAQm+1GIjJOwJxkKYOIuKQ+ADf4LcGQNzIQNebGw32bPxGW9uFGUwDhkHTMwPSA8q1poKqAd/EDEZfDqt2zrOp4zXOIEW6jy4hY8qtuqHrJSN+g6UvgwxY8JuZ2k4zAcfRBLQmLrEQB4fkjwW5FAbIkfpmAiPQP/OMtAbtXth4IQ8/rwLDxINTMICq+pfQ8ut2DtduKiFx+3gGgOqnu1tbswdWdHaVFP00Hi0gtgJ5ICipdqEF5MGsdko2r7R2E/hXEijAkiDHeRHQdgZhpuvdM5W/j6x+5vsGGQCaGflZWcdQZl+uVgVaWmjwc82MH5bAtrsSAPKQ/O469WlMDRSP+k+8tYBZnniqxIzw6jYjKxpDVWJq/a3+HyWNLeeZwZ/DzQgIA/xMAXjD8WsxZmqZcfT5wMjf9773vaRwtqAssPJUoLtJfQ5vnH9fGEDuzOitfhHPA8ldeSQGEVJ98P5302CfPO6qnhNbF7d/jd2DEIbRXyTfBvyFbrptrd0AX8MM8DPfDpW72yUw0A16+6PqA7ATfp2ZJjQGvNyhCahMGAIMCRBbYkY9K5OmjsGozKxsR8HlJ7pT9DA37puZqu0Iq4yBFEh9S/5rvUUYYzs89SfP/0cgtvTPaezOrxj3PRxpbZyLULsfOp3sPF477pqodsA5jaQdqExXhRHXhLvGewGu705WOtZg4JAIb3/1icD8Wmh/h76VpQHNGvrjmMYztmQCI7Dn8kLVRbXv1eRMVN0ZO9YCerI2366S04Dv6WZ+gG6wy/YIPk0aQDADMwpoKxMgH8qwJjDLVx5rKbb7+OznHhpsDm/z8do/RzdXofqPAD8K66X/6MAXZflN0cWtg2E8RVSNGZH92b66vHo++/fJvcsMoO9Hj8kIv72+XHz/ur/6wjMBNuX9MQXotzrh1kwd4JuYQAO9EzL3SPOPTSB3spFtVZd9+1j+q+pxpjVzy2FI3AaqPo3An4F/t0ETSj0SRaAwW/rbhhnYnekz8LO7Z1DRBnEwHnXGzUXb3X630WBjYRXJT9/n+3OnF53ABE51mMeAt03YyIyk/4guh1kDQOr3TCnTdWcAZl2T6XbRPSFkF2u3pk7ad31xFB0fo9QTgQwmr/r6qzD+qsxoOsgu+iYTKvZmssjGDGFdniH5UdOzqi4mMDIAPUt/aAx+g6mlQ/KnPAH/Uf0SDqMzj9Dst9u2AW8tAPB7Ju0wrxEwDeDkPteSifpWRtWAj7+GEW/wk4cXVlkD4P6YCQB+jEEfnvpzmRb8cviS+1bvJVwysego8OcFwOx2uu4MgIP3wl6+W7tT/azqx9jlrStxFqDOMZMgjlrfagHQn34yEyAiTwUaE7Dau6G9SKIlg3RYkhBmBNkOcFaAssjkhaacv6Wh7VXwl25xvBf+pMc28FvqA37cdJ4BbJDnMh3nsCbtax810OPv+i1LfqfHNqDNpOjHYHwmUsMc57YQFdJf9xcpDBCxAb7B7+S97W08h1v956y+DQ992bAGkPv/tXwObF/PANAH+8AAfK9sBxN45eUvjL7Brpu5KqJhAtYCMhNwhtiWLjlsW7cH+TpGwLzcl/NlwcyLZiER8SdNJAPCabKd45tkEmNB07G2A70ZwUllAjmP7I6OVUAwgosiZQE9l5lBpu+ZQJmSmDFNQyvXk/S5jc7PDNJ+pL/vmVXxG63rZOM5u23H3eoAsA0PLLHPkc3SGkCmsTszB4dlm+nBL//yL/9qDqtuCp06YUCwC0E7X8HUmbP+Wuj0GU32xCe+agAnwEZmHROIh4Lyol92K7N+ADv/HvQOz3YwAavsss0UTAOAqpYcQWXLTLWt0wVPBUyPfSrNAmPwh2fLn2AE98tcmmf9A/yVIZhJtKySJoAWsKQJNPo1Dp+Z8HRlafuvzyJL/ePTa6Hu6Z5k5s8awCxCHp8CzAuAPQ1+M30Y7769Cjy3Z18YQK5zuMsrwjU3HEh8TvdhvBgYHv0c660/GGsBs2lAVWthAr6CWD93iuNUYJwMJO8O/KbD9sBYmhJk2nXurB1ABxNwu5bSIY28TXUt9xOZCvh7lRHYHrPOUhLrAN4SJMR9gTvedCxJHC8RIaAaqgkdoPeZCTMtawCmtSZgfw98wm+vNdWp24Kmu+1eE4YGcNJuvqlW7Zh+1H7MbYEy39/BGYCc2apKmmPfYPcTjpKvaO2z8GmfC1+qQTzuWY/0QlOWp/K2TpFwK+OgMgLUW4DPhbEdHjOdNYwgL8JFGv9UqZ2fEYgowmucSbGzNoCkRxPIYdCgZRj4+GFwZnL4Mf7cdvFt8VuBf3bv1RlxvgmzCHmQ/kepz2fxdRESRoDJ/AnJXz6mMlfVRxqAVgEivdX+Zgv4NuwE5GkA6wD9LsBNfS+kFwGt6nsNIMDP2kPVpJx3b/N+BN2Pac4xX2PN61d90p3w7zoD6Lkn4y9GCZ3O12WyyRLyqEoBbny++QwGawLHeiEI04EVJlAz7U8KtncDMIhhAmYEuRJyA0iM7fDkH8XHg0GyAXNcZVwXqhpuhmAatAAWA7my8THbtg6gyLwGAC3zf5gA9oqxtF+J0NN4994SawARJWY4SB1Rlv4qqfjhVNXwuVIbDikBfnDFCz8CX1pjYI0fAzPwVMBpsn0mSpjATd2jz1qA6dAANk0DsgYAE6CPe2ONA+aaJT501gjEID7ap9sn/2qr96j22hh7Plc3TwfQALLJjABtwKvDSEqYwLHeHMN1p5udr5xHc68B/groYQYdoE/1quoV0CvzEI7QyywUoXQTsIIw/VgL6MGfSMZMwGp+IsyS3wuAJ/VlKyYz48GP5PeaRKj0ginGXy3Gzfza/WO8wQTQtk4EfAzTAK7Rw1XEA36YwIlW9XttgPhNxguA1gBMP5L03gY0zcjWIaDPsSlV47KCNJdOo8Q7ELa3DIBOXzoLUF72MO7dkTaQVWbOEWTTawGLyFSieOTViSuQ8Rqz2Fy3+tLwRsMWZ93mhDYP0HVM4ErbgGdeUEyFWPpbE0hRQyeSH3O/gh7Jf5Net0actx6R/r3RpzpnQW1xjTZJ4sclCt7+y0dTMNgA3xoA6rhVcuLtBvhX6khrA7dPT0yRQ0IYnhMYmdECIFuB60zWrMxkTf+Z336BZwD21mwxEneibZmbNi77xVdf/Tgg6Y/QXifwUXu4vjk//l4bYErAgpMZQT4ujDaAadqF0RyhCrfETtKtRoXkj7QLg7HRJYeZhNX/FKX8pjWMHG4gEjYCP+FW/c0ICNvGXAj0vdrvwz5oAFxl3j+p/oJnZJ2lf1+WmVhoAIr0a8DRXuIrSoA/TSNIHxqEwm2sAZxeTgzAcdh6c2x4M9PP8du6e9DndM8994mPVH8blzl+1937wgD6fozOvnr0auy/agi2+Daw0kBxZGYCDsNmSoCBCZgRREAFHI8PHzF3xq8L0Bv40IUbRlCZQaj8qlIDc2T25D/xmKx1Z5elbK2KW1ItaQHUwIygd+PPBtD7yuHnekifc3mFCdBvvuSKrzGrHyWbM/gt/cmH++N7lPM1I7A2kMGfNYGeCZBHaAQaAz4nYC2AOK/34LZBC/BWcP8yENNsay9sRzOf2Qts7UUl682YVpJqgDu/Seca3vtrcFiZCfTrBD416O2nk9jTKsyBxPlAC3N4M4HZApKACfCX5vC5Luvc5NEYSiVkGuCpwMouQpCX24kWABNYMtYCbEOXGQPAZyvOF/HemrvQt/ZgAphgBBBXA/Dpowx+oqy6h3uLjsngr1k36T/LCy2hY/QsJ3Jl41OATTM8uy8tQ29arlOxmw0r/Tmv3r3PZwBoyz4xgL7vZwcw8rhCwnCz2w2vKfv5n3cKnPFVyiQDAqmflwYM+FjIqxIZwAZoa2ZLbpeVbYodXV4DMOjnaeAQq8bAX5oKkMJtG9mEAfbYl69bd6S5r4VLpD623vylfXRd8t/p02os+nlVHNpssvTP4evc1gKggRlweWHTjGhd+ojTDePDoDf1a/IwAQsGwI8GwMtAR6ZnKiMah3WfA3ew7TkncugO2fvGADzPQrzFWYD8ei/6lZvMxUJgvxiI9O+ZAGku67v6rAHwKHEYdgescpeQmRZQg2YWwAfMm4wBP6NDmuVrFjn3jDQAKAz8bRjBPMfJB8gBmiU9MYCew0XY4a8fZ+63x4i7rpLV6jr2SO2PjB7jB0bgMwNmDCQ3YNm265/a5CyBGbufD7mqxxWtAeQvAvtcgas1Yr6Ou7656T8H7vEZ1TLdLtv7wAB6OLVOFjhXzgLQ2dYA3PGA3jedsF4zOK+HVzxQ7uoo98coSMM+tZkBam6eDhCfTZb+Obx3r9CleX2mdbk5zNKMrbe8CGgaGME2UwHTZ/vyWsyTOXUFuePieLHyDYnfqd6myXv+DsN2fbdhBIB7W2NGABPw1G0pLSdDfe9hXCNwj84VLOWnF4EMn0hdot/F8H1gAGv77dHFxW9C0O8EEOabjfss7VWhGTiOb8oZ+E3yix5JwnESf0OAPDAZjGYE65hBSbX6u6gpLDCBnIPBlMO8AJjD1i0GQucpAG6fvwfkoWZLcqJ24z85nrb04mk+1XEk+ckH6Z/XAPIUYBvwk0eeAuAfGm0l5ql7Zhoc2NJxolky7nM2wcQ67S7H2+37PZriPHjwMH8LoAkmpR3PLZzpDtn7xAByp0Znx1mAR48+0ebK9Z4bIJ4CWPW3+g/4Pf9Hylv1t+TP94eNrt54UOTw18oEch52c0owv0J8JKlMix3bcQOhiRSHCWB7apDdDos8Esih4SizJX6UVSU/jJHyDAjbQaOf6AdJ2t408KdHcXsa/Jboo7iI1ygA+F6ADXedwjWmVOdgbOfGzg73W0yBOqAxrdvWWyqXcKejzbhfeOFz/hZABv+6LHYubp8YwLDzLq6uXugjPNgs5Q38vANAGkt/awB9Ptk/AnjPCKDJV06f3aO8crzrb0bQl+N4p4lBrbKzsUYQYFaEGQFS3W7b0JzovfphC7tlL14oE+gxxCH5GfQcdmLKQRsM/txmn/XX/luk5WdWX63DvK4m6jFJ+2ACAnr+AjH9Rx0Mfurd9+nj1on+ff755xbfSvW4+b1R9PvMAILr+iwAHYha3RuYQP9UoGlGEt9xMeArqDxYMnAdBr3dtp1Hps8g6R/fHdV7BppUhvPu4wkPTUAAN/hNi21GgG3JbxuAs72HYd7PIiAStp1svNPMXhjjoh0+d+A2RUJ+FAdNM3XrxBqZw/GfLayU9uq/1fxghuJJrC/WNcZi9w9G1EL6xWGX/XqA33l5G9r+au/NGQDqu28MYGVz2zeB06UeUx5wRTLqmE/azoIZeGrQ3bitvBnU0YGSLlbRbeeMenrijrS70BuYgK8+Lk8HcpyZAO8HyAbmtc6YQdi+5eBO1QJI68U/mECAnSO6YiwG/mLegHERkBNnoN4c5bXx/cKf5/IN/DRP+Rr40OFmFw+b/vHDRMRhqO2NVvv5gjKMPpdRKLb7tdo/ot73MwC0ad8YAHWezbf8XgAivBBoYBj41gDmOwGbVdGRFkA5vUHy+7hprwVAOzEByuSSEi0mUK4CBEtTbGPD7SAPzCjvTNNrFiVV/U0Hg8wgkPwYJH2ESdJjcMclGM1M1YhmYdlD/BoaQNiAOFgLiDg4eTIGvW0kO8DH5t0CsZUPc9AVK/u4OQpUw8gKraT4OTjUtSmVZWcsXB6frCUknzVnAIJlOb9dtveFAYxuRjsLEB3cSUHCDHx2AHD7lVVeG1h3Y/S8noZR6Z4JwCVF7yeUAWaA2p7yN/AdUoBWfDz9NklDUywxAcdnO5dHPTAN5AZxnc8TZzUdyW8mYG2A+KEZ1HFE53IjrgOz6YMRhOY0MYTGGCDyCr80N5/VwQ53HO5RGz1lkdPtcf7ZtlaGfaN8aaf270NrIyzCrWrkhBvcSrfuDMCG1LsTvS8MoO+xpgVo8D98dHn9klaoGDcBxDyY0AKQ/N4GRP13fN72y4CnMKvVTCNsMtActt4uwD+uW5DFrlI3wjyjmTMIM4RtmQCYny0EKsCgmJ0RqFqAJX7M8avUD1WXjCoDae3aEvhBT7ltHia1e8CUW77Jkelwe4U/LyRCHm3q6uO+Ij4/E4Lfpkj/4uNEIExgkyagvleXlHvvxU5ysPvi0aO9XwCM9pRu2e9fPhfOVqA2z1YaYi2AnYC8/2/C+ICIQM64QuZjLPnNBAjLAw3/yCBNbNrgEdBv68mzYhfpn90a2k62bCdGlIlgZtZsLP3DFtgBuK94WzAJFc7gD4lvwNtWdKRN/hWG4MI7IDo4bDFd7RvMgnpPAB2w68qmnRuo6zZ83aA3MCtfxMW9Sf1zV5laTreuuplu5B4xi4uLy0+zDV3pm0CSf96gUYY7FLa6GrVDlRtUhc71yKLTy+fCLy/jy6wsBAFC5uMA41ir2ZbgVvsJRytgC9CgNljBL2MfQzoDq4SUX2gpw2kJNfCcj+nnkt+hIxtJQ8ElbyjIv+Ur5mTBShzGwMdtOjnwNuOBSzvcD44kjlJH7TDNzCbvjKJalsvO+ZDOJwIBeAP1LMO5pzECTdUAvQ8pnWit5K6e1PTn20mpuyOkTeslzi1W+ePlJKvTKtP0dj4K3Mct+Q/hDABtm/TbpZbudnhw3i+89NInqSYagEGYF8fcBK/+oxUwYL1I6CO/pGWMe5wXtXrqIg9yl+F8R/Zc6s8p+rjJvyo8mAbAcG61ku0HWorNq7MmSY5Ex8+TbSHhBbzMwOzOdqFDgyiXa2lQ29/sBPpRGodZ+tvO0t7ulmd1wCTMKM7qwR5uBOA/kj8uaVNH9SIOt37KpXy4P92Bv74YjY/tGYMTw1RiiuQA2R/6xQ+NXkuPujINmES/q8590wCG/Vg+F/57JBOKBAWgwsHKzQ4toA5iMioLg3qH/mDU6J6HyWp9kaIFaCV2+RfpT9oRs+g1A9OSW6n7xAgC/Mon5q6uewKipXyuCSDHuO59nsQRBp1q2Oa1hPdmiRn05XpuTHrS8HJu+tBMwBpBzt+AD6YAA3C9A9QCHcDTH//M7+OWVBrygSmUwClX1H/0IKuJQeebOZFFm+kDrm0MDNZMAPtzn/2sTwFuk3xnafaRAcBlZ/XmZuiNMnf6OHUdJcv9jQRAfcYsDe6cenWAMGAmgELLQB3l5bRIeIMe+t5PGLQGO34M1SQM8M8MA7oyAQOvB6TpXQf8LgM3+eLPTGCWh5mNaKHxlIi02Y0fI7ao3/KgUASEr6u3I6odar+0MaQ4OLxlZb+CtVfLqVss8sEcOmPNzMFuM+F2O27J3kRn8JMeZnAIZwBoy2pvErr7Ji+6zG7G6EZaIuZmZcAaeAZFpuvd5JXTOr4fhA7HzuAf+U1L3Wf1T2XNykzgbGkrcPADUNqCcdvsjsD647hgAl2epZ1lGgF57sPsdn53obbPAc+DQdYATGc7pD/glwH8tuOtR3XOX0Lr7xbAn9EnT9Hc6JfQIZokTyTh3PZJQBhydwYgj0c6vkiYvoAd9O8TAxh1KiMuPhd+c1leDOeB774eDVbiPBhMZ9vpV8Aogg4jTtJs8vTVAh/DYUCShO20qEtVeWf1ZSAnwEOPhOQ1BtYIZowEgg0mA9595vpke102MIGZFiFiM66bevoRG/C3rb6UYfswyEgu1X5I5C3vHIY2QV+5/eV+wGRgrtyf9UOeuvmrQFnq5zL2+XPguR241/dGT71b/sx1tUd8G29nLWvbmyvqgZkpAfgI5IQBggKSCsyc8DHcAepEb79toqJudcB79X+lvrWiAO6oHlrzyzpS9ls5XTZ2vkaJc7zdmc4MyGHBuCTp44BQtQP8lYkBMgOfNLj7PJr670yxa//QDbO+GTAKyGcMlIDXYFyv7nPgryGn3Umyzwxg1otxFqCGxFxxFrvq6QdExVMjzIPbEjEPNEuYlmBLR05HGRjbuF0GYXYTbuN657gJ+Ozxz/hik4ROv2TnOizRjMJze3I89XRd2+EgCCrwi7Oo5AXg0gp0E7gwdpe4QgfoqWdciSlHgvST+2aTxE/JZs6ldkHUfQ48d/h8cWiW42569pEB5E6OzudARnwufMs+Hm0FpXEp4AHE6SLbPKjwPy5gDAbS2jDI8kBzGZPUN2WxySNoKkgIjXfzxVRas+32qO00F6eeLsdl2Z7nvuzjA6EY25lyq36gczHYqe4Euc24MTBvMwHcjZkD/rgn+lln6hSgJ7H6v6TW9/RLftJfXF38Qo3P4F9KstPh+8gA+g6Nm/CFF198xREMm9fDTPPi1fzWgSgPagPfYQYMti/q6viod1JjvW09Az8HnKT2I+1vBE4uDK/ysvE3+k78fkNHyHYd3AbbiWTmPK2MxbYjSZfTbpS2gN/MQJm4zdgZ7PZHOfRFB/6I7xhJq1PjNdWhiFxH021rW+03PTsA7//AB6bMHVGOc+4dnvauwrW/JxFXAzgLwFYgXkuN/AWedYPztQwQg6gWP7MA6xLwIcxp82A2Q8AmPAQm++kceMEw6AX+bCapzyZcofP5f9rl6UtO4/a6HrYzzcidNQDnMaKLtlN5LozqzTl+Tu7htoZTIstvbjvxzY9bF/2xjcl5u459+wC148gzuzeVgQbw937yJz+0iW5f4veVAaz077qbsgR+gzRn5jGbw0ZuDxrbPc1owHogOk2m8cA1jfPL9ek/cgH4b24eaasNsJWv8WCbEVgLcF7YLju7c1im7d29BuB4+nepj4MJ1EbQXhhB2GowbfblvsAeuV1Wtk2Xw6wx5bAlN+3uzxss0TocDWDwOfC9nQrsMwPY3On1g5OWgnmgjtYBfJMz6PpBZoD2ttNiL6Ux0EibaQL8qLkKHxnewTMyVv3bRzsrE7AGQJoREyDcdcHtcnMY4dsYAx8gcY2Y6lI+9IEvaHKfLLmX8nrS8HVnAPK6wRafA99OVXnSCr9O6feNAfSdCxNgOhBnAXTCbx4/OOKb+23EBMgh5zIa0AaK7ZxnHrg5HFoDLdNEGPPc+qiu04zKdRw2kr9Jffz6ht/t3VXYR/dezaRr3dTLlwndLttL4QZ/P082/ZfTpn/KNTGdvr6vqfxNLwLZ88+B932ybwygr//M//DVV37LASFV5bHU97qAbeg4EtwDLUt/aDJY8WMMZNsl9PFocz6RPr2ww/llu60D1EA+ZZqNtQDs4yO9xttTAhGhBbgfDNqcFrfbYhBlG3f257SAv/UhnecrEyV3o01hm5w5TXaTbna/Bod8iKfuPv69qSzHZ6nvMOx9/xx4bgvug2EA7ARqfvYFNzDPBQ16bmpsLNWBMtIAnN52HnA9SEyDPWIUOd5u0zkvh/tBGPtntjSEePDFgdPGv0NC+gN8tADMzbUYRGICJvR0CL/rgG2A57CeJvtxY1r/zJBY4mbxNcjtnyjmrpZfCl5KM2JmpHdbcGdtzlk63n7bcQpQ/Mz+bFvL6c4AZJK9dO8rA8iT5bYW4M+FcyesARj8r8fd8cAxSMiTwbk0QDOdafWGSiWav6zD4Cd/nnO3aWAYSDY+22Fj0Nsm/ORU8XfTBz1Mm223x3ZmBIRlv9P124pLbTc98a0dDlxj9/mRNqfP+WVmtibLx45yf/QJESAf/siHP1bDpxvQE+6Rf18ZQN/FcTNGrwj3mQBL+yXVzjfdEmM0uIaAVk14D4EvKgYdF9+n8XV8I8lcFyXNnfoli8YIxARgBFyuf99g+zPoc1hoAA4Ie2XndBaLx6B3RPZP/aN2JYbUt8GAzTb5ZRA7/5Gd0+U0ObzkV3YeMk3W+kZ5P2kYOwB+C3WXF527l1jay0rXzl8Z0aObM9IAMhOAMeRB1GuyvUTKNz4GZfdo8N0tX6uvwNdLCo/qRboTcRcuG6uV+AFSBlP2wwT8Vhyn3WSHBjCYAmTwjvIw0M3s7Hc6bJij/bkNuR9HeUd/dRI9hzk9eTt/5+Mw24Tne+V6mh5/oRXf1R3hTSGjsWD6kZ3bRjzj5lAeA3Z795kBuA3N5uZwGIgFH08BiLT0b4Ry9AMsx2W3B2UOa+4q0Y+Q9vXSi8ZmoG+0cvD1Wi4MX63NhsF2ImB50GHbHWovb8XJE5+ceODuNYDyuG4hNIhOtPBod86CMKv6OR43xvbStp/TmC7nbTdf7ingN1An2zTOp4C5hAJ67mdm3AH+YBpl/p/vGXFZM3CfEr7NGYAsLNAAuseAXVXbE3d3yI7b+84APA8DTfG5cPp7BPjHvQ8evEMNoM7jyRPgNyNpb4C3MDky8HnU1ODHztcNgzgxBj6FzdQiPgXOFKK+KDPnveTetAYAELLGYbC53eRrt5mRbe/5R9kgkqsap8n+nLfdxJvWWRjYvV0YxRz0zj9s9dsM5CH90ewmKj5S9KTj4hA+Bz71SHHN95L62N30w2XTrY3VsGiHuPpDLX+/ent89tbT0/VNC84u7Hqun6UGzSZ8EfxUwMDnXeQyog67/wH8Z4pjYhB2709NQQPAYPO2nDuNYD6Vxddy4hl6hZ1ItZnrDn2J2/kNfkvEnKrvixwX7oysGpnT5GiHhxaTI/pM4zHgqQ/52k8wvkrnKUmfzBKecnC7vJ4u+8kL2m2N++gQPgfet3k9SnrqPfC/+uDhS88+e/bWpapyMz34TYNk2LRPHAMwqfzxEQJnIFtDb8YETupWnc/wAX6MbdwwhWwAPUC3ad/Jq0zGTIB4zgHknQCnwY5zADlA1GDPA9lRK2BZB1Ankr2SrsZZouPtaXo/c/IM8Khg6o8i0VVp9QnGgDUjCH/qqyBa+llzIMz5LiXN4YfwOfDcHtz7zAAYGcYXU4F4RfgjfS782Wff/h4+HTX7QuwWg5vBwACDNK3V0U/NhOSvgGyBchxJRXdlHG6pfyt6jppiY3CfSBPw8VPCbzQ1MPjjXXnOBPqOQVlTuAlQT3v/kTeHgDBpC9DANwibZtP1ieNJnsFsLYnw3mQ64sgy5xN92klz6NoaTae+E5eNei571Rczr/qs81ctwPVi4a8jaczEKbkP9D3TM+qb5/2mwT6UV4HnNu0zA8jtaG5/LjyDn0ikvoFg4n4a4PDetvRvan9HAPhHJkv7zATMCGyTFoZgAyOBIZgRZO0ktIBKeO4P5t27f++yrhCuagY9W5oDNIOVbANQVbI67k7lxE5EglJmkD3oo3qWuookz77vC457aNaGJSsDHKbhqUGW3NQzgEtFkilhKeAJnIyVQ/gceN8Fh8QA0ALO81mAvrHb+hncTUpuSGTw3+i5e6v9OYlBbmmPnZlBpvUXc9uns3nOX0/8xeJVfeYfeq3/U/37AABAAElEQVRx6GdiOoB/YgbSMnj/no7/3oTkZTFsDowoU2EGjtVsv08v1wl3ST+XpKMsna6AU524Qbqb3nau51L/x7ShMiin+3LYMKysCWxxBqCod1+OynwZ8+wUqi9jSV+erCexWfP3WYCl78ND1kujCJOo8QBkcDd3v+Q2UP9Jn8EPwH1F3gJ9b8wYcjjAN/hvrwRwXsQB8BP4oT8tywkt6QR+aQ4JHMf1RZxqDA1qFwP7RO0NCcnuOFtydVuuZfoaHQX8TFvmK/NkR5+uuzYW6fWAAT/r09Jcm+PH2T91ooF9aGcAaOLqyBw0fMeDvBUY1YybVAfKNvUGDJ4vQu8B7LR8Z76d4HNgtS39+8U8orPEx2+GYOA7nrjeXNUdBjODPn6dn/WBE6nfXKeeFggM8bnv4AFFJTcQkdKYAOzCTsa68oZxiQnl+CWpnmk2uhfyzul8P33wB3+W5qbt77XDs21hwRRyzRkApH/HlnMuu+veVwYw6my0gXgs2G8Gej263avOo/m/gZ/n+pRpNT+X3wPejCDTZPeZGJMZQQ7P7lMv+OXAbnpgJhAAkFhkTg34GdgA4FT+BgRpAc2d89zSndO2RT6lNbPBzsb0vZ1p7G75icE1tyKjLcHES96rZUxlus2U50NA3Bcvvrqska1xcBCfA+/btq8MoG/HTAvgLEBP0PvN2fvwbfzs+XP5/fGkgRkY1LYJx+0Lf296xkD8JvBf64k/wG875xnTA5iATf34Bt4AIiCPDwiUxcHR+/nzA0nOxrbBan+2zSz5HAhXPPQkLYSj0Uh/29YETN/bszzF6iPeWh0HpWAC0pLMCHCTJ3ULaV+ZmhcQKXnT/fa9hFGOaA/lc+C5b3EfCgOYteuVV179dQfkFXSHYfdnAXIcbquRES51+k4DI8xgPm9NgPgM/pKg/I6AThj0vaTHDxNYMpb8b73VS0GqcRherxFYsnlQnypPJH5v/BBShAtgQHbJGKxL8WpQsMdApxAax7JBaj1DwXzcUtqMoGcqDg+AG+wAXpe4VyvaAO/TNwLdN+6jTwAu0tUEOb6fMhzK58Bb37jNfcCe+fPKa2gBvBfgon4unLbkPfRNoPdAIV3e+2ZAsvYXQztt13HKL4N/BHLywmTGYDqHGeyZEaxjAkh+zKOTtzUtIGsD10jAOleGj/QSDcDni7yCCWwAP3RLxk8vEn+rRUv6+k7bo0fX1+261VuLYQh+WMp5ZabSwA/Yq9QHmJF/ZYrsBMwOESmj0G40xWCWAf2JmCu82szPZUVczcdh29iHeAaAdi+LmW16ZfdoggnwuXBLftvR2DQH7UFBfKZ10xgwnPGzuRtoAMSxDpCZgemz3QM/x+E2I7CdGUKmRfKbCVjy24bO4AcB/uqO22uVf2QHaAEuUtyX/OsMgOUiLZ8m5/kFuOWRnlvgCDNnGXzFcw5oBZURoBU4vW2kfmYIUbbqQv2bpiLJzklCDPfHdpH2BfwxFUhAh860pvf9iAw2/CycAVBj9xtDh8YA4jaWz4XLWdVO31sGKdfjGKQVxrbTxtttkzqawc8hnpGxxHecAZ5t3PabLtsAH8lvwJsRNJp6buDo9GwF/NAYRNmOftHRZUAY82tAWC+CYIwGaCsHR5XQ4Yy0IkZVktGDMwL69b2zk4fhdhhaAVcUQKDuEYCnvDApT6YTZgaj++apRJb+LcyMAcaRGIEX/2ppYXn+n8N6t7eX+/B99x8CAygITXfC325vgyrFWRKmoKEzvmWXYnyyEDDcJE3CJD61x2EeTvX1IB75Lek32ZTx1M2Xoqge+PbfMsgFfqTuXVr4c3uXmJ/DC/iR4hPYC+iR6lzCamUG9EFcOmgUBtBGpKS+3NEXHFTS9fCRaGQHQ7i5iHSkgQnAEJVTqPMsGjawwxC4BmsRVv0b0CvAs4T3+o3bTnnED8G/xbYi6esZgEvc1cwXnh26Z/YhMAC6fHYz1p0F8IAfSRTfO71RIN5fb3+28zqB8zD4bcMEDGrSAn77zQh6P+GmM41t8kDqYyzxDfwI1M+ptA6An1V+A+Aa4MZE5kjpyQMBfHfvGpDJbuBXeEM6gA6vbTOCYpe4AtRIE9TLP0+fF+zcZibAFEDsG0Zg7chMwBoHtutePirKwl5hPJbsZs6x78+CbWIcpsk1G4Xl+N6dPgdOFAInjzdUntJJxO6Z2WcGMOp0bs7GswAGxutxr6Q4h9Tt84IJxFXBbzD3wMdv4JOH6XJ+Bj1hPfDjqPBZ2vYTjdsHYAB7mJDSSOpymXmtgL+SlzQGvzOZIvMiqUOP9Qh2W3+ogU8/VdKGJlDDGhNAA6jTtAC5+srGILe/By1+aByOza7A6NSfaZwX9tL8330XNJXRHNLnwHMfRBv7gD32Z6587/ry4rfclv69eh78jl+ymfff3FY1txKh+mL6PFC9exMn+aSWZ1DbbeCTJrv7PPCvgL4SHQv4AO5OaxFcGAYwdQspX+f1WZr2bqQ36xnYwQwiF/3QnkGbiI4FNpVrVdt0Cb+qSGFKGfjOGtvaUnF3ZSvQdQlGkBb8DPps99XMWhr5Z5OZQWZWhHvrz7bTHdLnwN0m2/usAbgNQ1uvb37REaxLGbgAxJfjs40KejeYF+ZnC1BKMU16Rf6ljDY/FqO4SBn3IJ9JfUQXhrP/WxrAjzHwAT3tsnpvoCPxFSU9Jd3qCCBwAj95FbDNGRlhEQ6BTAN98RYJTH+BQl23YkQNWPlAUqX3U4usC8AEYBoB9nCrZ+v6AlUcmQAqZVWT6wPws9802Hn+n6X/NguA3avAs6ApAyEXtGfuNCr2rOZTdfNNiJsTZwGur3/dJHnnrpfc0GSOnyWE09s2E7EfmzkHhidzr7Q/jdo9k25oDEkLMCOwHRqB9s3x9w/9kG9W//Fjgla2wU+YJX8BPBIVKVsu1HUYGAt8cSkcqY87CMkAY2DJNvABVL4gA2i+GnMIupKOPmhMgATJ8OASTICHlm6k/sef6lGqkm+lqgbTqtJfNynq5Kx6RuDwL4d9SJ8D7/vnEBhA36ZgAnwu3IBFA1inFvKo58ycZCY/xeQ8GJeWqoDfGgIDGrPCBGo2ngLYNpjtr2TNWlH/YSbWGBqVyhNAYG5IU8BEfawFhIRNtCtOgF/B3wPftEQb9A6z7TRefIOhUp8w0gL8hCJ20wBq4ju91YT+dx3b2oLTV7pBk2vMZHG/W/op+Ildh/Q58L4zDoUBWBC39vksgIFpZtAIBo4M8BZ9Uk7d4Y8BZvVfJeYDQl6Jhu5Go5XLTACb03mY2K7DUdX9HvgtHpqBoQ5IV4MKkgB/ne8XSSoOgFnSo0tsAf1M2hcVOiQ+a+8TX5hJXyfvbRiBmQ5x9IGN6xuPLuttRbyrgAumRf3bV4NZwe/Br3yKprF5ikTdN91r7zi4bpvsdV+e3pR21+MPhQGs9PM2Nw21GXN8MmkAt/q2XoSdTWER0P2gAaxwnY4mMwGiAPdx1TY8SDPgc3yX1cw7MRakva4E/hlhbd8sbI1nBnzN5S3Zh4yxywfg0yYYEFcsLN4K2JkJCPDWAHhNeX5VOdk1raHmjZofWkflI8fHhbGtm6ah7fWGdR1OKWK8zdjTLPnRZg7pc+B9Ow+JAWS9vaBYkneTibnzzdQNZ+kU382RsyxawDogWNPI5cUbfCsAPCcG5Bn0MASH2U0e1hicX/YDtDhqK9CV47cFeNBalXY62waz/dg5DPBjmNYUaRverX9UlTDkE3VQQGgDUvEt7SG4uz3Xl41hFvN7k9dhoDP4cZf6FOm/Td286xPMMQHfTIA8MTBSdWWtucpMDNNuPQVIXR6WFPHrQRFZ6KelTzR745xG/t5UeVbRvvO5OSGYeXnDpQ6XzagXPE0D0Jd0rnSKrze3kmTH5xrImlcvmTwFMI2lX68JON7Axz9J9TJd4Ik+QJ+BP3MjQWEeosun95w3tgGeQdOHjRgGDCaeBTCIq53zJl1IfNFiY5h/L83BLfEBfgH3eOid1u1Ml5UUCAc120yLAK8/xMki/AZ+1HOrYaC+7IdTaAwfbQUeoGN8Fw6koflz4TTJA3WxeZqbZg3AdID/9nJ1cDgee6QBRLzUc4yZgIEegfopUqgMUACOpsBlsJsRQI87pzfwnZdtg3zyz29zBo6Zg8FrG0wDVjDhKwN+gJUozgzFNqDKqj7g52gFefMexWxOkwTO4bit/jucemdmg9SvfEgaBjsehQlEnRcqa63MeY7sQ/sceN/G+cjoY/fYz1YgrwjPoF+nwsdY7L6ld3Z2PuuBs9HD9JVipAHoxSTagNYgT0wAcoPeYPZAxHaYCzYjwC9lNIIv4Rf+SpAHd7UNaEDOxbv/wJWlriWq4yPDNT8ZZD0Z+RqzPR31WJCoeiBJOWlv9lRM4FoM4UkNTQ9mlXY+0ARgAp4ObCqDKYjV/kz7md9+4aXsPzT3nAXvb+uAhJeIEbn3aYpfEV6kc4numQAP8WICoxrQ2VxdTc9+oP7nBaar7kWdOV1zW8LZViEh9TSlOElLiAAa3nIltT6aocF7zd6ijF/pZfeNUp4esdmo038GfVCWnwA2K/hGuiAwa5ZWL+2HOUJv8Do/cjIjwe1402KX8LDaT06f3SYApACfXr0vZgb4YQL5JR+mxQ7mrXbwLoBIq7DQIJQGuxhufdFSqNeNblIwpjiQpHC19sRcqiSY/cIfp7xmUeF57rlPfKSGFlVulWSvQ9yLe92IQeXjZj3OK8KN0UFeEXT21JxXPnWuN/hqK89cByYz1AJyhrUQg/+mvrUXkANoDBLbbsJhBFwOc3YrAEuDPIPX9CMbZig53aJI56sFJocZAbbdRBc/Yj08BY3F1+hCQqNRCaAGPwuZWQOIPOvCLfQ8+QcTuNai4ZXeIRCqPep9vSJPMUH6got7kOtVq/CaLbSChceAWQw6COwcRCPqHV5ZoYubx0pvnRxi202arKJWLb1mVax+CnByXIQA9qPLIkdd6Ebwp5xZDwDQJ3clvwzuPK93OIwAphFpqu3serCPANDUf9TcerX0SEqFIT0t2R2HbXDZnsfNGYHjAoxVOyGMOX+UIY3qTkwA0PMadZ5e1POLTqaWVXdiAo68E7MA+Nd6x8ASM4CWsp/E5N0IFhIP8VXguX8OiQHkdoWbm+c3BOdFOoBv8N/dFcm+SQMgw+Pzp1oZIf3FCLIW4EgzA9sOn9nHkmhiBFb1c1wPamsB0ODOgzy7iVeW0t+n2wrwmlkKrwQ98+jzhoyw0RVZ1Lipb1HHS/nWAELlFzFMweA36HiQt5nKuElXJH1RLAoTl2YkrQBmSVw2ue84Ap6nfPl5gJzGburt3QOHrXkVOCSpwk6xX3YaHftV8YXaep6GYG6fCx/ResHnqO31T1SjnYCI1bwf6X9zq68AVS2A999hPBUA9GY220wLYsCKGfTGAxmb63zTndLgxfh4Mu4Z+AlYMNsulC0kL8Eqn312g5/AmJNrioGkDw2gqvwwgfzSEkAXl/JYUePRBqpGEAWJoeR3AsKMbsVNMrOqXVHq9Ri/HhNOcoifA3fbbG8aVqbbZbvnwmYCDLr4XPio8gxU33BrAXrvTpDmswCzaUA9vmsmwDoAxi/hyEwgS3/c/RUJ+RmAPwYzc3quajglx/pANmYOEVYZRUpSSAGMtYAMpFlGZRhYWueordxGXCy8TXXkGEY9vBfMANCj+rfFv5R5fgNTAXWZz1srse03AvGNwGwmhslUpmpCiaD1QQobOdFGPC4O9VXgud2HwABye1bcfC4cCVJUR147NecXenePBkyd258UaY4GYC0g7wTwtN4xD7dIA7A5FlPwA2uEmQngzkzgDp1XxtpBi7tTCi5Gbb14RBVAeFCTjqO1XDPQE5EMAM5pIoq202TFPa5xXrad3nVo4YnrWAMwYNn18HbfkdYA/A3FvPjnfLe1DWb4IXXIZwSoiuNH+a2L6+kP9VXguZ2PPypy6t1yZz06EO2zAH01eyYwaQAC8El5zt5awEgD4DwAV2YE1gLiKK2k01EvoaooPKpfZIchwAQMJjljNyz27S02qXhlCq0NFWwGX9MWKkFThcWVkJJxdUyv5dWlMWhzvMtxPe3PNHb7RaP44XfSeyIKTSAW/bQACDMIpuAFP1Fk6R8J9ONyrJVgx3y+tivP7fs0PhVoexH0xydzaeCMqn2orwLPzTwkBpDbhTuYAK8I90KgCTzntbRCAzAT8G6ANQDSnNSFQqcf2WgBMAHUxzZ4KxOAGZghHGVwdxlBA1atLRBtTQC7AcVMgbKqeu8y0cYL6MvimYsIpocWkC4Dg7Qly+XdAOezZLOCnx/mOeFlIOJqXujLmoDDlvJyuMEfftWbdnkKYOmvXon+dluc1mqZ1fkWPnC4D/uoQ30VeG7nITOAaKcfC86N5kBP1gKYBvBSTUzsBuhIMMZM4NgrcCz4SeW/qi/a608GBhNAkgeg0E8FKCYFBl3kKi8MQYyAOAYuF+67+OAF6UWoPJqtpT1PAcjCYLe7TScI6ExeJc9tni2sKU3Oc6QJdNmGt2kb8jXmhCqTDO9M9ILfKF+ny+Wn5MWp/ssLf1n6Z/V/Jd1CAG8EaqcvKwMdMYqFMwALue5n8DSZ3c/697VmEj9rk18RDiEAqPe7T6e4aQYBE9CW88ygBdzUnA18HhISpRbntEuvQyg2MIJjPQWH0UZYrGID92AGBMIbsDtTmEUXiFfEfPU3a/JFak/829LyVpL39ubyXn6oxm2D0UxMYJKmucQM0gzwTLPkBswwqnyyr0l79c816wGDlo/A7/YE86wFRv1DCzDDgtlMWnyZJqDFwEQ5LXiqPsv7IvOai8HObkOvCRz6GQB6YxpB877ZZ1/bBaAR/U30IlzfQAbXyXGcIC5RVQswXdMCHCD7WCv4hRlcBBMgSrAKiqYNsDFnjSBiNv8wRSkARzVnlMovJnMiLYVB7qvlZA1DtsF/qwpcJ6bUaNc4kKyhjZCfzKwOtQ192JrsVGfV1Rw3TQccBsMo6yDzYUgZYWo9TiGMDi3rAKEZLRQMs8uMciTZF5LOgjl0tOYMANJi4jyzlPvlmff8ftU913Z0MxDJ8YpwSb1RfE4fbgYP0wFMTAVkexoQYV4LqNuBhKEFeDGwgd9MQDbqJmP3ROtNjGNfAWqA3YOdwV8HPvmHSX5AGvggrF4hGVUIoD8+OQ8bRoAbAyMgzppABPKj9HwpmPS9aRJYEWgC+eppN/nNCGxDb/A7bdY2CoNDiqOxlbm/6QB/YxA1sDAtRSST65+Ct3YOXgU+EyxbZ7TjhKt3fscrvGX1Zjfr+uriZaezCmzb4dhnqO4ZDGkt4FQ8Hy2gLQhWJoAWwBRAjx4pLRK0DESYQXbjhxH4OtEuAtIpLjEBGARlL125nkdSbW1c3zNlfCrA2Bj8ZgoOt+262d/bngpkYJrGYbYdjt0YXO7HRMBOASf+ehCbxOHYbhtxfX1LP83zgTFG+JwXOOutbGsMmtK9yC7SVon2mOhQGcDsllw+unguB3ga0DOBqzqNn2kBlQkcwR2qaUyg+mECnA/AxjBYPWCzW6E1RbHMDLAxXuEuvvprKW9JLeKSZ2EWUB2xdSnkYcMIskET6I3r1sLRBCpgJwDO62pa4iea4nZYtgH56LLK30voaUtU5db6sOqPcX0t/V1X1ynb6FSvh3nwyoOPpXyyQJkWixLBvjrHd3lfW8Oa22TipsHFv/jqqx93sAeT/Xl24IGF3TOBm+tyOOj8tHQZrww8C8nvnNbbfblQ+xgxbjMHyp5dSDVfiov5sOjJjyvATwY2YgRn+kKPjTUB/G5fuJ3nAl6sATgfbAM/h5Vw9ZdAu/FCc6kMDRuJbUN5ZgrUM4Pf7e/Lx+82Oa3z29ZmWoLU97qE0334Ix82A8jgd/TB2IfGAPobEzfvuU/9xi84opf6Du9tBlZmAien0yr/CavL9VFemEBmBEUbyHyo5FzCpxHPcWJOEfaG1et+oJuGcMfdqXyubAwGwmACWRtgegCzgGmUl4RMeeU87DagcpmOw85gz+EGdjC8BPYAfiJE2xmuz5NGxgwO8GNKu8u26eSPqNYnrf2dFlSout8Nh4AWtgAZBAeFmYNqTL3FE1JrwP/+4z/eNIBuGKx4PYhOBESMmYCeYy1+TQXutCCAJnB8VMKIGDGCSKAfTw1YJwD4XGGuihu/HmkxedgGXraJuObY8ILxIp/bwLQgGIGYgTUFmJfmCyE5oSN/g8zZKiiMmQCeAsASzm+vIZAmQAujwRNTlaLNoLVQli/nEu8iqICPsuQutKVO1MvtL2nLGofrQpiN62z/k9r97pHyO0hNoN7qJ+2unUjvtjAq2M9jpGM/c3Z29lW//JFf+SW5Y5AW23NpBlsZoCW8DCqDibCb27IWFKcE9dqwOy8WKO46vTXoasO22y2fzB6Yu0sB8RwGMG1DMoNeNVMYp+5yHVdp5yFHevcAGsO5FhsNIBbxyMN2zo+wbfZODDznCfDXGa9zWMXn1V24YQYsbhYmMmc4c6BP94pyCoMo0wnoWANgPWHSIAo9cTwOjH3KR0y1pYq7nwIwFq70zoGveec7v1mzR77Jzs0H/I90WbjYVtB+m4mF7nc7qP20BD5xa27UpW70w+urq1dGTcxTAgaEDW770QK4YmtQmkBeEDzV+XZrAr0W4Lywe/DrK9nNFPBnTUEyeqpKpZuA5SO3uY4tswUH4Ie+AXWBLgcD7nUXICsArKBE6ksDaJfiGxCrG7Ab/NDZ7Z0NS320FEy7B6oIZdlP3BD8qnP/HgBobfJ3AR1mu+0AXF/3bwLO0n+uqjnxntorw2xP2zGqdr5p91555dVfZzD2ZhSWaRhwaQMgmMDprQ7kpMBjXugnkxlBzmPkPjvJr5rXYL4p04lpuuCBvPkWZVD0ZSH5eYcgNPEuQbHEIt0zv+xTGVwTwA30bDuVy4++lGYSfmx1i6+JtjAF/IAfWqdH/TfIOeLrcMIw2V/q0Ul+yDoNxPlb+kdGG36+9PKXPsLi8YDsoMBP+zaPrkEv7HjQyk3iZn7ms5/98Fzal0E1aguDxRfxTXqiBWjQ3p0JTB0TCE0g9vbrusBggS+XdXv69L1zTRnYPsS+S58gy3S4yyCWtBSoeGThrG43ZjoP9ByG+ygOA0ky3+lrvPFIMo9Ely6y3afZyl/n+A1wqhvGPLZ/d4EZgeOtGUQaBWbwR0b1Z2IIJX8zg7IIWYhC7V++nTm7cPuLwF75t23Cv/tTf/9D1Y0QmQkS0xyKfYgMIN+bdgM/+o8+8lNEMD9cMccc8wHoJ3cAP9x1ldiMoAFMgDITgA5NQLCMNPygDaAJ9NuEAB0D2G0uVRemBtenTwUjyNMEn1WIhbKaAAmOuT06u9Wx4BVGB6BdT9ukKVOGqY6RR2UCkSE/VXI73UZb6jvGUj/cFYQTSOcaBDQBaNGZIQD8sisxnUiExhf1sClhSP0i+QnP8WZGMISR2fYhoIXPytH5U2VGBexh2Lin9rAhtcpuDzdquBBYJE8BA+5TngyraqjBT155YFlS8sorVGrMjRaKbljxvppwyPlx3u+LudRbbI8rLf68QAjQYQin14+0+PeWe3eXrwYTgG5upl0GD27HwwTsVrmLAxMGAHC80Oc0tgnvTT5LP4vTXv3qdl6hoAwb+s7MiDD3X5nXTwyQuNzPziOHQePwDHynnUl/aSXM/yNM99aLu+THbogZwLoFQO7hV3/1V7MAyBTggS4vAnpKQL+vdhoV2kMz30jewwasqTJIbe3TIHj44OHDl976lmfekdPc3KFPI1EkiqQD3Enya6MvRvN4wUjfthMjiAVBMQFxkMYI2CnkiT52CcqBofPGCHxWAEZgbQDJD8TDljaQmYQU/amao3mtXikawDrhvWSTNjBiBld6XsHaw5RpcRlc+MwMkM5DU9X8Pi7n4bgM4skNbiZe1aeb6Eoujs/ANw0gj9yoa+2fDH7XA3vG2Otj373ab/qrqysvAHKE0tyqcP1CdDDgpzkNIKVte//LzcnD10yAm3n/S6+8+n4xgB+glUh/rTxLlZ4WoQA/cRjtm0sLv9KMQfG6+KS0Bx/x8WJQ9R6aAFMCGMEJqqk0guuKXTOC67T7Z0aA1Ef6Yxxm+yowUgX8NesRrVoa9EV7ARynp2c6FxBtSW0+0ScHj4603nGneb9y0v6FzjT0gj63xRLagItKPeZPzm9TUpezlMbxAB+DcmFagI9pTKpKfcIchxtDGqcLf80vh3ES1JMj3J/85HP/IBJPPxn89aZMkfvuOjQGMLof3MD7qHQf/9g//rl3fe3X/IDGxcwAAB61xSD9AT9u9oub8ShpATimT4eZEdxogdAvE2B9ACbAAqGNzw0cvU2KyAV8aTLANdYOVHphAorT8gQTi/75A6fSCT84mZ74KxAH/I5jmlCYQAnJA98gI8Zttwbg9K/Vdt6j/IjL9aAM0xvwJayUblqDuwE/iNh25FZNNxTGjnE3IP3JY6zNBemM/u/8nb/1/qr+E37Q4KeBw2FNxB4bRgAik7YxMnyd3T8/f/De937vDxHNoNBhPsBzJwDEqBF29di9JK5GpAbcHUqCRo6cEYHsUQy/ylWGAc5z+loZUH4qiB9NoE9QSeWO8+3K70gX4ZiYf3IIRV4W+mAOtywkoqaryjeUFTnq5Rmcj1eohm+k5QflIsqBrtYDhwb8UZ67U9GjW2kwqodckT7bDs8gJX50kXgUvhQWhenHjCXTRd1Tfn4IKIAbbVKfqrpBJ3/pbNonFwu4CkNr4xgx3UreShF0Gfykj8tt4mbirv2B2wY67g7X933f9/05CQQO/TDnZwoAh/El52GZaWQdVrtojedvrWW84EEfDH2+BSQHgwoj+HKzGWQF/HhkGD+xfSSGwOukYmpQB1lImfrs/Ym0hrgkmHnBqF8yijbQX9YM4lFjdg90xdrB/fNm8wYdHkNmQdEPIkV9YkwCgKhe/JyKC3C1kFgfaL5wMPAZ8AEGuQEpVwNMbVNOZbD0NJv8ziPTlfLri0108o/ackHDLcAG6EFHeL2cFzQAHrBj253Bb9oVO03xHGcmRbddXlx8VOVzQCOrZmgBjKWDxMpBNso3V7ZVOG5g3NRPfPJTP815AC4WABlwNlb9Q+orMEAP8HVBA22EARgxgXKkVPZZ2cLiyTs/fdeYQGUE58cCdb1aeQOmAJMom5Kh/QdTwG9mAZM4SecAqFPlXc5Wmk1hBEwPJglbJGIB+yrgofMF+DYxBRcGbb4Ix2/j/iWs5K/+Vt+ZJPoU0NeAyEuwnhlpVAC8qPtKrvjwE6bLBhA7v9Iv070Npl3vI/T9F4CYQX3wgx/8685LNmPG44dgBENXMYL320w9tN/tyLXvb1K7iczt/tKP/IX/w/PlnMjuE2n+uAF62IC+XvgBdoTDBCojsEaQGYFpoT9HE0jrCWYEaAfZHRnrJ2sKDsu2IK+FQ2kLdS+euBEgAFoBG20wwOfgc77gzxftZT4+pXHaVbus0Bd6p5nszBxUa3EqpHupK/krHi0gHlAKrauF5fbA4FwO9fUKvpUdXujKRb0xtu32/F88v40N5xEJ6s+P/diP/dTC/D+THZR7Yp8H1awCXjWJKTOI5WoPBv3cL/w/7Aa84+z8qdszSe9TnUY5PTm9C/CHij+BX+nagMPNoENaWIJ4Bf22vkX0VvN2dgwwU1zWKCMqflg4zIZzBdnEjsJxmcnoGSQ/kKidhkwlUVUBlNcATIG0dD0IMzgscU2HndcD8JMO+t4mbpPJZUIbwNdoo44wljB6EIgtPCT7beG74Qbs2fRgNfBN4zbZJrxf/INJo9kFz4HB6SIf248ePnr+Xe9+1/dWBpD3/7lJ7vH5DXIF9tgu4myPG7Ch6qBn1kYNkoef+tRzP/Etv/eb/019cVaj4lTYvWUXLwaI82MweeAZ9B54YUviwAg8VKEPoEg633L6TowAyVO2D8snx66vHsUUwW/pyVpBlNsxBCp1WnnE9ZkWDHX8OIysazEG+7VvEQuGfG3Hx53NDJgjG3xum/fNm1+OAsICRoebSXiebNvx62zKtBHoAuj4w10jeB8hDEqhPLns0NbvDrHt/sefwa6evuXpbMIBPt+BkAYROUb/o0Ikxg5dNqH+/+wH/3INg1sXrlumALjJu3D1SnQoVuv2Q2lQbYfbxY1jJMIEQOF9mbd91/d8z7f+8A//hb/x1PnZ0fn9+xyrPdIOgXYDJCdCKMzBr3TNWGJkLYBITpDZWPqhFcAIesOgzFKVeDOFntZaAtpBnDeoBPltQtdVYlpy+ll9pCrAdjhJgyHoFxtTABjOxZ+q0CzGDyOQ7jbsisig1ttwBgO3vq+kDZHpIBP+DHTTazFEnSYWJ4A3ozCxvAjgGDfhXswNt7Z0Y7H25CzifG8LXamTmfzb3/a2P6BPj39acTAAdgDYCYD90hDqenDSX22aS0cCDsRwsxht3DiLIm7mCSreBz/wgX/0yssv//L5O9/xbQwqjZq1N9eDxGq/8gn1UelCC8DvQnBnw3wdk6cIZVCmgRwU5axAzwialtBpB/ltQpq6RA4n0kgwev4vbEo4ruAjAKZFp6AO4y5mAmUNWLF4C5o1i5XIQUBhKqXdMI9TZeD0Bj7J9NKV45u7kziMhR8w60XrwRjivlQmUUAuoBv8FfiBygr8yA9JXw1TOjl1tuv4SBpeMHfH2ea+ck+f+83f+ocSAi+KARDFTzgqXalP9RyatTRuD6GdeWSDCEYH9p1W7+9/7sXPv/TeP/Qv/auoqpyoU7DWAdh+0n/5KSBXTFOFGTBQ1jAGEIUQj2QK6eS08mOIY24dQA46dbkyiHA5isqh9HXwxmu6OWcskN4lKer8bTNwyRObWnDpTKJspiUUULb3Qm0XYFQLVbfU0fWlfsRHvWtdicsXbcXksMdxx2Ke8iwd5f0NylX/6AxGaAUwYbnVG1EcgJdEjx2aozsdeZaKTxyX7lepkvpH9cj3WO2YvNK8or3R92zpRhtKkrhvJCVH9cmf/TP/wZ/82Mc+9iVpcWwBAnhLfeb+pHU3yHlYZuqxw2oXrXHbQCKMDvDHNED2OW8J+r8/+A9+4p3v/Or3MA0413QAqXF2KjhoUDAwUEWxbXrVNMdl7cDS1WH9dMFTBPK1ZhDutIAYfoE77MGbfSMi/cRUQduQnloQRTusGidSwah+tkiBuS6ZZuR23tke0glYs76qkrynvbu9uRCg7+tefEln8N+W40Pqp3RFOyj7n5VBNFBmtd95VA0ARuNxoCWVyR2MUcQ6F/Lhd7/73X9K5f+OvEvqP9m28vAcijlkDYB7ZO5NO41kbI25s7vPff5zn/6D3/2H/kjs50t8cCBPkjMWkLKUC0aQhE0AH+mhMJ4yAxCZnkJDOMFAuCgchlIveRu9HMFkkFTQQkMCNIOsFShA/9JQ6pW1A/KLvBGgorMf8HMWgPOEgITlyThbqAZJqt5xXoBFM+hUnAo8ndluk3cHok3KPNu4kdZSRUgfdsTjjg8gpgcZlDZAX/f9tDYBU6b/pnehESCDtKdc9wv+ElPiVAbdGgaaLP0JjH4Me6KDEZ/w/LFs+p0M//gP/uC/95vPPfcZSX+kPnN/pD6c15qAnIcJfhpmUOA+ZMPN9bwOd+zL/cT73vezv/bxX/uZq8tH0j/vdGyfkTGZLOGn0MmFhOf9cTCBbEjHBePgQtrkvLLfTKEdEUYlF4hj20qZer2g0cEgInw6dMThI15a4heXePAD+ni6sU4v7M623TACmIU1BmxfuWy7A+yAXFeEnZ6XBTmH1cW5qGz6QeIn70YnecNgfTkB98vu3rb078PxWyvD/fzzn/6HP/P+938Ct8zS6n+JPdDfxkUPtH00KwSwbE8DQGucCWBLQIz/nT//oV/821/17Nvf8cxb3nKXpwEzoArIgJgBlMFMARgzATMEbIzpsT01cLj9HpQM8mz66YHjfM6g+bt0Dgfcdj+ufXQ7TRP6z6vnvABiVs+RzAZn356cDrX/0aOLtz311P1Q/z0dgCa7cxqYwchYG+inAmYEeRpAek8Fvv9P/InvZ0F4Ye/f6wCvuQ9Hdd21sEOfAtDfMADfRMDPKCLsiMVA2feefvqtV7/v933bd0oKa5FJanEsjIF4SfA66ELjlNAZgZ88soTKAz/SKR47CpVNZfCTN2GUk+NxI+CC4aBGSxZHfKUjEVMEaErymt75yNYSWEQCAhbEgvBxfphK1Av1mumDOibK9LSilq3C1Q4qWA3td90I4pwFeWCrZheo/ldX1/cBLDa0au1p2NVtesKs3rd4aHSlIqP8TEu5vndK3+oG+Knfj/z3/9P/+GM/+j//n8rjui7+WfXPmoDHDdkdpGkdc5CtK41yGwH+UAvQgHj6r/y1v/nnv/Vbvul7nnnLW2+fun9fEl3n1aTaM3YAou0s0ZeYgWkovkl3DdiRcTxxvUYQYUm6aylcx/omyZzPGPjAS9YO/GzD9c21+2BUha3CAK+NgLbSmMz0TJfTOAzQj8Idv42dJb1BnhlQjh9pAToI9lv/3O//Z/81pX24QfpTnZW2blPHfaF54oGxJw11O80E2A3wVECbAGUq8MGf/bkff+fXfs3XP/3UU8fPPP1UqIr9NMDtNfgz2B2HPQL2pnjnNUsrBsAgzwALRkBmlRlkRkBwPljzJOBfAqoZQK4T5WKW0pRYSeWju4urm9tzfVD1SB/g1DsYi0382cnxpd4UfN/hmdbpbWeQE9YzAsc3BlCf8fjiK68+/N3f+Lu+V/fVH//0yj9zNg7/oPpj4HgHDX4aCSDejIab7SsWBCVBHnzXd/4L/8YXX375i1oUPLq4vLx3rQMkLA5nQNJZBn929zTEjQx0pkXiW+qbljiARdkGWLgl/Q38WLEnQZ3jH+ukG6Bn4dDg50TdJvAbrNnG7ct1sg3wMdTLdXPcUhrADA02F+C3H8A7njDi7O9p8fuK9LWeuDGuD/XD7/rQB1w32gx5cHF192//u//On+ReQyPDvfc4wLaZ1B2HHKj9ZlgD8K1DC+g5Ov5jgfyYbUEIP/ZrH/+Z7/qu7/4+rYo/HSvzqP+eRDIdYDrJGNOFG8BiRzgFVH/QkKFM0BVnSSs3wGdaQaE5DQM5JH7kf4+XfMTWXFQO2nrJKoZ5ugwLcdiqVSXVh7gX5v6Ag3k1WKl4aXbk4UA8nTHQHExePTlAJczhtp3Gtvrg5Pq6fLPAYfbbdni2nbcYHguOUQ52uTXlvqjPQ+vz+sHl1dW9H/q3/tQfZ9Vf/WvVn20/LjMBS/+Vbs7lH5LbqvEhtWmpLbmt+SnB2VRAA/xpDaZnmA587de+8z3sDOg5AR0cOGV9sEl/3JisDQSQxTAwuG0s5XMYcTmcfByPxGexCtt52BbgVsIcZ9uSH/XXbuIAK6oxdjZq72KePeCdrs+DcIDv+GwDZj1xeQ8bk92ZbuQ2bbZ7OqYROczqP4wUxnh1ffMQyd+BP6v+ZgB+6o/shm3J5RyCe9Zxh9CgDW1we0FpXhAcMoG/+tff99/+M//0t3zP0888c7dpTWBDuQ3s0DGt6E0GPyq+1X3TAXwN7DjX7jDbgLwHu+N6ewTczABGgB+lcb5LoCf+tQDe+W5jwxSyEQvVe1nTcWM9a/CFl15+/vd/x7f/oBSCB0nyW/UfSf83xdzf/TbvQYcetg0TGHF3mELM+T0d+Cs//r/9zLv/iX/y5hve8099O4NN21dKx0s49U8fYVdNILxI/ypMQzOQGykPjQsE6Ph9OdzpmG0gkFs4GcsQDhMovvkvks5MYKT2A+DIk3xxdAbQO76LCi9xSFUnBfSo3yPwA3oe/PHDPyXvkitu91cu02FQmWaTXWhLOer1k9sbHu2OKdmR+vjo5vrq6MMf+ZX/9Q//y+/9T5X//w/+cgtWft+MDIBOGDEBgLHCBH76//qpX/nVj330b3/Ht3/Htx6fnX6dwKbxFAwgbI3oACthQsjUwdVNCHGOMR2MwWGRqNKj9hOOFnB2fvZ5gfcZgG/wTQVIwkryG/yE9+A38A3enEkGYM4Tt9NRpsvFBvDZ73Q96Al3/rhJk9PZTRzG8Q7fZLNmo/zZz49Lfaa+lNJ0e3t8eX1z9+DBg9/5z/+L/+w//q//yz/3t9TfjwP+UqE30e+bmQHE2OvudWBPYTNN4FOf/OTv/OiP/i9//93v/vq7r/u6r/tGPT14v8A0JDnQb4M4M4RCU8AgAojKJfrMFKIOxBWHztsgXXXaX+AnyFpBiS+/lvi2e/BDZSAJKM68ZeE4AgC8ymjAb0TVkYHfx1nN78Nz/n2c/UpbGJhsVXHry92IrX480mEiaUc3xxePHt77+Z//hff9wB/7o3/6Y7/6q79B1Aa1n6owH3P/2HYVD94OTfbgWzluYG47kh9myFqAL04JxhkB2Ui0WBzE/Rd/5H/4T/7FP/Cd//pXv+PZexwaArAs2qH26zcWBkPVZ0pQDf7emEGYVgN5uPgngMbAfPrp+y8+fHjxTvIx8LFzvgXMx19Ufd/ucIEx0ivMQc2GfmS8Bz+KG4F+lLfTKk4S+06PYZ/cAXqHb2sD7kxLWWg+2By55h0KL3z28x/4i3/pz//wB376pz8r4Mc235bgJ+s31bw/9+Vj34yc+ADcuf0jJmCmEIxAA+5p2iw8PfM9733vu/70v/9nvv/3fOPv+oG3f9VXPcPjxOwWmBFABzNYMga/4zODWLf6vwR88gHMqPsG9Trgc96eQzcuHxvQ+yBODs/ubcAP4J0G4KPBYDtsyc5AB9xDo42GK70nAdA/ur6498UXvzQEPmnrKb/Raj/RXol904KfTlgeocS+OUzug54J0AOABK1gpg0QASPA/q/+m//uh/6V7/vDf+TZr3rH11dGECv2MAPiY0EQx8Bk4BOdwa+8Pn95efU1AvQMPJb6b3vrWz7/Oy9/6WvKHP/eF8Vv3i6YfVFAas/W90Ayc8hVeVxpT9o+36LOF463DuzHdzcPbo9OtK6hsxfH917Rl9Te2udF/vFQlV5qAthtAD3fT7x85ZV7v/HJ3/ybf/Wv/fhfRuITv0bqk4G3+bCnDN/Ekp8+w+TBX0LenL+5HzIToDc8JZhpA0Ro4DaNAP+f/Q//oz/2B7/7u//5b/rmb/ouHSc+ksoe23NMEYhnYQ/VNRuYw5LqD10Gv6U/4WYCuDEGtqU+YRlYjifcZrSK77htJP3/x96bB8h1VQfer7t63zd1q1utXdZi2RZ4t7HBbAbjxOxDIJkQkgAJE7JNEjLzZYaQIZMvCYRA9gAJ35DkS0LYIQZDwAZsbOPdYMvWLrWkbi29793VPfeec3+vqk7X66qWJYOlqj/qvHvuucu7796z3c3TujLkhQoNej/QyTu7Xh5nd1DGdG7AewY570z09MJ8NDgwtPu799zzhY98+IOfgybPwPdRdprP40qD37eC+WV3fBN1wQWz2wIm4BsBBuCfY21AAm4PgYeWEXic1wquu/ba7au6ul7gmUH28eM+3jMFP7hhDh6X/cse+Nn47IGfPaiLHfgrkfZ2oPp6FBrwSPaZ+YUG7PTs+jPYPc4/2x9S3g/4aGYuGhwe3713755vff0/7rjdSnufNsvO90EGvpX6Pg7Jj22Ro1V5ggvxl93pL8T3z/fO2W0CI/BMwP9gBrE24JF+M5GHMAL/jHngnz0z2L59x7YtmzZcUeP2HqcqKuU+Aie9vMffk8iPjSuEVyLx7WDNZg5e0mdvuiF/D62kt/l4Gj/ok2z5gnZ7sNkrjTrv8/U/Brx/9oM+PTkdjYxNLRn0Pj5b2vtwsPH9Y76B7/H5pL7Hlwa/bwX3y+7siin923aBCXh8EiPwcUt8BB6ZzQh8+LbXvOb5N998y8vXr19/RXdX19rKmpp0lTuN1DMFzwy81PSMIaSVjko4G5dvoGYPek+bpOLbQe9ps/PzA97j/M+q9tkDXuMRqEIuf0mS3Ud6G97/RML7hyDlT58+tfuOO27/2hc+97mHPZofg96HvbT3sMiB70mR+v75gnb2+QbI9ysxgHytojjbNoUYgU/lGYTsdsunFXiCfAzhqquu3eU1hO7VXZsqqqrXupWIC37KzJ2m7fYk+VN7tSrZy1x9Xvz0ghMNubV2mU6/oDeJ+FuI3cRZjPeD3S+bhTZ7wPtckgZ9NpNwFys6O8bVK0h46iLpne3uf3awe+nuDuF0Y35RJPz3vnfvo3bA+3RFDHpPhnTHjvCQ5/hdHQ4OVZL6vtXMz3ZyE10KuhawbZTECHxjeQaAeeDDOczAI9wgEsehf7bMwOP8z2sJnim0trZ2trd3bF/bu6beHe+1lmPHvJbgdAZ3Ft8y84yaVeJ/0iD3CXIGugtbie7Vdn4Mch/GbndagjMt5qKJqfndXrIPDQ2d8IN9ZHT0GHY86YErHPQ+WfaALw18GnKF0HbuFSa/oMhtWxViBL5ximYGnjiJIfg4//OMwcMN6zevdzMNW7u6Vre6yyy6Ghrq66vqq9am3CG7lU5jyP5xW3E2jufsgQwue0B7XKyq+4BT1/3g9j+3G+DI+PjEhB/gPvz0U08/ffDQvkP+OZ9U93h+2YPd41Dt/bNR7z0qe3DzbGE+ie/TlqS+b4VlfrZTL0NainItkK+9sJcZedl+At9ohLOZgccv0Q48MltD8GH/K8QYlEr//QIl/9Tc1NQD3jMMnoGrOle18OzhyRMn/bn48Y/BDMJLb/+cJMGhy4Z2oPu47MHuw1kD3gdRLfwAZ1DbwU7Y00Pjn1H1/XNp4PtWKOKXr0MXkaxE4lrAtp1lBL6RGPxAcJZZeLwwBP+A/8A/Z/+SmIMfaCthEtl5PpNnyi1moPtyEga7j2JQM6AJA/PReJz/MfBLg17bY0X/thOvKHGJWFogXxsuxwx8IssQPG5ZpuAJlmMMXrJ6BgH09MX8POPIN4CT0pJ/NsymNYOcKCS7D2cP6mIGvE8DnX/2Pwa9fy4NfN8KZ/jL13nPMKtSMtcC+dozHzPwjZWPCdCIxMEULL0PxxoDiTz0TIJByHMS48hOt9yzzS+BNnuQe5J8A93is2lsnB30Pp6BXxr0vjXOwi9fhz0L2ZaycC2Qr21hBr6Bsge3DzPo/bP/2bDF2fQ23oftzzMNP1BhHjzbwWvT5QvbwWsHrI0vFLbpfZkMeMovDXxa4izBfJ30LGVdyiarBZZr55UwBbLMxxyI87BQfDYtz56h5BuExAPtQAbvYVKcxS9XTvagLw347NY9B8/LdcxzUFwpy9AChdo9myn4JPmkvccXM9A9jR+A2dCnXe5nB+xytD4uiX65ge7TZQ92H/a/0qDXdnhW/gt1xGelEqVC8poL+ZrFMgZokhgE8WcL+gGdXVahAZ5dbmmwZ7fGj8hziQH8iHyIhGo8k++TxCwSijor6HyDnIxLkp2W+BGCz6SD/Qi9xgVZlR+1b1ca4M/Bbvij1omeg034nKtyoW9eGsjPuU9aqnCpBUotUGqBUguUWqDUAqUWKLVAqQVKLVBqgVILlFqg1AKlFii1QKkFSi1QaoH8LVDIIZw/VQlbaoHzpwXsGChNgpw/37b0JqUWKLXAMi1gmd8ypKWoUgs8J1vgXPXxkqLwnOwOpUqXWqDUArTAuWKO5F+CpRZ4tlug2D79TDdK2E0PJYXg2f7SpfJKLVBqgWfUAsUyy2dUSClxqQXOYQsU6sNW0MtW5vvvv/9KX6f1a9e+2sOu7u7/4eEyv0LbnksKwTKNlxVV6HtlkeZ9LClaeZulhCy1wMpb4JkOxpWXWEpRaoEza4Fi+yoCP/vMkrhEBP/OnTvv9kh/M7v/TYyNf9vDb337Wz/jIb+Lt2+/lmcPd+zc+W/ZYfecpBiUFAJtqGK/m2nWFQdLisGKm6yU4EJvgWdrcF7o7Vx6/8ItsNK+iKAnZyvw7UmJEnZ3H33JJ2hqab6RhB5y2dnszIyg3SXP2dGRw8vlZy0tLVfkRCw9CbGkEGgDJX3PQt/NNG+iggVdSdGiJUqw1AIrbAHLJFeYvEReaoEVt0CSYEjKyAoM6AoJfKF78gc/eI1/GBgceb2HjU2NIvjn5+Z8cMnP3cmeg1t0d6j7X6qiYruH5Oc8AZ/zYfcrNIZQCOx7WMGluT33j0O239e+d85327t37y/7F+/u7v5jD50Zf9LDidHRv/fwO/fc8zcevv71r+/zMOtXbLuWPANZjVZ6LLVAdgvYwZodV3outcAzaYFi+5YVEJSZIyhAOpgjcD/96U+v9nG7du16nYezU7O3CkzP3uShu/PQg6i5uVlgV5dcmizPZ/LHlAEKhLsa/Xafz8jQ4D94mKUY+KD/2bsSEFwam/l/risE9nvzXe13lO83cPz4e/2rr+rq+q1MEzjBPzmZHYzSc/Mi+J/e8/SbfMTVV1/9SCAo1K62PUuKQE7LlgKlFsh/bV2pXUotsJIWsIw/KS0CgXgrGMDnFfDMxc/MpV/mCZuaGq/3MJUq3+bh+Pi4B5G7GFkgLv25YOmv7e0VfGNTk8Cz9ZdK6WssBEWDKYSxkbFP+TKODxz/Uw9XILg8uf89VwSY/f58Z75vzvd0Fv+7/Mtt2rRJLH4r8H1c9q+8TLPne05NTPyRj3eLNt+XTeeeUQgKKVglRcA0XCl44baAHbwXbkuU3rxQCxTbVxAA5IcgIAzMEQxucd7zfERbW5sI9rn5hTf7cEN97eUe8kOgY4kvpJXvT0yo5YgCAB2CY+cll0gWCBTyIx9c/eALQdJBR/ok/MjIqHgK+vqOvN+nOQ8UAtsf+O5877zfd9fznieLLysrNLqQAkD7Wjg2Oibt+d3v3vNLPs5NEfQHGhQBkliF4LmiWFH/Eiy1wDlrATuIz1lBpYyfcy1QqG/A8HkxGD9hoHB666qvrqy+2hOkqqtv9rB8MS0++nSw4D3O/xDowPl5nbsvK9Pip6enhW52dlYgAh9YVVUl+Isuukggf1ZQg3+mEEUAj0BSfs9AgJHlsy3IkvoD/cB+/5zv/opXvvJeX/G62tpVHq5U8NOu9rstM0Xgi/E/FIKSIqDtUfovtUDcAkmDOiYoPZz3LVBsH0hi9DQQFl+9RzhX71s9bG5qvs3DxcWFGz1MR2Xigi1bSOeUi+BH0Hta/yOMQGXuvbxck4+Nqeufuf75eeX3KABu1b7ks3btWoH8IUgQLBZPuFhIPngemAogfUWweMvDlAH48rANEdd21qK3Yi1asrIKAfhzBZP6A/1A4PDw8H2+Ak1NTbKIksqsVAEgHZD25jvOzc1Lvxo8fUrWFGzZsuVvAy0KANAqAmRp2680VUDLlOB52wI5TPi8fcvSi2W3QKFvDmMnTV7LjkjmdBH0bk5eBD3xCGTCCHTCSYJ/Pp3Lp3H1p9PKp4HT01OSFfki+NNBEehYJQZn1NPTQ5HLQgRKEtHkhCocg4NDQtISFhdSfv/AgODxSDjBJ+H6etGLIlzfCP7ylDZ3RYXuPqiorDzpE/QfPy5z3U6Q/aVkkPkrJMgylCt4ch6a9Z7cudIPFZksqV+I4D927Pg/+3zcrotbPKyvq/NgxZa/JHJ/CHzCSZA1GYOnBj/ladauX/szhpb2A53b0ZauvYCupBDQEiV43rRAIWFw3rzoBfwihb5xUQKfOfruru5f9W3Z2tF2k4fOIlcJ6wPutxAE98KiCuqFILARkErlPAHB1Q8eSDwWf5Lgn5xSwR8cARGKxsKC8ul0mCpwAlWy3LRpo8CqKt3fPzur+/3n51QeHDlyWOLn51UeHDx4UMKDg6cF3nXXXQIbG1Wg3/eQTGVHtTUa/ok3vinENwrE87Bhg5ZbW1sr+OpwvgAeAQQWYRSEhqA4DA8NJ811S37uzwo08MVCEdgock7h+EiRCbH0IZewW93/LdJTrQAAQABJREFU/3hEodX9JCpWsNM/UJwIk4+F9MPy8lSfjzt+7Oh7PFxml4ZVBDy5/1nPgGIz/yXFINMWpafnWAsUEg7PsdcpVde1QKFvisBPsuCkEdnv3rGq8xc8orqm+kYPsZCxsD3O/2C4GnJSKVjgMGoEPvHgbdgK/ligB8WCOf8TJ05I0s7OToG43vEIOIEm+HXrxLCND/ohfX//cYlHUD/66CMS3rJF1wr096sHHkv+qaeelvjamhqBjz3xuED+errUw3DttdcIavVq2Z0Y4SFoatapCDwBKAKsUUARsGEUg/q6ehFkbIdrrK/f4AvKI9Ck/EJ/fN+R8an/19M2NzeJ52HH9q3ynQuld/E5CoDL78d8mm07dvxjdlp2Z2Tjsp9tP8iOW+65kCLAWpHysFYEhdTppd/2+R44eOC3PcxajOmD/odClaQQKFXmv5CCkKHUp5LCYFukFP6htUDOIP6h1aJU8DNpgWIFPmUg+HO+PQKhe82a/+EJ3YE42z1EIPvnYn4w2iRay/AJU461CJnbx7V/+LBa6iPDwzlFDA+rSx6B/737ZOo5IpxD7AK1NWqRuzlqicIzAF1To1ryp06rB2CNmUJY1doqpCeHtNy52SkJD54eFNje3q7xp04JHB4ZCfgOgU7gCqypUdd4dbUuVuQ9UQhQbJxC0usTOIVEXA9lwUPgLO4/kozC38DJQflu4CrKyrSggEhHi1f6x+HR0QYPW8N7zM3NrPXhJx5/4sseJv3mFxdHfVzXqrbd2TSVNbW/6cMIfL5rNk2+ZyvIVxq2eSL4wdMf8UQ5vCg469aulXY8cuiITBXc/8D9ohBk7SZgfKAQkKVVDFCoiS8EV6owkF9JcaAlSvCstQCd/KxlWMroGbdAIYFeqADLkJYV+J2rV8uiqdq6ul3ZGVtBTFwSnnggAgDLnzAQOgQe8MCBAxJ19GifwIsu2ixwdFTn3rHc58Kq/+07dkj8448/JnD9+o0C165Xy/9EmJPvDAcA7dmjlnxPt1rs5LMj5COJ3R+Ct6FB5GSEZT4+PiYkDQ0qV1EAgIcOHZT4qupKgbMzumuBMAqMRLo/8nfbHwXFQUUtwWOQXsiVN80Bj4dhbHzqp3xCV69+DyurtNyKitQmwY+NiuthdFQVkImJCY92HhrNd926dRLGo1LTWPe8EL9fIsyfy11efDYdiSLR2FArDWkFN8ks3oZtf1hpmHKsByqDzy9vU5XVEuHq80ZP63Yp3OSh8xyJQpW1iNCjV/LL/WBLU9rxuZQiPyb/i+Sn9diSwpDcNqWY0AIlBeCH3xWKEvhukZZItiwLRU1Ot4POvAICH7R8Y+bwN27YKC7fmrraGz0BlibECHgscvDAJQw6Yc7f0iNQH33kYYm6bJfqG6PBgm4KluiqsGhvz9NPkYXAnp41Ak+fUov8+HF14Y+OikHqLWOJD+fGRNu3bZPwAw8+IPCaIMgbg2UvSPdXGbYJMvcOvhBkLcDUtJbPWoDaOvUs9B8fkCyqg0B2y+FysuzrUwWnc1WX4PuPi/yOHnroEQnXBI8Aia6//jp53LZV3wt8qiJV758P9x0Vgd3c1CSuhObG+ss9nu81M6PtMzKsikBNTbWPjpiSkID7c54fiTh6fOAguHywuaVFNbMQSTmW1uJt2NITZhfFl/9dHRK3vPIWiaoJaymgA2Lpx+Gw9oSwhYvpuZxxMzk+vsrTOM/PH3t4rP/E73jo5rbu8eDEiZPygRoa6p7w4cHBQcEfOXJEP5xHul/W+FREZkqBcCGYpEDk1LdQJi6+WIWhpCgU0ZjnK0lJAfjhfdkkwc9AF0bgBP8mX8UX3vjCT4SqvixA6PIKfPbdX33l1SLw2zs7xNIJaVcMUAwKJYTBj4WT+XCN19arxZwKq92/9Mm/kqxues1bcrJEILW3qWv85Al1oadSItf8WgShZxEfnoON61WQTk1NSjyr/2+55VYJp8OBQc4yljCuagm4v5PBVb96dbegmCIgHk8B4br6iUCn78WUwNSk6mUzs6oYbA3nDzQ0qifh1KlBSYfgB8/74CnAc3DipCoStBvtG9ejpqrZPyP4ncIhgt/txhASLG6mGBD8XV26RgHLmbUGszPTosF0trS0+QxODA9rhSU37/rv2OAf60K5AX3WgFvDKQLp3vvuk/FRkVIW9dmvq+L4ihsvFcHW1tIoL4jLP8vFL3Vhd4XF2/ZLqrjLXNrV5SOaR0dnp9RrcWHxuE/T3VP/awrXShYoYk5xkDB/jakqoR+bmzrgce7cAnFlVVVVSNgpEo96/NjExEEPl1mT4KOX+1nFAf6wXBofV0hRkPculEkp/rnZAiUF4Nn7bkkCnxrkDFgE/80vfOmXPcHk/NQXIAwwr+DnjPXa+npx7bMK3qRdEkTAJ1n+SxIYBIz2ZBBYLa06B37HV78ilLe8SgXxxo3iyIgtz3/91L9J/Fvf+jaBMOi2DmWsLNobG1PXO5b6NdeoRYwHY9sOkXvx4kMUAwTcZLCAcX2ngwucy3/wUKAgIBB5TdYCIMAH+o8RlQNnZtXlv6ZbBSwCfTycV4Bl39Yu8jUnbXZgdFQVjN5whDG7GhDo2bT+ubmpUdYIVFWWC3T74oWE9qmqVAWqvkE9EWtCvuRDO8yE2xBbWju2+jinANzrYU11tfCKjo4OmSLwuOwf7ZyNK+bZnovwxBMP5IyTTcHjMT0/I9l99duPyzjZulEVqp2bLxK8bRf6I3WIz58Ii2Shp79BZyHxZYsLUq+ZmSmZ8pidW3jI085MT4sAJ53bdSAdn7UX0/W1olG6NR+SbnIhWuVp3RTKCzxsbl/1zgA98J4HgUmKw8zczP2eAA9ElsIAL9cPL7nk/VupolBSEPI24/mBpNOcH2/z3HqLHIHvqi4C3Ql+YeAI/rny9Hb/Wu6AGJWgZvV11ivLtyxPVYhkZLV+VnzOIwI/B5knAB2MEBIY/l133SmoF7zgBoFP7n5K4HXXXS8QS94dBCTh2bBvn7nnV9/2asHb/BF87Cbo6zsidFjCCGoEPYLLLhrE8scClkzcHwKV/CfD3PiB/fuFZPdTewSm51TwkA4Bun79BkEh8I8NqEKwca16BNwcs8RjyU+EtQOsTWBNAfkSZiqgqaleohqCwOa9Z8JUh72uuLW5UTQgtkeSb1mYE0ER5FwCFv9xsFJ12N0wETwYlalIBBaCv7OjbZvPs7JCTy7i+1NOIWhd9NAvzGu/2LNX+83jD94jUTsvv0qgE6QC66pVcdm6UVM+fUDXhHzlQZW/t16l7X7x5s1KEP7pVy4XzcjgWaOSkyhPgH7CrpSKVJm0d0VD/SZPPjU5/S0PFxbSMkc1G0UKR8fE0h8dVQXW02T/UBjc2g0Z97W1NTt8/MnFGVHAnCdHoHOMSEesqKz6MR/fu26DK8J5Dk4OH/dwenHupIdu2+x3PXQeBQnnURR89HK/YhUE/XCmXbMyLnkOshrjR/WxpACc+y+Tw3hccXkFv8PLt7jppps+46tUXqOr8CdO6aUyeeYW7beTMNubtm/ffrfPZ5FJcR/I81upxb/3yR9ILnMLaumyve3E8aOCX1APZ7yIbtvWywTPwT31YR89gpr04S4dz0A1/3CJD656Z2kJ3v6xWA5FpSqs7uf8gPl5dZTgYie9u8VPHvvCYsNjx3Qqt6dHLfcbbxQDzVl4qgDMhykEFIXR4JGoPuSWxeX5oTgMhoOD1q1fJ1QIepuENQxMCbA4EHxNENBT4fwDDtbBgp4L2y5RtMgfS9cphoJi2yOCkfTxHQlBUZsKngC3+0EW+9XWVIhCUEjwJwl66mMt80ceVQH+xS9+SUg2bNB2Gh7U/lXfpt+9ujzIpbCosi0swtw/qIrAR/9Z5GzUs3lU8nnd9ToltLFXvyflFyvwoQcy1YDCSbtGi2lZZFlZmbpNaMsqH/LQegbIx0IUhunptCgU83MzMgVD/nwnV67g8eikUuMSHlpclKmKKFUlrhCX7npfRm11lSgIKAonT5x8zOOnp6alo9spiCd27xZPj+MzfZ4uz69YxYCkKAiEgSXFgJb4EYBWiPwIVOm8qYIV/PbFVDIFwY/r3g3wXZ4Ql/T4+OjnQsKkbwVe8nMuQeGEbsB/26djsV/Io2iAQIUBkXDDRWKQRF+/4wuCumyXWmrf/8GTEr7uBS8TODoyJJDFfSgACHprsZeFqcjFoB/hko4t+CAAOTFPMs/6Q0DXhFafC4sTh4YGhYpFd0wloFggaC+9dKfQUS5Zs3oegY9AZirAWWRCWlXTRhKBTBngKciJzAo4C1tC5Et9IEFhaAgnCdIvsEirwtHC9uRE0gNT4cQkt1YAVA6sDosh2V3AeQrVtbrKn3a3CsBKBT6F7jt8TB4R/LMqv6KnFe3inpb4us6LBSLw3YkUEh4cHxWIPt2zWaecTp3Q4fDnn5S1ek4hGBC6N9+0VmBXh8jrkNZb7ElyKiaRh9lZ9ayjYNGPoQLvut/lHuekXKeHzkMkCkFNVaV4BDwu388t0hTBOFem9cHjAy0KLf2Eczjo9+49ZPyz2NPxEVEQZsKajvaOrht9Xm1trcMeujs4ZKQ0trZLh3jpS18qLhh3gqPwj1RZ9LinO3TkyOc9zJpq0IbwyNxfIQWBhrZ8Ud47N6tS6NlqAfsxnq1yL4RybNti+ecIftcQwrGcwBYTCIHNJSctrS3CUBxdzgBzUwXCyew1uc6Cus43rrtNTyT15MSYlNsW9qX7uHw/BD5xSYwRAcAiv4NPqgVXFSz7nt6NkgWWEnP46XkxSJzDUF8fS4by8AgQngz3wrPKn+1ruO6ng2XOojbS9feph2L9litACWTuH0salzz5jAUXPbsLEMTsGmARILsGCB87rhLrzruFz0fNtcrPurvFoxut7lZLlHMGyJfKwdCBHeE7oXAgkC+55FJJUheO1CUfDiaaCJ4BBAVrAPiOfA97JwLfk/KxPJliwPKtra2T8vEYzYSpHN7DWvbggdRjKOxC+Mu//EuJQvBDNz6nilT/EVUot2zS9mtaJwYuZEsggn9JREDMjqkicMNVrYK56aotAt1tk0lJBI/CRf9bljgrkv5cVpYSF4VTGL7ho7H4IR0eGxcLvqaqSjTpmelx0VQqw2JZpnDIj22htCf1QqElTL3pp5QH7Oxet9s/r13bK5pWU22tKAI1Yc1CTV29uE6cA1HwDeVVoiAMT46JYsBahEcfffQzPp8VeA48efYPxSAb559LioFtkXMQtkLqHBRxwWVp2zRJ8NMwogC4xT+HPYLV1dwnzwElCHq3X1w8BPVNTW/z9I6Br/JwZHRCJBH7wQcGTgjjcZadMJhLLr1Y0iHoYSA+bTE/BAUWH9fvYjnigsYSnQ2r8dNmGMPIYFCUfTocuMM2wAfD9j03hykk7JNnlwCLAZm7bm1VwYHF7I4slHTMcU9PT0p4IJwLwAFAMEgsdurDGgarKGB5TwUByL5/FICO5hrJgkWArBlIcu1zrgCeAuozf+qg5LPj+lcIbA+KAe+PoOZEQQQ8kHamvng2aF/J1P3xXZMUgBSXGIWpJBQAd3kRWeTApH7F2oU//7M/E/pT/dIto6outcgR/IMndSppNmyvnBwfEvq6BhXcq9fukHBVoyoGOYWvIFA1rQrG8563WVK97Lrtmq9b/JD9Y8oFRTY7rphn+x3m5xdFYy5LpQ759NMzM2JRNzbUv9iHB473yYvicbGKMt+XfslUFv2R/uPy9dkl/vjezc2t+zzR5q3bH/aws6P9Wg8bGupllSVrG1ij4NakiGLgFG+Blaly4TvudktZe4DHMuuESusxyDFkfFnmV1IITIOcy6AVVueyrPM976S2TFIARPA7S77DN4xzsclAxOJ0l82Ixt3Z1SmSzTESEfTDw2O5gv6kSorB06dF4Cc18uXP3/VGH9fe3iYmT7GKAAKCfFEAsPgQ/Lj0ETjQwzgR+Aj6E4fVRQtd90a1cBF00BEPZPEfApBrgY8dU8HBeQHU5/TpU5IUy4ijeWGcrPan3rj8N27aJOlQGFBEqAcMFPjQQ8I/I/b9Y8m3htX+LOZDQcH1j0cBAT166AdSRNdWdX1vvugyCVNPLH/qweI+LH+mWhDEQN6PcE93t2TBdjnuRKBd2W/PYtJ4jUAomO2WpKM+do6d8v7hHz4pJF+/XxWxqnCHAukQ+ISJB48iQPzZUgjwDDRUqqL5khueL0VcuWuNwLFwgBKCl/KBvB9hC1EAmDJgqiAqqzgitGVl0nHqa2te4sMHDuzT7Q0mI+v5mp6ZFgrWpCDw6Y8kt2HwQNbGtLW1C+qlL32ZwMawbXVmcvZOjzgyMKADSWIz207ZfYJiUFGZko5VW1UlA9J5iv7JJ3HjRxSfs6gYGNMiVKwEVtQCSUJrRZlcoMSF2q44wR/26ZdX6IlkTeF2ubHxyRHfru7IW1EMRkbH+iQ8Oiq+9InxceGk9e5kknztPzkxKQpDXX2drAZuqq8XH+qOHVt3eXoYPQyeOUXyWqngJ5/HH3tMsnj44YcEMlfOorjnXX654C++WC2u2mp1wbJ4D4a1b99eoWO7HgyPk/QQpBz4I8TuD4WkvV30KtARigNTAW5uVOJYXIhCwKJAFBHOBSAjy4i5LOjue8QAcpcj6VSHVQBID6NF0eO8AeZyUQCueNlrJQmr9hE07gAeshKI4Gd1OoIKgcOiSjwV3/zGNyXd5eE7cCAT5eDRSVIA+M5UYjwcxISFD576fuMb/yGor33t6wIHplSxgg7BjkBH8BMPRBEgTDrCLR3r5bFtlQruM/UQWIXglS/okXy7w0FUlIdgJxxDGj5G6AP0uPRR3Coqq2VcV1dV9HpKpqA4wRLFjf6BIs1aFsZLBuZa/iieTCWR/vHHVdHkdksU166gGL761a+Rire0tk35h4OH+2730CnWeS10+NGWTZukg+Ix4MAo57Fr9Omdy+luD0ZGR77gYdZtl/M+nPVL8hTkLd+lKykEWY1X7GMhIVZsPhcSXVKbWYFPm4ilz0l869eufbWPwIU/NTUjqrdznUn69nAkbN+hvjs9HQex2IHnXL+U58kSB6ZEZv1desnOl/sgUw1EoRAQhoHHYXPiH4uN7r9PFg9HX/rSl4XUutJJD4PhpDy2+W3fpi5dLG/omQNH0Heu6pQoDgLiyFzm1rGQWe2OYISBYumMjIxKPhwpbBkp5W0K5xWwdoL3xaOBIsAZ/5zgN775sOTf83xVbCq6dEogVaMWZnq6jVcUuGNB9LFoYEH0vJy4lQS6ytWV3TO4TZJ1tKogTOv29fgypA996IMSf9llWi7t2rNG6S+95BKJL1YBoI4oAqxFeOShByXqAx/5W0iWhQuVW5aNX2nkmSoEKACUZ9ci3PoiVTS4ywG6QpDxhWsfhauqumbYpy0vWxTBydTavfeqQmld+hlBr4om/Zrtpkw1dQdB3hhvI9WpDRRq1rzcffd3pOocTMV7cH7FT/7kfxZUTU2teCT37D8oawCgKwThUxws1djautWnsVMIeAqcAvK+kOeZKgQlRaDQR8mKTxJmWSSlx9ACSW2FINYRFojZz3/D9de/06MQ+I4RiGV++vSwzp0NDT0tSVLRTR52dkp0tHf/YdGQmSMUGvfX0tggngEWD4G30CoMxOMJ2LZ1s0qAEMF2MOgsxCPwnbtFgY++Eo5oHQ77m902KJtEwhWpyhw8ioCdG8diXhtu72Oum8RY8DBSXP/MkWL54vrH5e8sE8kCRQBFwXlOBI8gt4sB2S5npxTwHIxu2CPpEdwjDSr4qW+xcOvki3NIHz95n4TLukUu5MRlB6Ym5rKD8XPNqPaf19T9uuCYWqDdECxYgJdf/jyhw2Ny7TXXSLiQAmCnBKjAcDja+Z2/8AuCOjWinpbZaVWAODoZeo5QJpwE7S6LlSoM28PJh7Ntm6QI6yFA8IMnvP/Je4R+fux+gRdvU88V516gyCLYbf3pn1XhSGjuckDRrKwU+yBCcXVTeZIFlvo3v/kNCWcEv1r4VuAzNcTUEvWwU0zggVyWNTqi/Y1tuYw3FObrrr9BLO99+w98yacdCZ5I8ikWFqsQnDp96sM+z2fgISgpAkV8lCShVkTS854kqW0Q+DQAkk9GMrfqcclOQ2OjCNoTJ07nCHwsezJpbmy42j/X19WIK9ApCI/68Mz8/LKmoVUIrOBnwFn8RZs2XOrzb2pqUJPRB9yP61NhOPd/73uCx4VrXYYSucwfB+JAwkl4WCowLlziqzrUdc9RvqTrO9Inj1aQE89uABiYe2+iBLKdEEue1e1MAaAAMNXAEbRrt2yR9F/s0Dn5Q2UHc/IlUL2g+C2LnxcUFj/xhWDz+DohGaxQhSVd3p83SSHBf0vFz0m69LwKFt6XzBBI//SP/yioi3fqe+HxuPqaawXP98Dlj8AnPfklwT17VEH6gz/4AyE5dEwFG4oA6RDsI8MHQRUFURxIT6JN9d3yuLpSp6z757Q/Eb93tl4erYeAeCCCf+zk1wX18lddIXDralUA+gbEMx7d+4QO/1fdIAZ8dFHYJosAra6ukXQsYnWWtIQ5qhmFFEXtxEmt99TkhNDdFzxsHBAlSPeHhY4HDIUijg/bOglbxQA8a106OlRxZNFsddj1URfuXnBTFqIhuNsrZerRnRugmhEZFQmT+BFHWXPuRH3YjbC4kH7MZ71v3753eZhnO2KhqYKSIuAbLuGXJOQSyC8IdFKbIPgR+DSGcFr28be0t/+Wj5gK29iO95+QyXDm8EnEvuDp2TmZAmAbzmL54k2ehm1wzoV7RNKExULWIyBxy/xZwW/XBjjLXky+6YmRtT6bY8dET4k4CQ+BPzY2KqXgwm9qas0pFcGeg8wKwLBe9KIXC5bb97BMZ8PBP7juWcxGFrjex8MBPGyHIj5Vrp8FTwYegrkwJ5+kALAm4OiICsyjYbX93et2kvVZgT2VKki2Vf+95IeCVWzmSYKf9G+Y+V15XExr9x0OFh0KDnRAvjOLF90BVBL1ghtuEOiO/BWIAoDgRxGQyCL+Bk6eFsvxv73nN2X8oAgkJUVBsJ6CJHrwVwdH0671uQKf+L557a9JCgFrEKaHVOFFIblil+jl0bVXXCRZnRiS14m+9ci8hFmLsKVKBXZVl7IJ2nNr8Bi4VfVCTzsCEdwoAuxOOXr0qNAfPHhAIB4b1tKgWNtxxzgrVjGQzN3f617/Bnnkmm3GJfVbLK8UQev43N2ecGZu7oQkOMO/ro72MZ904NTpRg9RDPyz/3HnhNsG/XwfrnZHL3ropkT+yMMVTBXoByutEfDNtuSXJOyWEF5ACNsmVvCLpHAu/tW+Tbikp6ml+UYfnpqY7PPw8JGjugrOB5b5TZoT7pxLUPKZnZ4UjoVrG8Y7O7/4LcmuvFxNhYS8reBPIItQCB575Hsv8jSHDx0W0qlQL1z7uPKxZLikpj5YCqx2pxwYEBb+Lbe8SqLcCYWQrAgiMFEEuL52ZGRE8uHgGhQBBBeWPmsasIi/Nd8s6fr1iPZoX4sKiBVV6gyI26v3Sqqd6Q8LTLL0yTpJ8K+r1LUTr6iWGaYoPavdtr9fPQe4dPFskJ+F93z3XkFxAiKLv3Bt045su7Tpiw1PTk2LJfY3f/2XUlFOXhwfUsvz8IS6thGo5XPaTghiylk3pXPfq/XSQ9BRrx58F62tVMHc0qUKXUwQHo6le+QJheDO/gEJW4XjootVkbh44wslHpf7Q/vUoqeeIdsIBQDPA/hX/8ZvyyPjBkGPB4BxgsBl6mpgQL/jqZMnJT2XVe15Wj0rjD/GJVNrVjFAIaA+lIdgB+K54NZJd/uhJEGRpr54JkcnJrQiZFwAWoFvya0CQLzzXMiHXLdurfAn1i5NTk7d7ml6erpVc1l666IoLOTjYEkRyGoMHq2wA38hQtsWeQU/DeMO7vmKf27vaBeB7Q6s2e3Dhw8f1Tl9H3C/1uYm4WxDI6Pik+YgEM4AtwrAYjq93qdLp2dl6sAJch+Mf8wl1tWL4uxc+E3imnNHfMpAcfvwJewEnTBct91PJ19DDm61tkhMdyCLxMcZh4dv/sdXd/rHU6dOCQd0LjmJgZEMnc7JzmviIaUCVv2/5MUyXqMXv+SlEvH773+/QC7Beec7VHC1hGuAczI5gwCr//G8sLiPg4S+//SQ5Pq1VdK8z5rAL/QqeAaYOkAhsIK/oXatZMWiwZe0v1nCKGpsUzweDibi2l+mTFhFztQGuwlYI/HFL31J8vvlX/5lgRdffLFALFWgIIv4Q3GwpINDw9LvfveP/kXG2+yAdNeYrLzqgDy3DaqBefporpyxgj5OWOBhsmNdDsVXJ9Qin55Sj1eNHngY7dx5mdDRnixqPTHZm5PeBlAAbu7KfZ/Ry7X/Mw5w9dOedqqKOwNOndL3RwFglwr7/dkmiyKFp87Wq06387vLnKokinFsFQF3DbLE/9iP3yaQ3SCnwzkc1NedUCgvuP/g4a/bslYSThL4SXmgCDBF0NigH2xudu7bPs2qzlWvNGlVEzQHqDmakiKQ1VD51eQsggvgsZDgz2mCI4eOfMIjrOB3B/fI6lgn8HPoEfwgEfyELeSAkMmxSVEAsHiZU8QjMBtunXPXt7b4PJrD9kGXv6xuK9MDBv2RwiI5JsOc4mhYBT88GOZIg8U4GAS73cUEI5xPz0lVcf1jMdr6v/vdvySo3rUqsKjv29/xDsG/7R3vFvilr94l8Ffe9bMC3/CGNwo80z8s1Mpw1C2rrNeE1e1bLlJ+sHpwWor4wqiGH5r94Q6BY3Mvk/ocixTGCkHt5wWPwL+p+U0SrgiLxjiHgYN5UBQ5EY5V3pIo668mzElzIiL9ihMFj4fzFFAAkgQ5WRaKhw7Y1toi421rjyq2j+zeJ1FYzr0VqqiF7httrZcpZ5IvgVj67fX6XU9PqIUOIRY/4bvLVGENa0Cd4OiSKFbBo6B+/2in4IcnD5F0WVjRulni2zdr/dPj90u47/6nNF1QhG0meKY4QIspK/DQo8ARZpsqgptzAPj+7A6AHg8GYQtJf/iwvu9FW3TKoyZ4+GaDIeIOIBJ+U11ZKQ30TKcCbD0II/AJA48eO/aAf85SBG70YaZgs6YGPNr/UgpyT1J1OOmHDopCGmguOPDD5X7PjeaWNmIbX2d3l0gqd+ua+MicK0pNlvAuCHws/zN9xfRi+RGf1h1kI5KUVfEIOk7AQ8CyZoB9v3gKKB8XIwwfSxAXIJbBdLg2F9c/FhAuxk2bAqMLc+UvfOGLpIh169cL5IAhFhPByDiA561veZ3QffTvPibwj//kAwK//GW1QH/z135Nwhdf9jyBhf6w/GmXQuEbOxskyxuVv0cTYa3GZ3UXZvTouLqYR07IFOWS4s/VVMHlVaqQ3Nb0Einz0uqbhDG5I/yFUbELIz6IKZxhTzuzOwEXMtvHWAVOmN0PWHRMkXBk8WOPix4bvezmV0g9mHqyDWEFP98dfKHwgw8+JFnWnHhQYG1w4dcFFz7lIeCHB7R9LJ6whdP1oj9HbSGib75XntaPHhQ42KYdANf86KL232PHNcVsOCmQtQEhm8i6/onfUtccSIICE0J1kw/L08mTatGzq4QDgRgffB/CjFfuBGC8sjuA+gDtIr/ZWbX4mTrD4mdKzqajnwwNiYEfcdQ3R0+f6D8uScA7/rTRI9yaG30xMkyAWPzFTk2y1ilJERgaHBRFoKam+8d8ka7+v+Wh49OiOedZLOij/Q/PLp4AxV6g/xeyAoAGyKenY6Ax0jYCe3vX/o4nRNCMDo+Iy5/EQAQ/igB4C3H914VLbghDV1tX9z3//MQPnhAFYHW4X76nW13y0M1EavnAKBAEbEty14tCKhA8c5EwDhhEFE0oXdi+19KiHo2pcE0s99q/4Zd06q0mnE1PIVikMDIEDOGXB8Fy37263e3AfrWUTt77Hcnit96k8NZ3/7qEf/pn3iaQ2++sgOd7UH4SJJ2NRwF6k0x4RNGbOvSzz/YofGhc+cQn98rMiU1edBgBv6tBGfP6chVou2osH9Lwgoj/5Ow5cQ8Gj+C3J8Nh2bGqH8ufnFmlviVYfKwhQEGADoiAJwy0+KTwXXfeKUlYDNgcDvDpHc119ZOvFfzggdbyr9m0RqKmdWofshieapA1r9GG9WnB0Z8nd6si0l+ligKCHgFPBuweILyup1Yfx/SDTc8dknDLwL5Askng/nDNNApAiIzseAGPwOd7TdfIDGKEJ6CQIsC4RrBz4BTjPlWhbI5zAZgqYlss/QkFgO+Z5ZEUvjQ5He33debAMeoPtIKfMPEWIvBRAGy8DbuTGqXjuLUA23xcd1f3rwaan7e0IawfPhOJHCgw4jIJzqcn5XLn0xudo3dpam660meNxegEynh2Ue4s7FU+3NLeOhXw4jNPUgiSBD95MhA2btosnOTA/n1ienPyHCfKcZALA5vtcPMV2s8ZsOQLhA4GgSBkrtCdBi6kzC02NlZK+G0/+7MCsUSdJi7h6jDHWB22OWHB4KoWIvdXG7bnvTvMNf/v33+/RI3XjkIi8I5wchyr1H/+7W8X/DXXKANHoKMAECYTGwZfCGK5IgCvalZGec2V8nmj+4bVQ3BoQYcOHgM7lYDAv61J9crLGxhqCHzFh9t3l9xKh+Vv60t7smsCRs2cPwIfxYDvyrkGfKfFo2JARYNPf1eKaLnsLbaoZcO0E4KhULh/4KS8+If+/hF58ZrWqyT/nYuPCGxHsT11OKdc6wEgDJF1+dcHwTscqUa3WClyIVqoUMWgqlH5/bHQ3TaEbXdR2Da48cRdkvUDTeoCHxpXSHmcVMiJg+vGTkvU2rKjAitnVPOw9dq7V+RUxB0T5eXaHzK7XnS8xicGlmv/oB+yfRCBTX1YU8DUDoKcXQd4ClH0SIehwJQBU0bwETyL5IciQnqg4yObwrN4RMEDi7X4oUfww//AW4jBVFFWdtzHufEuH7qxufFSS5sQ1gbOrAlIIDu/0XCl8/sti3u7vJa/S5rTRu4oz7wdHcHvFsGJ4KfIQp4A6CxkIHSvWdfn4+677x5RAJiDx2JnAOPaw6UJQ0DTR1DachAMWAgIDPYdp+cqJcnLb75Z4OounTPFFUj5KBQ1YQqBOWUOlMFVjWBiDvPn3/4Oyfcv/uIvbNUkzPbDD/3JByV8zbXXCnwbnoGweClv4mWQCCxLgkVGfBwOl+Fc06IW/FVhscQbWqtzsiBd2NXo4lTgp+cVohiRCM8IYeLB4/qHEXPkLosd2TaGYsj3YA6YKSH6x8R3viBFpe74msB6Cv7m78rTtv/1h2CWhQh+iJLC7mQ7saz+959+BoYrSdbVa7u1lW+QcFu4dKgmdYwsBWLhtwcxc3pChyN4BK0NR+HFhrrV01JeHfT1U42S77pgsVPYVj12w7n4AiZ4JPpm1aLvD56KmelWIWioVMt/7YwK/rI5neuf3q/h2HOhekjEHDxHWW8MJ02Wh2ua+d7Uh9X3TOWl0+rJw0JvbmHKQVPg2UNhYKoPwU9+s7PqMUQBsAoF4xjFAg8eniXKp541FQtr/fNilLoPXDbE4i+kCCDw4XvZeeR7Zts0ce72S+lnrr3aAy6Hb0OXBXP4dBb+gnos1EgXQmPkMKY8LyyKgRNgouq7AzHoYDmkw6eHhCN0dLQJ3ioCOcRFBBgQ3d2rxfe8desO4YxHjxySOQBW21MeghUGQxEcCYqA5yhdqxCA57pYDoZhG9mLXnSTZInlj4VAuYTZvw9Dw3XP2edYJLgwL7tUFfZbb32V5P/1r6lgov4VlSpwCTN3vC8cNPNzQYHYuXMnJCuCsYAPc+qEbSa8X1I8c+Xsu0dBgr6qUiUL7c4lPBy8RHkIfMIIfjwaeHTY/nj61Ckh5ehkXNqkD46cKHr4PwSF4I/jw8OJzaJfRjdc8SIblRNGwbECP4fIBegX/+39nxSTe+/+ASHBgu5PozipQhmpnI56g+CumXjUZilhBD2RSWEVw1BF0dM/UAs7inSOe3g8V14FOR0dmVOW2Bfm0Nl1wBTFgXKdgpsd0PqX6S25EfVF8JNPFHZltNWp4H388cekUixORWBTU6Z+gr4ZMRXACYKDp44I6eruiwXuDeNgy0UXSRgL3h2gI+HKKq0n441xyfkXHHwlxO6PqUQMAcYza4GgA6IglKXKxTXn1gTkNCyCH0WAdBYi+OF7S8JTUy0+TVtrq3zA0akp8eGgCLjt09LP3Dbs3SFv4dvuOfSsgM0A+D4uuUzMBfRUUgCK/Ngs9mvvqNvukzhGLj5yx5jFtLAeAOeaksnz+cVF6ahFFhOTMQBAXHzJrj3+ef++vcKB6ip1YMPwOVmPgUu6Q4cOyiMeAxQCe0Y49EAETmew+BHgWPIsYoKxIKhgMOSDRSvaEUgHsTC4d/7Nb/lJid2wYaPAj330b7Oolz7CkH7v935PIl/zmlcLfNNPvHkpcREYBLW9FGlJ0qAogOf93GpoQQ0N6yIwzhvApTuTUn7E94Hxw7BRDMiXduQ7YNGPjqklezLsD89Y/rpokUWc5NO7R9dYpPbtA5UX7njta/Pik5BJigCK0p988AOS9LHHTiZlofjgCVieqPjYiS5VZNob1GWweHh5Fsc2w+9GdVJIv961Fa3WpS/xuQJxDYbvksepMGWw5+QPJMz5A7HgDwmY2niqTvtH6uBhiWGbKot7abeQLKKfjM9OCcqdKCqwukbkIGR+N5I80x/WrVsnYTxQjEvGLf2SczMYv4xHPAcoDlVh1wnbRfEQxBUID6my5T0Blt6GkwQ/dM6TKIJ/cGhIGqCzrU0Ed1llhXwpDI3JMb2WmHQluHwLLD86lk/7XI0VTbGIytM2At3BGP0+jTuZSpK6ASOSyjHox7PzQvA3NjUK5ys0BZC0FsAOiNa2VvFAXHHlVcLJH3zge8rpYhNPBQOKAHVC8BNGIUhaQwAdEIaAQMIDMDurijXbBnENwnDSwZTh9rNUmNPkpD4EIOUgeL92xx2gBA4PqYXLfu2xaVUlVgW8u69c6P7PQyronvj8ZyT8no/9vUD2QUtgmT8E2jIkeaMQzFjkTlEUOhg67ZAKrl7LcDkLfr5CPQQoFLQ3FtZYEPzDQ4OSP9ccowiNjk5ouXMzOfUcHFUBHDY95MTlC1DvMj5sUFzwcORLk4378J9+SIJfuv3L2ej4eXpOPWSc5T8/q/UeDAK1LmwD7MF+i1Mu/4Dgn6tWj8LQYW3PI4trJOEWle9R+fTTEtZWjOIDhB7QbuZO8NT+FJmDhigdgd5n7Eor+KHnwCEO6AF/cL8qDhs3qweMtRmswXHbeYV0JJzoSDoup4rC9+kIl4f1h4N7oKMfjY+rYojgd9v4hGRmOrefoIgAEfwsIm4PJ0OSnnIstNsDsfwLeQIweGK+Fyx+BD/lEHaGlaA2rV2zkzgP3bZl6zqCj5svlp3qwn2mcS7cFijyzfv6DosLvqOzU3reqo62Xp/UHUkrgtkNMIlH8LMo0JEs6wFgMYutxpIBMTMjHfjiS3dJB9+9+0mZBBwZGRLvZTrs1yUfqwiARyHAcsRVy5w8UwUcOIKrkPRTU8ogmUPEVYmly5G7CC7SYdGwJgCLuzxY1B//+MeE9Pavf5EkAledzm0+cbu4GBWzOaQSeODBBwX+1i03CPztT/ybwN5NWwSeqaC3rnkUIQ5uYaoEAQpEkKd1ijJCIUJRmgsuZzZTMFdLPC5/1kIg+DmfAQ8QR8TOhPMh5GXdX128qC7XciQe2Bj4KJZi7PEJ34dwkiLwsb/9G8nqH//l/yPLvJAT92rD7YgPT+v35Wz/S9MarquX7u7OfNW5b+vqR+Cn9AqNeLM3gj9v4Q7ZW9EnUR1hzn+yWy3m9/yP9wh+x/OvyUk6PqyqwvTAAcGfnNR61R4+LuFDh1SAz/5A7YDa4BFgCqEsbDcM+ke0pV0Vk6YDqgAcCxY9/WfLFtXrUVwZfwh0KscUEx699Lywn8gtFhaS1WFRJYtEmZrCo8RuAvI73f+UPLJWBI9UeapS+J0rRwynvXtUgSAdkPGeXpwWw4jtgYUEP+ljwR/4HIJ+2igCuPy5VdDdVbCKPDz87Oc/j+qIbNO5kGyi3Gc7FaCaRS7NeRuikc7bFzxbL/bYY4/JCHGLd2QguKMxpaM0N+llP5QzOTevJgaIArBYD4DN5sUvfYVwkK/d/oUX+biJqckcEvb15yBdAAUAiIWAIsCtfFiWl156mWThBqJAGAr5ItCACDwsDsJcKlJfr6wQhvbE03slq7//5EfJUqAV/DmRRQRw5f7q624T6vd8+MMCr7zxpcumtoLeEiP4x8YnJGoqtAuWPYIfyPtbxQMBXxsuW3GLmHKKQvCz2I/92XbRn3X5kwkHNxFOgtXvfKtEjR1VgbRqvfDveFdCKizO4yAYpmxQBD77b5+U9H/1sT/PKaKzRQXdiWF1fROZwecqdigG6seJov7jypquDMnbu9Wl39YU+P2A5rg4qOWQPxY/YSCr9DvDVMjoas3/kl/+fSHpXaeCl/fDJc65Celu9ST0hgyvvBE5Qwm5kP4wEk70mzim/XxyRD0yE5WtkmDguLoe3HZfCX/2s58R+FP/+acFtrWJnh9/D9YCIOjn57U9uDyLA8I4F0QycX8oGISBvOfcrNZnUzjngwOn2I3S1ak+pL17ctkbCgr5RfOTa/1zdV3jitYCYPDE+YQHFAHwCP41PV094DxEsXnooYeOZOOLeJapBEd3QQl+2qWkANASBSAd61Wv+nGhHDx1UhQBNwCkA7W1NO3yEc61LZPzzgMgUwYFso3wANjLgQqlY9/ty2+57S5P+6l/+UdRBIbngosvWILsCkAhwGVN/igC4BH83AnAiXpYHBnPsDJAXNxl6VxFG4sAQUY5rWH1Movf3v+/3kdVBBYr+Ms2twt9zfEgiHHdhtxiV24I/+Gv/Io8veU3Fb76J34+xChAUOcgXQCBD5772lkVDaNH4APJD4WCy4vAowAtLqqFa/NBsaDduaSFI2BZVU69gAj+zpEhUAIPl8kUaoyr2XSFPLcfUMsxUrkfv295sKmZmkERYFuoO3BF0v/Ll/5OIGfnxwWEh+YcNu2ukT02IjEZRSBXQSD94doqeZwOiyevCJf5TIYDfaCL5vX7P73QFVCaP/Ec0MMq/dHVOlWWfvXvCMn8ghqAg6dPaJL0rMDGFu1fvDdTVix+RAGiHPAnjqn8GXj8OxI1PqYCv6xtq4SrOhQeO3BAwizaxaODB+cfPvl/JP6//JwqAl0XbZcw2/W4ThsFnsWCjz35qNDx1xOO8iYM5GAubs/E9Q+eXQX0Uzx3LOLFo0B+Fq54KmBhQTyZCHxr+XMXwOrOjmZfFgLflvvFL34xdGgbUwrna4ELUQFA0xMBnq9RlsOFKdFYIx8dHhQOMh/mXp2Gvs2nX93bI9B15K/4cKFdAZkjgpUhohFb15jPK/uHInDDDTeK7+873/n2Zh9vz4hHESBtkkLA7gIECRYC25Vw+cMQU0HwL4SGSaW0S8FA2JY2FOauuWTm059W1/y+Q7ulSsUKfurPWoCaTSphWGQ49f3HIcmBKAQff98fCv7It9Qi+8WPvD+Hzgp89t2z/Q4PSCyww1oHBDsQi98KfiymcMdJXDYeGCAW3HBYVDg6qoINho/lb13/HNiEKzre1hZKWreoUwHVL7tBMGOPfy7EvEYgCk5jOCuXNRu8z6f+6a+E7lOf+VeBzT3Cj6OmSrXQR+f2C55w2xpVDI/uUcFqFQKnEuTkg4IgyKy/Q5WqKPWfVgHO0buQZHYVKGZ1Sue4mfNnM2/Fmz4gBOPhEimmtMrKlC0w915d2yB09UEBob9THvBk/1F5tAKf+KbN18vjXKjAsaOqIDAFxxQO43UuLPo7NqBy7O/+/09J+nc3q0Ky7dJdEubo774+LZ+1AhdtFbbjDRGhYw1PZVhjwrkf5WGccg4B24Gx/CWx+0Ox4fujKDCu7TZCth2fPHVaXAbu/BTRrApOBYTLzRzfk6K7uvTIzt7enhd6BIv8kgQ/48aVoxlILvI3n3ksPdkWuBAVANsGRYXRLN/73vcKPZoxDB+LcHpar2VrbGoSjtLUUH+LT+Bc4H0eHutTX6DdHYAnwNP4XyHBbxfXbN66/WGfbmhItyM++shDIhnVcR9F3B7GOQIvfsmLPXm0apW69rg0Z/eT6orkOmAhyvrjvfEEEIahpFI63mBAzCViMfT1STNEd911l+TK3G9tnQqIrKLkEcFdW6cCgPjasAgwMpcJNV1+tZDASJMUgju++WmhG/yZgwJ/8U8+IrCpqVGgFfxcxgKjwfMBA10IigBrIZhjpX8g+CVz98eaCKYCmHt3R0wLCYoGLn+2+VnLH4uRNQKnTqpCRTkWpm9+uaA4AKjSENAPuAwGy4/3eOCRg5IC1/6JYXVhd7bkWt4jkXTHaHROFQSriGzYsF7yqduo8Lt3fzGnJigWKAT2vdQh7w7pCGfw5yTOE1j3n35WsE+FuftVZlEb/ZXvgcJXExRcBOFw8BQce/hOyQ8LnyIbGtUl37jlKkGNhMWbp0+pJ2A4KB58TxS5+XBt9cmhIbIS+MRT+j3/7K//WsLv/e//XeCqNeJpj3p7tSUaG1VhySzOXRA6+qME3B/fEXwqHODVGhY3o7gzrklnIf3T4qMgfhuacscrdEmKQGdLS5un6ent2eKhUxx6PUTgM+74Dj4u+5eem1fGkkGWBH+mLRKfKhJjShE5LYBmOTu/ICO0pqq81RPYgbIQLs0ZGTotHgZWzTqXnYzU3vW90rHdQJSRfezIsX6fD/ta/XO+X5JCYBWBK6+57h6fvrW19fkexh6BsDp9U5jj2759h4+OLQVchVzXyzYhFrnBGPEIACWTrD8sKA4CcvsmJBaG8bGPflTCCC6m8uYQ6Fl5+UcEf5IiUFlVa1JoEHxlUAiwqBqO5vIJFg2+/02ip0W/8OGPSQbdazcIRPBzSQuME8Efh8O+azsFgOAHz6pq1kzQrrNBALC4Epc/28Ws5a9vmfH0jI/rnPqGiTmNqoJCIS5/sJxYRxjId0VQdVZpRngCUDTWdavgRmCNTWsOjTWonBre84QqCORvpwomFw8RlReiCBA5M1Qvj7tP/0BRAVbViPyIOpqD4Gm8UuJ3/YR+18EwRcT2V74r51/gkVlYlGHr5sz1hRjfI/selfxO9KmCrIVn/uvWXS6B1rCocHhYFSIUOCxmPEeZlPrEeRergkI7MqX1gG7vIZ2i+MCH/lRQ//O9vyuwPmwPbNSZyNjzRzogi27pt+zvty5/pjxIZ+HAwICgMHhsfF29fh93EqE+WIIQrq6oEE/lju1bxbVRW18nfBFyBD+eB/BJ0LXrgaS4BLxqSAmRFwq6pABwVFvm1ii+/Xx4qAbh4ez0zGEPa6t1FU86Si36cCpKy4hFIHic/7nb+oASPzmu2bnFX9t9BFMFzlwXX97xgRMy0kdGx0RSJQl+yXSZPzwCnCTINb9uNa/MtWHxwODICou9OtwuWF+n45hrQTkrnvvmmRPEcuDscPJHoB0Jt4wdP64CuKGhiSIFVl92hUAEWbR/T048ikAOsogAlxhtXKsCaxyPQcgfxeJAvwrQ333bT0uub/+DDwjcHM7IR4AjsPnOsUUVVstzPoKtGu3Bka54DFgrwaJJBAW7MLB8rOWP6x/LEU9H7zoVgOxLpx5l198kj+l9/wYqB2K5IhBQAFiDwOLNucrjIZ32CwRWdavOxfft12irCFAYUwP2llYr6G++9hUkEfh0/26BR4OiZAUk2wpPjewVuptfropnd1i89o3gceLobDw0Pe1rhD6dlmEsz/5v6LQqLl+//WuCa6zT/tpbF5PIQ+eumwVW12gECjO3b/J9raucXNj2h8I8H8ZFZdUcJALxaB08pHLuz/78I4L/r7/+GwLx1HDAFB4sMkGgMlWHIsraHCxrBC79Gv7AXQZPPvkkWQrEk9UeLgfrCueGtLZo+4+MjQndut41Mke0dq26Lpyiqa6SkFtcbnAt2jUWOYXmCbjr0MWQyhNVQi3TAiUFYJnGCVEoAmIKOdegqPZs16qI1GTAJY5lh4Cw2WNZAJ3LVRQDd+2maMDtrc0COzo6nufTctnF+PjkUz48ODJCfXww/llPABGsEbj1ttfe6XHNjY3XeOgOoFEfog+4X2affrkiwj8D3M3JCYZtQidUT4mOH1eBwFn0R4PCQyYsTjp48BAogXgAmFMksqVV9JNo/jJluCgEnAPAoj/okyCC38bHikdQOKJgOVuF46Mf/RtJ+l9/4z0CmSPlu8IgYVxJc/0oDsypUx88CqzaxlOEpwTLHwWAdEDaD9f4Rk6uiXKnUuZf93ZJUtf3bZLmhUfmGgS/IRz8wuVB1IfV7FsmW4Xu6SqdYz41ogpuczh7t1fYfBTNDCldNJz73Y8c0qmBtjWX5dSDKYEcpAu0dqiA3qIOt+jgwX8OJFpf6Lm05w2v+xVBveLqjQLvvu97Ap0iLZC1FBs3aUURvAMDufLjiUe/JfSzg2rJnw4w6tVhc/l1L5H49IIMX7f/XF33TEHzXfEAoThKIvfHuGItDtv+iK8NnkTWdKAA4Nna8/QeIf2bsP3yne94p4QR6PRP8uPAKTw54Fn8iwIKnnMJjh/Xdvn4x/9OolBY2GaM4N+wQdubbYktbfrhdjY3vsEn5H0ZD0x5UR4KCGE8FsUqAo8+/ujTpC3B4lugpAAU31ZC6U6iOugfujo7XiiI8IdCYC1ANGg7IAkDZ2eHJScEQWVlVblH1NTWbPNwdVeHwI7OVcKRpicnZGRaxSBJEZicmBSN2ykAPrv4VkMsBkFm/bFKHxRhBBmMEwaAQsAcMukG+pWBuHu8QQlEcFgFACJcoghsYBTsBgQg9MAkwU88+RJG4YiuUMUDgQfD/fjHPy6k73rXuwTCSFEEuJWP70i+MDosLfbRg+e8BDwkbNPCtYrgh0GTL5DdGihEvVr9iINqal/5RiFF8Ce5/MnvwRrRO6MXBFcy35f44/vVAifcFFbln6hUT8DI1F6J6gxyH4/Aukg9L0wVcIIDCmHlXLekw7OAIoDFvzUU2B4UATwDd9z7VYlhSuDWl3ZJ+FUv3Clw8PRJgfsPqEsCxRUPAGfkj09MCF1bsGAf+WbuWgSJdH/tQfBv3in6czQ4pB6jzHdUxQuLnzUvfFfysRCB3RQcYghY6Fgj0DEnnvJoeFQtavqpOw9ESD8YrtV+x9veJuG21c6nmPXjoKqDB9WDgCsfj9+GjaoQOcNDUnEJ1yc+8QkJ0x86OtZJ+PnP1ykPpgy7w24D8nP9XPgXgpz2kMTuzwp8VSeJXTk8PjCgmlrhpOlAIvVzzwuFk5y/FCUFIPNt6RjLtsnp0ye+r0kuyqTMekIRoF8hKKwiYMNkMTev1ZibU8bEorCxCmU4TjFo9rROMRDYuapNFIO2jnbxYc5OT4kmPD0xddzTnRgOJ5lEkXBEZ7GKb856ADxt3l+5TnFECzrFAQ2CEJcx+/xZ3MQZ9V977DFJUlmZO8TXrAqKiMHPzfEZtCQrsCkfiCJgBX+hdKS3EIVEWz+K9oSpAjwCv/DOX5QkfFer8GHxWZc/5WAJYnHBGNnexSpo6C1kG97cbm3XK43gb7r15yRJ3aEvCywk+Dk5b2aPWrDlV6sgoFwYf/2cxrc1bZCoU2HbXd3EjIQnx7cIPBH2u3f2jEuYP9Z60L6c8Bi1SjeNFib0RQYOaoprtqpigOt9flIFH+Gbtl4rhFVhn/zFW1TRmBhXOfDIo9o+lI8HoDm4ppnCWr1ay3nkga8LKYL+dN+RnPzXb7tcwnhmrKDHxU55QFbFA8Fz4Ba7c+zagLlZVSgYTyiEbP/DUCA/Ftf+4Qc+KCjGBQBmw3wAAEAASURBVPHs6ulcpYrSxTsvlqhLLrlUIN/lX//1XyR83733Ctx5ySUCb365TnUQXqLQzqtjkkt5JFHWH+cJgIo9jmwrIqIAxONmFYiv3n779wok1QoWILrQopcVdhdaYxTzvnS0G65/gZKXoUiG1NzvWiAzpgqsIlAezqNYiEK+Ib9wQFYEo2CR3mRYpOXO6BZfpJuzF4WgpblJ4Kru1aLhOktETPF0ei7YaAUqSLQR/Enb5CDnYBsUF3YfVKQqhaS7u1cgDAk8YasokC/QKgiFBL/Nz5Znw5QD5ICaBx+9X1B33rVD4A033CDQWv6kYy0EuyPmZlVQYjEiQJIsRBg++WEJHtiv9dgQjqqt3fxyIWl/oQrgYgU/+X6xRRWuoEeAjiEu490DqgAQUdWlc7yXlx0S1PSMKnijozpVNDqnCgBTN259t9DtCdvbWJPx4mu1HS/e9UKJX7Naa7IQTk6cD+OBRWtlYa1FdKXWZDEcMT0f5vBPHjkoERysg6u6qalZ8IwbPAIIfhQLK/ipV0bwa3uRT9LcvtbOTa1VaLtw9C5TECy65fIdtuuRDoiCyJoMzgsYDAcgsTYExYB4Fms2NqprwQr+HTtUAeAAoeNHj0qRt976YwJ/8V3/RWDssQoCHo/UxNS0GBxlgT/Ax5xHUfgQHkPeYyEYElYRIP6Zws9//vN0UNWc7CKT5ALkPZKjz++YkgKw9PuiKZ5Z2wSFQEaByxsFdyGsEmegWMFh8amQweKiUTBCfcOus1ghQKFgMR6v5fKVDJyhLRy4MghiBHk8UMMAdbsYcgYwdCwqot5YPPOcA5DWZkOgsZ2QeliI4LX4pLAV/El04K3gBw+05RNGEWEKAPrmlg3y+O1v3Slw9erVAjeFuWQsf65vraqukXhWXSdZ/tzGhsUnifL8cQQwLv9db36dUGGxNics7suTlaA4QtddLyXh6ouW1wvHJkeFbrBT34tz0xCwDerQiZqa6oVuclEF5cHokISbKtXyvPXWH5fwVVeqBK8Jaw5Ygzc8opb+AgInCPyTJ05JOlz1tHdF8JS1r+6V+JbVumZgdbd+H0G6Pxb9NYejdw/tfVCiEPy8H+3ZvVEtY1ztCGL6N9+T/C3EQ8YuAL4vgp/Fs6Rj3OI5WoIP+/i55ps1GhwMxS4R0q1fv0EeL7tM11ps36GK6/pwWRAnHOKid+eXiCBk/LtFyBKen50RfsC4J3/HVpRPBAUMPLC8Ij/fIj4JFjvnT3rWErj6TIEzEH5u0Be265/GODMhR+rnNkTzk468zKvgkxbNMj4P4Pd+X9K7Syjzp8czECx4BDSu4yCX4325lG/xpAMPHXjC5ItlCQNKnKMOjDV2xTGgUzpwEfzkT36EgSgAHCDDrWTHwjWo0BWCSwSwmQogPaveCSdBFIZCioBNTzpWmbPdrbIqCL6Q4PZ//3d5evOb3ywQwcQqf2WPbh9zUJCSLH88OrYehLnkp/+YurSv+PHXS1RVm1p2Zyr4B7vWaRHjqgAwBRBdTcm5sP4HOinSsqjpRoPAPzbaLoQ9TacFNgRNoK1KXebXPe/VGt+jAjkVDtxJp9UjMjqvBptdhU/pCHpOrBs8reWggG7aoPXBE1AZ+u8118oa2ujAPl17wna/owcfIWuBWPxdW9Ui7l6zWfAjI6rwYOljwaNI5GSSFWDRKwoCnoOecDY/Fj9JkgQ/8UA8BFzKwy2dl122S0g4Irhr9WrhS1yPy0mO8IXpWVW1ZgYHhW/NZo6gDgI9ZmfxA3XwsFgBDf/AwDhblr91/c/NzvVl16/0vLIWuJAVgEItheaYt43c9r5hn0FFba2YTpyUx/auMhTMeBihES8sWy6CHYGP5g3eJsZiYP89axBgQNDjyiPMQKoIJ4axeIfyoIPRIujBk64+nGXP4sLeNcECCxYy2wZPhP3DKAachNZ/fECyjC3vIPgR9HYuH5emdf0z52nx9o4E6m8h5VMuaxQsnQ1ze+Gbf/InJYp99Ah2jgK2c/72+9h8CQ/0qwBb3aOWXG+D6q29x++ARCBz+cz5E84hcoGJjTsF9R8nvmijJMzJeFiYY8MqcDlKd7hN9eHZfnWpz7ZtknTHVF5GHTVaXyxQChkMq+gRZAg+4i1EYEF34vATQtLWo2tv2sI289k5xpMagNU1tULXUt8msKLihMB9+/YJrJsZEcjaAQT/qq4Ngkfwc/0tHhqJdH98V8J8b8IoCLjimeNnKoBbM2kHFomiODEX7xbzSpaswWCbH+OTfkU/cnJdOoa7o0Iglvz8PO0TariYu5YnKtMpiqA3OYNE6eBnvNdKIR5D6wmAr600P/iVTee+xwGP45yWrHj4dxZKHk2D2OgLK5xXuF1YTXBmb8t5AG7xW47vlIFTFubwGbC2FCx2BoSlQ+AnxYOHQVZV6adkoODaY9EMi9VQUMqDZ8KOEvKlPgh+XJhsc4LxMBVRGeY63SJFeVXWAmzdulXCOy9WC4v87e4DLrth7ry2rl4YGZYDlhQu2bawn5/Fcw+GWwDZh7whbEtiLr2/Xxebfec735H67N2zVyCCXwLur7ZOBQiegCQPAvGsxv/KV26XLN7whv8kkHaem5+TMAIto1Bpy9OuQuT+qC9HMrMNcmc4sa3t8KSQzq1TlzrpgAh+qwi0X/tWIfnE7r+DNC9EESSyLxxd+9DqOkHNj6nFTnzV4H55nE49LXA23EHw5e+rwL722msEz5QJAo5ysIgRiOQLRPDXt60VFKv3iad9OcCHqYPa4InYvGmjkLq7PATWhd2DbC9s69B8WRSIix/BbwU+5QKx+FlcCH7Lli3yyLXbHJCDwsylQwjymbCIgbUOTmGX/j82MSmWw8iYemCYSqR/UZ7jO5gaAvFAIeChs+HyMuQhBkpMKQ+ZbBUfj98E1z+p7fgGf7Zhaf//M2vRkgKQaT9GgqrEGTwyUlRyNM2Z+Xk1KaJIfXAZ+rxP8fBkcV/wEBRSBMiMgUcYyx/BjyJAfKwAJCxKzDAQfb2FTAUlCxQAFAgUEsql3izKgp5tbUDqTXrC5JMJKwOanVOBuTA+powsMBoERnNrmzDGdHhRd7Kb0F1x5dWCQTFhyoKDdfAcsHiPdho8PSiPrKrGk8CUBHQWogBA//0g8LZseVhIt4Uz2a3ln7RojNX/KACHw8FJbKfrnVUBUL8ud27b1oswisC6K24T1F8kCP6ekzo33Ltduz/7tcmnf0A9AJ2Nuho8atSYI3OH5GH6wTsFMmiU2m2bU7Lo299Sy/z5l18hGFzhLMrDNc/3DcmiY2HbaE+PCmgWuRHP1EAmrE86seBXgKmi5A7okohN65oEIvhx9ZMPUzjcZcGUT21NrkJbU9cgDVXvjgKVDMPaGSe4pV86D4D0R5evhBfmZ4Uu3BkWDYWpBcbf4oKOPzeeJJ2+hfzbsBZXHlqaKcaQIEngY9ln5WsecwU/BowhWnLiKfFMIRIuBOETpINP0X8IW8UDPkT+GDp59v/DryEtwWVaoKQALNM4JoqOJRxhfGTwSR/ftUpXLRtat0ZKxy8Cjo6PnC324CCbL2EEKi5/Ww50MaMJBZIOAY7Lz04RMBAZaFVcLhIuR2FA4llIKo/3Jp73Bm8tLOoFPXS8n8NLw/Ie4DNTIJpyfEIFAJ4ILDB3RLIQ/NRPXySM3OUj+bnrnQWPBeYUAsGz7W5yRi3fiXApDwoDR8pych/vQ3m0HwpPqkIFudZy6T+r18dGVL+8omyDELW1rWyotm29TtJ978AhgUl/7EPf2qMuZ+iwTB948AFB4aJGQdh8kbriZ9evl/gjh7QcFAKuY45G90j898ORzZRH+zRU6KJBBDyXH11y6aWSrimccZ+qUEGMYsd3z+D1+zE1hUIxv6iLDrZsu1i+Z3oxNnklfwQ23931PxXcYWA4V7pkzDianhyX8LiukXOXyOpJQG4cSP70X9dvJSyFZP0Rn4XKeaRfg+Q9LZ74ClYLB1c+eAS5vAxIB8tYvZmFy/eIAKZ8YD5ajysUb9PBPxgfNmzpbRh6rmm38XnC2AzaUdinnYfwQkKtjKtcGC1DR1m2beh4m7eoi9s2DRo5Am9JfLBsy4KFnjYKQ1mwXDhpjPQwkAxUyw1XPXSsPo/DgaExUGEoNp+FhF0HpKtIcP0hqG15KBLgGbgoDjBe0gNRSCgXfJxPaFjwQOKBpHcnjwgq7FLyDEsQtAMCGnrCDY06193UrPy8fLW63mGQS8oBESD5gcbyIWzh61//hhzUQrielsVcC+GcCIjmQjxhYEWNLlpsn9DJ+etnXyZR5WYxY12NKkRu7lxlRbAwWf39xje+RdJVV1cKZE1GhVnlTXtUVPxPoeM9FzlHQrCZP757BqNPrj/mCOBoYV6+E/0IQYyAIwycDlMu0zPqSYrCnPfEuObvBKDmh+YbKpDVf3IEOfXLigeVAxlHOcgiAvS/otOHfsz7JxWRJOgZdzYdhgR4+m0q6EuLwXPJd4XuXEG+N/2KclAYCHNNO+E8EMMtT1QJtayQKzVPcgvQ8V772tcFIpxYGmRuHDOAAVWIkWQYguaXKld9BEWAePJxB29IgcqeM/WFIVrGQjrqQwrySYWKWwbDKKLDkL4QQ1CxSylO7Q4uEBSNMhgMilA4iY78qS/vQdgqCJRA/NKwKkqB/0fkR3vacCZ//Q54bsh3pdAqguUsEg0Zuf3Uy2a5sBgE2rJUmUgWM8b9ol5d4FBUBo+OE5CCmpudlq46MjstYRS1ujr1DODZmJpUSWrbmfYj/yzIEBAU44J4FGW+g8Pn0Md0RvHMoockB9J/QBaih86+h31P6ICWHryFSfnYetp0hK2Azgj4vM0VhWHkPBTL9ys7zimP+tJNwFtYbP1turMVLu3/f2YtCT9/Zrmcn6mtzDsnb7nIXF7CXH2hQhmoCHw7oIm3+SThEXxLzx/Q5rCNkpSPLY9wzDBwscLYg3yG4ViGbQUo+VHfON8QUaheCCI8qNBj+ROmHOoDI05SCKintbQQFHh2ZvEQBwbNWooM59aSU2U6h049gPMLuYvxwCdClevRQkVY5FgeVAOzeJP0C+r5jlhTgQLAnDmL0aCnfQgDaa9MWJ+gD58940JmPIQEKAh8D75z3J7mQxBP/plyrSpKjEK+F+nIP5eq+BD1LTYF5SbRx/ULBDgweN9oUUcmU4/kk1EUwOTCTD7hS/BBGIi55EtCvGdcjyUUZxeBYpo17vMPEHf0Q0LJvGFC9IWFLikATkcOnzy/Kp3pD6jSwnk5D+B973ufUHBiWoY8/xMDhYHDFACCaB7JlD/5EiyMigHBpYbglyQogECAWRct9cWixJK32eG6A29deOALQRQZFBHKZ67fpuf9aV8bvyQcFC7yhwHTboRtOhixm/W0UXnD5GcjU+XB4x7qwW6txUrFV8wHiW0SJgn+1KQuf0sHi90ki6ZmdQ69usCIR/DTnkDaP/P+uSUsBn7L9wlT4s6eZ9hAn+spi79XguBP+g4WTz4WT6nU2yokxAOTvleheNoHumIh6ag/6ex7ZOqF/NL+R3rSWQhzs3jCKLyEK0O/LFL+k2zF0LrybTgpQ+jmpqb7kmhK+OJboAA7KD6j85gSTTJvW03Pzg/5d69L6fXACK5i2wMPAIoAAz8z4DUnBAZTAbj2Mhq+MlabLqkeCPokxggeAQCDApYtYey5JTFQrUKQS5UcwqNhKTD4aCfiqVfMEI1AcYu1IM2B6FsoYKSHMRImf8qlfaxAAZ9TiAtg+YMvDxYb4UpMseAZsLVF8FeUqyAnTPokwU98bZV6DBYi9QCAXwJDvVAAF8PUw0LwVCyGtSn5HfVuiic+8TJX0FMO/dWqT+Vxvky55Orjtv3J70xh0nciP747YTuubDx0hSDvQXrChdIRn6mH9hDGMfFA2y+XTDml8rIzd6l5UBnCSYypCqUro18saDhp6o/tfxwARH2Axb5vkuGAB8B5pA74PNmVRf4Owq+zUPJoh5SNvyDD+XvBBdkUZ/bSiecBxPxLHzIDt7hyEDSxfyIhWawQ5C5uTqBeis4IeB34lAueesOwEAxJ44zV9ExxowgsLfnMMEkMr2BuViEokID3pj0gt/gkQQId6ZJg2azypdkKbf8ky98K/kIWP+WNzGi+zXpVBOglkOuJI73EzQlyVRi4nMomwNJH4Nt48NARvxA6RnmEoMdDkKsS0H62/cnHQvpnkoAhH74X+SflA97SUQ7xK4U2PeGkehOfVI5Nx3tagW/Tpyq03cvC0pJU2EXAYj9Lf6bhYj2jxeYPPznef0IMr2LTlejyt0BJAVjaLmiK1oRBszyj8wAYmDAUBq4d4Fiiuot4aeWWYFjkEyTjQtDQyb+QwITO1oNykvCLizBwZdxY5tBjQXPgkLVIyB8IYyacBCmn4HsFBQwLPyk/8HwXvhN4IO9FmHZLoocOy78imMx2Md9i2E5eFRQBOh/pk2Ahi590CH7m/sFHYe4/DocHBLcV/OChJ8y5Bpx0RzwQOsIoBHiGyjmYLqVvjgHKgTV8l2LbHzq+D+WSD2HoCAOhs/HgobP9Kmt3iZDQL2w60gMpB0i9SUc+0CdB6OAfSXSVYVHFQjhxsywoYtCXcbJiQv+ADkh9sfzBFwuTLP1C6Q8fPvC4oYE/G3QpuFwLlBSA5VonfxwdTXyxhc4DsFkwUBngNp4wAzkdhU8UXHDxVACGU0iAJ4C5OysgixWw1It6Uh/weAbAc+lWzAjCNjLoMnPsaokWmiLB9U95MEbKs+9FudAtCRsXimXc5As+vo0xuOTJz7aHLY98gAh+wtxql2vnugUo4YAfbR2ol0Lr8rcegaUpFLNE8BvChdncktNmN4IV4CSPBX98tr+uWUjRXyEEBpdznF9oCNq9Ih307aAIZNYQKCHfxX4H+onF830o3kLSWbxNF8738fv9hZQTB5mCQ1FhsSLnFHBEMOXgOWMcgrf1tvWxYfqjxRcbXghTLXhgik0HXZLrn/gk1z/xZwuyDbuI/OCUDL1idewisn7uk5QUgORvSMdZto3oiJs3bwk5KSODscEYkophQC/G+3aSKA2egz9iD4BWF4G70nMuYEiWAYI3pcfb6MCTDogHAIGdsdx1HFrGRzmkJ18g+RAGJtETb2Gh72HrRXrqlxRvBT7p7Fw/eFz/UZUehIMiQDwQwY/At3jCFiYJ/jQeopCgvEpdJXPTqtdi+ceCOtDFAj9YhlzqQ7kI/nSYgrXhKlY5ht0H6SCIUsECXQiLz+IZ3DClFW8SCXoK46XY7w499QQmpec7M36hjyHjDUQILywERSVUmAOTIMPjwRw2igCKAfWhn9P+4MnHQvojBoONJ5wqz5V7z1QRSGpXykty/ePCh65YaNOxDXuZ9Bhqy5CUopYVbqXmKdwCdMTMeQCappCgIWcGOPfGL5Tp3GuZnn8SMbCXTAlYRhQyzAii3E8LY6PcZwqpt80HxoAHAA9BkkJg0xOGERIGwjjP9vuQ/7mGCP6FKOxe0s+dWCyC3yoCFp8k8G3GlTW5/QLBn8YVbxOEcCzww7XPkFmBXxUfJKUCJxV2S8wGgV61oJPOqUj3k6TDORfB8OeYhqiMk3GDR2KBVYes5QgdhHGGIKRewKR+SjyQ/pQo+CFMgJRDv0fgczIh5mcUXHTQhzV2Eeno3+mwFiOjIGCPmAqwmLQs97suOVeChjLJbXCxMtQ01zFkyVYchi+sOGFCAnZhuWhGUEIDLcngLL/ZkvyfU4h4qdpzqtbnprK2LRizrAXgPljOTJUR51x9nb46j7ifh5yU5p/9L2ncwXCUytEtaj9OB0sCPDAdzgzPhEN1rSLA7V7MBZDAQFs+DAlGZMjjIOliRsnq+sCYeV8YMgM/KRxnXOQD5UNOvQkTn4SHLglST+KpP2EbH0/JhNX7hKFP8gAkWfykswI/KYzgD7frxgctVVYpn8PiJ9/FIHjLIhXECP6FIEiwPKPQD+P2ZAoqnqTXHFEAEPyxoA+KgA1Tj3ToLyw+Ax+VB0GGyyjQ0e72e9gwdHF+CQ+8F9FxfwZhIf084G3/grwmnMBYFy6VQrEnPmnOG4XBegg4oItdQpYPkC9rTAgnwXJ3f3n2j6kAFPRyM/fPLoDFoGDYKQDanzUATAFYDwB01pKn3Ow6+eekduKujFWrVm3zdG4XwJiH7sc1EOyfxQOAYoALpKQAaHvJf67amBVxAT7SMXJHSKYh6FC0mUDXscWUm19UG6Y8HHFXaK4bRgUjQvDbbYGZ4nOfEDThuvms7VdKl5kK0LAtLze3pSHqRTooYJT2qGGmHBhlML7FoKCQT1K+5J8EYSBJ8RYPPYya8i1dUpj0Nr5Q/bHYKoPLNeymcpat8qGyOe1e6aCgVZk5dwQ95RKeS4cDfGBnbOcLYXsuw/Qs3VgJuK0xSfDHc+5LBD/DghrlQlz9fP/c2ChCMbD4VBCovM4SRcAmCGG+J9+H/ojiyfdJSL4ETXoi7HZE8BwlDX08NcH2zUBI+fQDu1uG8RHnGx5Y+2LxiWEs/qCYscbEKgJW4JOfFfzgn22IwmMVA1sP4t33323jSuEzbwGE2ZnnUEopLeBu/TomDxU1vWfSJGXsyw0u1sUKZRVMBdg8MwxGY1hdbekQfDAmG58UJh3xLIZC8MOwMNSgIx107og3iYoZtmGY4EkPJB/CMH7CSZB0vK/Nv1A+lp5yyJdwIYjgh46TfBdS6ulZ1EvioqkgXyuDZJkrz79PvzKlUwYoAuSL4EeQgreQuf352MOkBTP1Hq6Td1NOQXGIXewaLsOjZKYAbDlW4Cd5AKKwFgD3ms0nDseWd6hX8AjwHWNBjJ5iPFFxPuYBQQ4aBYKDtMBbGNOFcmw8YepH2EIbj4W/lA67w8QEwY/ARwEwVEUHkyz/ojM4x4QoCnOzcyd9UaX9/2enwUsKwDNsRzqiu5dabkJ1LsAzUgCoBot10mENwJK5fwjtIkDwAcJgcLFZAYaANMniI1lJD106XLICPpOOmRIwyokpz9JbAWvjyQU68rF4wkmwUL5J6cDbcsFbyO6LeFV4IEiH3QdlYXEnt+vF++xjwaYJ5mLJklsCitZcsPjnENC5ZFxKF2O5XArGSUSqPLj+F1T02lX/SYoE2/VYBR+vCQgZxy59815WIaAe6eAZSrT8WQTC+yYIXPudlygEFJiQPqHZSRUV6gf00zhBeKD6tn4IevwzpIvHeRDsMT5hcTDnbcTbSvEIhITW8sfiJ1/4AmEumbKKAPHW9Q/+2YJ4AI4eO77XlJmgIRmqUjBvC5QUgLzNIki8ddZIocOxFkCIRycm9viHltaWXYIIf1gahRgNaZgKIJwI7dx/IuGZRSD4OQseRgbeMkb4dFJppCcexkl+4IHQQ0d5SXjSAWFw5E868oHuTCH5lnHiUciIOXQEN7sQLcMvVC6WPatA3bW2ksRMwUf0L+pDvnHYTDHMhEV5FQZPukKwPAiadNKaACNocfWTL4oC4URIhzL5JdIXGVFoHNr+Qf8rlD39i34HPYoAB/MkzeFDD8TDR38Cn+I8CbapFuDgVvCTj4VJgt/SES62XaBHgBNeKUSR7es7rJ7WlWZQos/bAgW6T940JaS2AIqAnAdw+ND++z163ZrucJ+r6g3FMhwYdqE1ALHFmfAVMBjKg2+XfC1jS0geo2FopCdipfmQzkLyZ27VthPlQgdjhfFQD8I2f8IwYOjJl3jyJ5wEKcfS095xvhQYJDOLu3jPOP8Cgo384stdjAZBMXbuPbYk44JyHyqC4GC1ObH2oCbiwcfvQYL4ABmGQRyR84DAjy39JMufxX+kDu3D0cEJlwRCnQxDPrZ/0R9IyPclbOPBQ2f7Af0TOtqPMArTIicghm2QhBfS6pmBnsuh8CCBZxdBHF7hg63nkuQpYWcOrfYPi/+W0AUEi/8S43HJJBGsEM+26yKS4czCRYlBV0TSC4ekpAAU/tZ0pGXb6uTAQH/hrM4dBYKIEmBQMDIGPoLF0oEnHTAjaEihEMFgFzuSTy51JkS+MNLMASuBJoFhZ3LQJ8oHH+cHIkDKy1zeExhbeDHSQWfDZEc875cUjgVVsGCx/JYKUHJWGG9zAx3SG7nvFnsy2Q1hSI9CYVzwuVTJISuwkihZnc37pBeThoUOG6YK5sOugDKmrjj7f4ngD54OPABs/0uqUMDz3VC0ihX4jA+yj/MBEaClYzxBRr8gzO18cTg80G/o9whaBD708ZRRQNhV9TFd8MiwFgB8IZhk8afO8EjxpNX/SfWgHyXFWzweBLZd2/is8PIaaRZh6dHtWis1wtlpATrmj7/m9cKhnR4tnAx+HS/SO0PXPZYCDCOp1pYREQaSDkYEnjCL/aADInALCQrLKElvIeVZfGwwINACga0n5VAvm0+xYVsPG7blxmHkcBC4Nl2mfCVcIuAzBMs+UZ4lKvQdIuviDyc0LsHbjEM4qV0R/JSfCvmCh7HjqbIWJO+TDoI/nl9D4YkVp/zttkRAm36C4Kd/8HqkK4S37x3rISEj8iFf4GJYHMltnrjwiQdaQQ8eaAU/+Pia6JQatJwfUKxZaxWWOF8ejOUfo+MBqZik94f+XMMz2P9PEzFiz3UVn1P5WwPjOVX5c1xZ2gYXEryK8wBQnmQtAOcBPPTo9x/29aoInBALGcZEncFbiw4GiUABWvySRWd081AA9DC8JAbAvv9Y8BuBRj54AmD8MEreg/d6pjBmMMYTkPQevFeczlTA1pv3hYz3o53BA+N42IdpH+jONqTc+HIWY9nzHZIW+yUJetIVqi/ftxBdUjyKAPF8vzgcBEs8LsL3xuXPNlLokyx74m3+9IdCeN7z/7L3Lk22JMedX55XVXcTRAONx4VRgDbgEDMrbiUR5AbrMQxIbaVvIqMW+gBjNiatuZxZyGRtlA2GMkowmNFMJEAO2Q1IAEHDNNjqIdHd1mgD0bdvVZ2XMjP851n5z/QTmefUqarbN2tRfjzC3SMyMjP+Hh6PBOjRwy5U7ZAO5bkqv1JXJ+22nE+DRD/thPhFbE4ExICfbByA+YwPdJOTKIsAWQPAe4KURgDY708+jhuL/7RdCP0z8kdPIxWqx0i+83yoo4FDaOllu9ar/z/32mtfr8qa9v/T4qdRQOw0Ky+WNiGmVtuVD3q9T2t7kzZgL19qrREs4wHmPxwZARjaxABHBGhqB/kmJNmWoOOjg+TI07bU3XHUmw5GLXt9LYOOF6ry8Ohhn3So598z0FM+lHrA630BwLPAP3LE7+Wd+IOOnefGzfEAeUL7RyOP353yOwAS2EGusdPWJ10Bn1qofuTYoo8e92NnRx0D/OrYL/BEUczQHPCrOtsAx04FKPBjF+CHh0bvpQI/8qdSFv9t1ut6l9Wp9ib9dgu0QKydNXHHtMC+2L9jel/u1TdHgKkBjsrtlT2QyMhQV6GjMpfl4jmAjIARe1D635H9GepHU4Axdx109BSEHrxSvw/3NLLX8uEVMEgHYPQ6lEfeqUwBYMfzgx8KcIFYJ7kD/DwoIsn9YeQ/w1ExOYAYijxmovQon+uhOpH+UMCnHKU3m7TmwR0BfUGogCoaz5Hfni0jftI5X4IIAOljgR89pYz8Sae94KE68iddKf1KZEflI/7jj5+9VeWx7fqWHAOyW0n1T4mJavbEVy0wOQD554AHiSkANHjw6qE+D+Yvf/lP/6kS4DwAn/tHS6h2PHJOjkiXx5TY6v5ojrGjQIIBgs5Fd4BEQs2EXjHTdMgMlcm5Y0o9ZI5XS1EA93z01A68Cz7MjwjwqQ2Azf3Jre7XkD/62Ltr2gF8ChCg43lRICYdNUL+fFbX0y0E3JGXkDH5Wk5kR9879JBXurL3bm1HPpO/uUmh/s06nUTLWgjyOQlzZ6v/dVvkXhwg9JQq4Gt+jsdx1qmAnN6585kSoByeK3joe++9r4us6X8RmegRLTA5AEc02iGV6/X6vUP55OHg60CBfKV85IfFVZrvfDTFYB3NHEfAC7aQqwEjkQUHEEtvOsgE/HS4Xq79UE+fEYDK3RkfAbqmK39nFRhnCEAHJ/02mBlvdzHLCNEdARnhi/ids3TMnfvOhViJms9zgxj5/pzIWg8qHgEV6dhFHvvw2G/Ka08tIBdRBXwFfgD16jqd0LixCAAha/FPCgV+LddD/pphPCN/si9W/XP/5B9LCf3TftiJQv/k3xXFgfLnzRry+3/1/R8NLINdW6zdYgA3UP3FEpscgNPvN55o6zyA3/iN3/iDynQJuHXPQySAkQfjZzqyqOOneuwCWNiZ8nwDgHwcBOySHlIfIbc1mFJop5afQ+84Am3LdBhQcuFxBKDkH0vVjvKUe6z9oXqUo+Xn9AF+2hXHINQzhbnRqFfDXmgnyOA5pONFDACFL4/G85/VD83HDmKaT3tByYdvGS8Z0pFT++Sjhxw87xu8UuxpugL+wj7GdWMHMl09e1qrbNYJbwD+pn3ab9DO3h8NnePY7ezEqMgRIAKwkxP/tN7wLP6DH0t5nrV9x9oZK6/PH+360Nusx17H8yI/OQDD7xSe5cE2ix7U5kSvNJMwt/22dBN0RHP7iAwf8+FFHFpNOrxciHmovbuS045k6HUR4mfOGJ56RXZI13LRgyIHP5YO1QfgAf5sOSKIPiMk1b8r4HcANQR33grkOaX8HNDrFNLQEL+O9CmHcrmv1I/nnvwc1euI5GlXgH99k0b8N9d8vdNcMaugrr3BYYvsD00nArBcpvHGZuT+f9pzaHm0L/I7O2jkviIBlMsUAdusSe+hDMR6sqakqAUOglmk9IKn86D1th0PankeQKuZiAB4oi0GZNuTn/jmAv0/fApg5K6C2cxGKmKWDpTQf3fVeXJRXM48Fu2QxWyHBbiHAiZyhFLH6lOBxg4RQXLuh+4tVE/7AiiUDrDDl6EW/1n9ID8CfoQV0ChH05GHMuLi/jKChVcgIN31uUEkSEg/J08+5SiPWc1v0tsRCdKhuetHDspIf2PXgSO9s29hXD9Lc/3M7XPwkwI/9hjxs0qf94v0zr5/++jSnJO9mCs0g2v7mBPfZqKcHGXKolil3UlzzqgOFMe+N1zHXe8GIAIw7f8PbtSJyYffnhONf0LUaSMQhMWAw84DWKbP+pUnwtV6OAK819pRw7PIjxcRyglj7gjYnH8TYWi3undgFrrUxWIAizsgFqoEcLEGEGkHrTwdNXpuB7u5VY4oPqeUdqKjD4Ff2sH17um6uW8Ux0iLSAsjd+T8vgrAu754hC5vAthBnnzSAWox05kCGDrSxx7lDaW8f/7eGPB//Ozj2gQfxRpqT+Wi1fq6GJCpAfQB1r05XHwMSO1hZ+U3MlnYL9OagSVTEcvUjek2wLkfCJT0uE9NPegGLZ+pRBOgnshDsdM8Z6lbhXc5eQA22+20/5/GOQPtHcWeoZxPosneSED5oKcY4XZTTxLOl/NPVRcv72Ppf9sLYC8Q/NCG8t0A9lU3BmKRI0AEgCPcWStIuU0k4nANHKisIwHg3EGRDgEg9PzAvNsN8sNkqwdAAnCF8nec4fW2emDeHR/OVJeRPXKuT0KG4rAhxggeXqm3i2UQgtfdIKTTfnTYzpsbjD3PN7uka/k5Oc1XHrs54AfwAXAo6Vov5ZFX4GeRH8Avfhtfu1ZzHZ4jkJlHzN23jgFJYIBQbguqc9QREHFnOYExFwFwhQf+cX119Q8PXIVPdPGTA3Cm2/v042cfVqaXly/VH3gvX3giB3WJAC7Fw/uJfGTkKEiekZvZV+CYy0OccuG9wzfgaoDMJeof1JPPFzcHHTEn2h4ptLXL0LaExjU/4umoNX+GQyWhTQBE5YfyEUArnnf5dih/aHkqR/0Xdnb+1h4jeL4SuLQ1JVu2m6nHaSOrxp6VxIgeD9KSAWLklaeepMMj3/Dt50AGeIh1RvpkRMA/FNixo8+N6kfAf3OT/Pw9ETQW67Uvi2LKGZTWa+7p+iPnyLHbg0gAn5fWYj0SaNsUKX1tL+5skbp40rUetoaxWJnA1jwcdgPguHOf6T+GrgVAT8sdyl9f39QOANusb+kxALuVVP+M1seq3MSXLTA5AMMfAx4sfZd4EFvnAVxdXb1VmX51t/1KRRlxMQXAyLvKq/7ogBJ34D+rgOW74Qc06qy9ATr90zZtTijz2kCFQ9BOLc8fsBEg1wEw4iDM+GHlRN8siIBfO+jc9Wh+o5+6SHYz7PgIDddv2yGpvwK32o34pry2BHZ9Ll8Rzwp0ubZ6AZCTDKBDfaRur27zAqfr5uEktKojTfhdAPwAOB03PPXRdAVSvVz0oOjDq33SI+AnP2p/8qGRHOm8d8yRX7G4L1jdj59E5Iv2pLy7plsct3W6v5tlejNpN776iSNQHkRWV6E8irymADjyrB3YmKOIQ7G190T9Rq4HO3r/ot0N6EF5HuGH0mn//9CWOk6u6T+O05+0mhbAEai3A/7iww9/VmU9efLF32tEur/ogNgV4DjaFU0pCvxpiUFRbHiV24oAfzu1+jZe6igAAkL1jWOS8l2OM8nFUANkyT9i9wIdjoh3WDriToYkUE7OrtvTqQizR3uLeWdd31MO/6BeHSlBQhwoRnZcByN515ev4wHo5ANAuZGmAxM9unl+TbnJonbo5DflpedK0wF+uUzUfETvCfZD7eSAXvVP5bn/3LeNLa+/sQN9OMt/r/tsrWCAn3roSJ4IGvlKtb3JZ3QB7zS9hszceXLux84ifua3u7g/31bgjgdK5zZMg0iAGzjxB89lziEgf9r/f2KDZ9QnByDTQMdmf/DBez+sdHfFP6+n/cputNWX0xFhH56OifQsdeDne+Kpw8ZeVt8EGuBvazDib6c2+78bByFJRCMF1feOSDMyfNQ+Cixq3/rR0nrY1R4sOSo3HOmbNYA/NC6AT3+MfAT0dKQuZ5/l5eu8tEcEOK4nCI48+o1c28EUtQ7gqz52IsDneY3y0Y8o+lE+94/nE6DHAYASodLnnqvHDuX4dZrDSX6T3nrti8jB57q5jpVNOWxNnftKuXQn4DYje/J5/nHUqBe7FlwuMEA7IXfXjgB2I8rq/2hbdaQ3pY9rgckBGNdelTTreA62XfjgMmdPLH58+bXGdkbAga6Jk8FS9ZoOJeVHkQBG/toxUC0GkAp0dGR0lMiR3thNluhAGAEjTzlQ9HEsPN0Qp+nIyElU070DbouFnOp3BHWuAASEmoICfrNWIo3FmhB+UhgN+NZw3N/mgKhkjxFqdP0APNencgAG+VC5TJId+NUO9XNB+QHQIQdFTPNJh5Kfu28AGSH+9To5gFv7Wh8n+GG3io1Vf1wvQEq7NnLpl6e7h5nuc9SOqj+zqRzWkM456MsE97bIt1ik93rGFKAY0nbQ8nHwaTcCessl/YcYPDMLwDPSV4eW4tlWDd9D6Qh7sqakXAscBLGc8guez4PX24Y8uOV5AOkN056exsMhgB9LfQoAv6T9QofAb+Xw4hHKpKPwUCY9Ez2i1E+BvAFuOsJEsQ9QUI6vOTCAbewlPRwP7O6CelAt5LZmj/KadCQH0gj4RV2Bn+yxgM/9cH2agX2jlsFaEl98aek4WuhDaQcFBpqTy4RHbyxVII/0kXNACgSjfAU81Fkdz26YBvhThAye60Uvotpe/l6IgjpWku0sgO8J8sMBX9K3FgrgREDJLh0We1A0Q3kbeND+mj2W5zwD1cu1B8CvevD0Fwf2/9P/ogIlxOcuGRkT7bbAwKemq/gCptBWICyxvd7zAC4vL3+9aqO//Os3f1zRC1blVEz55x24OADasTGCKRbped/d8HwnO3R4RWHfIbeDQlJu83+bOTt+u0nvCwDEC6j1aSwO/GWhUYA+p4VjoBEEHAH0PfIgixhJRy6iel0AOBEKLU+BPLIbpdPh0b7IKU899gb4OsIH8KMOVoEAABuKD9RrKKUeWm4EMBGga3l6fzSf9wIg7wA+X+fb2ntjoSVG9GqPdvJ01pC4A8xrnyS4bpcPfuQAv6PGA2AP4nKZuh17PctFom0NQv+72aruGFbz1AH49dh1cD/odxo+GeQ51OuCx7GEZxeAOgCcA4Aczz21ppwoHblp/z8tcV7aO3o9b5GfOOt4ov1taecB7Ja2HdAOBPJWYCpAHAHP58cW8+kIUpILIgAsMbhJQA7gLzJfG/PdAI4QbceZjt2BmYLpGI1HjuyGtu3RAeBg6IjK+zdvl+Tw7KVDbrpj16iL5OjVnCPQra/VE0SxC1Dg15B+c539v6IjWGkHDoigQ6a16KiHAj4dvt/G/urcW2oO6AF47gO8VhCgJ53bo4DPyJ65fHjsUg52oLQbvFOeb547zzj8YzTgqzn3QFOGAv/GEnAMOBnwcrm1V6L9Pqj5sTzAP1ZP5f1514yAZ/9/z/a/QGNKPqYFQJVjdCedAS3AeQCvrlb1gUB0+K4qwE9HRcflchYB8BUInpF+7Az45xfWAdykAAXfFuBkUVErFgsgJ+UQCUAuenEjgEXeAd4Mkd612y6ffCjxDo8gZBAOa+4ggBhmMKo35QH40g+TXZ67ktpXZ3SixXoo+vW7frujVsB3PSmI54N8ACzTLIj7nL0CqwvYD2k2nxNXOXieV+oHT35EkYvqQz3GAr6XhwFrINrL86MfAfAzslU1Bf59kcYFmq56WZ4H0Yb+AD965Yi//tms5UvvPZGOwdeLwQemRAY+/ujjnwRVYcCl2XQVmj7xB1pgcgAONE6QxYPGFABiPJj95wG8+umvIHgO6sBvxokAzC/sFvM1ESmcCIA6AgvrcHAIHMBMHx6gh8e88qSfSrFLuWqvA0CCjDnHgLUCRGI79g2QKUfzqR+OHiN75AjpO64T6TABACayrx26XB7FhFSBVvBRZz5CO2Rgj3oD6ORDkYNXSj768IzkibzsLKRPOovJ1N7R/Ejg13LW5tCvgsV6Kh/xAD0j/kiO3YpMBdAuUeQpsqPp3E9Nvy/+en39PSmL/lWSJ/aUFpgcgFNar1+XB3XUeQAARTOnb8YZ+UtZ84sEZVt2/0n+6qV0a9dXqTqzWdolsN+3FQD+tc2ZruyMcAITDmhmH7ya7dojWCn+wVkAFEDpVAjkNAQE+DtyJJi8tofzuZG922n7jXS01BcxdUB8X7c1O8AN5XLQV4qcpsNrvo4gyY/KAbCxpxR97HJf0AO4SCeUz1SC6uGpIE953o5WUecRiGgA/CrenLxIrClJMOI/FfgpD+Bf8iJmzmzDEVitUr+gjiJ2c5T7wXOZkyefuX/4YykO3Xe++92hHYyufmaAdmwVXii9yQE4/nbz4B1sQ84DKIqv9ZdkiEqIs1+o3JZk+/3ntVtRLvlby1oAUdxcp/encQTawC/iBcAfRQRUHt4BkISARiP2QNyTsY8+1AUyPwAAOhaPZYNIGf1stgA/I3xftG/3l283sG2MegH0AHz50ai6SF/zQAUMb6JqR+moQwFS+BztynsMpaWqcgAzgKI8I3oWe7IdDzvIc9+47/o8UAnS0aN94X2KBYWRlOtY2Qu43aX3b8ELOdKeigP0G3telC9YDGCKRAiwA/BTT64/cgSYUmPqYG4PYgT8mq6L/6iHUu6Lpuf4P/n2t7+fk5nyT2+Bg+B1uvnJAucBbKwHuzjyjdj7Yr/Upgs7GaQ5D6Dd1pwsyP5+XQzIFEFbqzxexNYE7CwiMLeIAHJ8U8C2S5fn6rRHQsgp5bLpyDX/3nmGssORs64i9ed6qHcE/JpPx6zAjxyOAMOf4dU7PPABCClHKcCh6V0ev7edg33sKE96NKJXoG9brz6LnK6PKXGATe8D7UuEwB0+MYg97Eh2hwUAAf6dAfXc5vpRYM6fiAC8Ajo8elBNVx452/Tj7M7O/Keefl0sZmSRsGukH0v3VCUjYFn9H2R7MnP5nmA/NF3vn8qXfDTSIdLaozIlDW2ByQEY2lKxHA9ib1tyHsC3vvX79Om1JUb8NoDsWN/N0nM/36chPzyCEfBvb9IIbXHRBoSNbQ9c2qKhaEpApwIoL6IAGR1zJHdqetRRAMiD7Sui4ghgQPNJF0q5c7aXGRBsLVS7sANdGPEDgHTQzYCuH1CluMEs5eQUAGjkxuq532cAg34E8AAy5dJ+lK+ADE9+RLETPR/oZe0BlDIVwP3i+hjxA/wAPI7B5Szdz7W97TgCa1u9uzJ/eT1LPzjxj3oOpqt2d8MUgK8tMUNElNxx9ymF/qkorndwPUQQ/dz9EDVnmQq6tf+fPF4U+lvSoXR4w0YkaL3gtAVKL3hbDL182oxYKG/SwfMA/uZv3vjbqgCdK1sagjYdZ/v5VeDnHADWAERTAeoIsEuAi4wiAORHlI5kYUDJx0o68o4QnZxWAh14K/EAs7cOjFX3Q/UZYYam1RFAEIfA8n2Eafmdjs62Xc6tg2cbH+bQp6Mk/VQKQEV2GuA1CQDP2KH6NAeAil3aN3c/0NN6sqTEPmqn2R3eR7iWw32gfV1B7tvWjsBb2PkR8EvuvzgAbifzg/tJO15ahGBtjuHKHMOtDd0XRVqTo3ymmE72y3ZC4PUyDRRY/MexIysZYeyXqdwLPHcsLpI+18H2P3jEiAAwBUB/pnLUAz0d+ZPOfYO/enb1TvX7yZeefKOi5TbAX1W0/LtOpLgyiiOAYzA5ANYwY0jbjRyjOclqC/BA9rbpx1fX9eeBX3n55c9Wiiz6A/jVGMCvEQAF/mgqgAgAjsBM9g8yJTDUEWDR3wJgswrjCGj9eSs1XXk6gBxwoAfww+cowJSTY4Tqcg4Iyd/rAIsLtn+wSp3eiKkYth+y2HCbOZ+hbbXkACaAW3lTAIDQB6AbPv2KgFjl0aN9yNf7Fdlz/cwPgJ/6AyjwCwtxUw4UR8DrY+2i+Ea9Nb1TLW1fE6A+yFMvgP7aDtqCV+BHTymOgKZH/NIiS5tFvdmoeEbEyYYlK9aQRCF/ro98A/6FfU6aiFVUfi49AvqcHvnlNxreqn6P2P/Pq4aJiY5ogV6wGqE/iQ5sgZur67crURyASA2gUeBX+dUqeeydCAAfB7I1AzgCxSYFKph6iBwPLQfg39ND+3fRVbLN09Gee2qAjr1d+gkcwD/QhAOPyFMvjiQm2x0jVl9ZBh0vnzGGR6/5BEVKmVmoGSBq5NIvyge4NT/i0dPrAnAjPdKpjwJmlB/JA+yEtKPySUeecqLrJr7Ge+Yjf1e0HwClORTUk0WLiBPKZ6DNiB67TAEgv9inEThATjp0Y2f/M7J/ZkcAd/hZGhCvrYFW9o2D4jI5BtiD6oic9KE0GvkP1ccRzzkIZQTg54FNBlhB9pR8TAtMDsAxrZZ08DyZAsASD2rrPIDrzeanJvDbCPZR74BtDcBsn17ovb3wO/sueGGfBV4QWuTjQCwWxBHoK6RMayIQzGQkQb4dAPCjvhTgJ/RPBEB59M7lCABQHmqnQKM7qa9kN6yEiJuMYb86JySiZnYdOEg3yn1ukt01sCTlXbFR6fmldmknFSXyQjpyACrpQ6leJyH9wk6gwiGAkg9POfBb2++vI3+APqrn3IHbhsQDHbqNzW0A3NTHqdnFHFMhRHSQo11ntkh3b+8l78HcHDfkleIYrM1TgVc5+JU5krMA+JGDsqh3Z+cUrBj5Z84tIOSPHSj3Cz6iOeBH740fvPETfhulP5Xkib2LFpgcgLtoxX4bPLj1UP0XH3zwUSX25Auf75eWVCIA5frnOoeRyzwd+V3YLqSCKYHipn0rt/LZW0b+Uow7AkwFKPDTcW0s9I8jAPBjT3nSx9KFdbRbQtyBATpaPouKGEBGu9HxMAJBDspIEF4p5RwbyWBkyJIIAFrLJT0auVIvlyNBaHP9KUOBEgBFTvPFXJZV4HcFAX7SkQc4crzWD57rwC73accUgHwjAjlo047Jj19GzxsOhSkC/PoRPcoH+It9GqEz1Tbn83tmR0f6AP7K/BYd+VNvIgHwyL1CglGd+5fsDjuzAcVc1gIw8keBOX94vX+k5yjthRzv6T++++4vSctQPGRGMAzIMmpT9u0WaKPG7Zzp99AW4EE82JbZ8wCC0lgLEGSXjkD7uW92B/BeJE32Qc9sxLCVDhJgl36qUOCP6pFLx5HIAWkO+HURoPJ0LO1WqabQ2+2Rqy/5ACXbKUkfTA1YrF8v1bRmYokhpiTnHIOmnm1FgBLgbOeWVyXAp46jynd4tpHpWdOkm+cajfjVXuQYUM/oOrDD4j74DgXBrZ3VEaAc9Lgd7Jsnne2aGgHwET+LQfWFMgMAuQI/9smHB+g1/ZVXUrezv04Oh0YCotD/SuKWEfBHI3/qxf2Cd0ebownJyFD0pv3/mYa64+yDoHXHZb3Q5niwv/7f/I61g83JWwfJSFEbqYkEpBw87sJCd0wFoMeiQNb4dE4W9NITJKkjgB2oOwYknIk6cNNBB+XoIkDlIyAMzGWTwxB/VrMtwNw+i/kAsgaA2vLOWXuwWh3AGXqdAPpCgB775MOHFIAH2OFDhZTB8wpQuCNASMvs4Rax1ITQP1MBo/03kFvrJ+ndSEyqCfX1x9H0cAS4D5if2SLA/cDFnRHwY0+pAj/5CvzL6HvBKNwx1fsbORy5YnHQX3/99XqxdCnP/n8GWJggsgoP5YmCn+iAFpgcgAGNNFCEB/Ngm3oo3jpk5uIpgw4pAoaZhSQZURc2d7fdUTyWhtGFDcnpgIdpnSCFp0NM/ARTt1VzgIiDcVun7zd27gr4KaO7qC/ldO63AD6RB+a2CXH7wDL4zLMCu/LUq0MV2AH8jmBK2Fj+UvXgBeBZE7CzdAAf84zAcZAAYvKh5COv7egRFwAfJIfHkFAeTxb7YadxoEhJiv5cGfzwXmrIn2II8cNDN5t0UufStukpj5xSHfGTPxSIdeSPfo7u7OCBzpSAtTPtArDn7G3W6frL+/0skI06uHvruoJ6PdfJ7af5ub6Ue688bUdsmaDawfMAvvdXb/yoqunFMo1pcAC845HLwBHgRDCOCmVqgCOCGYFsJLQv5ordOniPrMPeZfTV3lDeQ/96oeIIAMBD7d6VHNv03J45Wqc6AuCO25UfABn3mVA/I34Rb1gB/sEA31g46hdPTwfwxVroGOBQiIOAI0CEAHOkwyvFAQBwOvmEDrgR4gDgOKCnj2c3n9c+aVAuwI8dKI7AzD7asV+kXQDwEeDjAGAHOXgoDoCO/NUBYPEfeguLFOQcALWLvgI/jpqWGzkAtBv2pv3/tMT90oOj1futyiemNO8j+65ot7n5hzp9+dKX+/KjtO3cbpV1ZAC/yi99aJhycAhywI8dOqxjHQGAPuoQc0cHa8dAvaDnchAI6bojwNw9i8CMd6CmQkIBjJxcB/ixA0BFAU0D/gjw9/aczI6MCB18eMs60mEowCuPg6DpkX0FfpqDdHUEAH7klHIfcKj4jLPfX1Pgvjf6CeBdHznui/EsVmVtDc/9yuT0/ckBP+UPBX7klSoAa/5d8RoBAOh5P3PvsdbjwP5/HhlVmUb+2iJH8LzPR6hOKse0QOnpflDpvfTSMAdgwYhplkJku226ZZz0tbYRNHOPUSSAqcHgq8B+KdpxecbIH3SII9Wy4tqx0OFkFUUAIGDk4tmyP585e8/P/AD4GXBG4siRr4ADcJFfMOJnjlnqGQG/9p76wms+5UXp5EcAT74DPwlG0fPkIBKgAM/2VNfL/KB9ASbEO4AvwI4c+pyIRzqUxaqE/3jvdrb4DUcaeUb88Ar0pCu9scV9FwO3+al+jt9t7cjxOYHLnEbKJwLA+8MiPm3vKF1LObD/X0Un/g5bQPuDOzT9wpjCE2UKgAunD+0/D2C/s/MAhi0GBPgXMrJjuw+OAIUTCSAC4Ol2x90RwMGgI0bwSMrIf6wDoECuQB9VZ6icz51jyACURUwk5yhADUAonwd+HpdUEvqU24T+LQSgOGWHAABAAElEQVSgwG+CCviM+ElnTQgvOA8jlPIiqnoqB8CTDg/AQwueLygKwfPGiF9fJtSU6v3X9uR5dIdPp7iIqDFVoAUIz8if5Ob22Htsc+PZ3QgYyFDdhqfiGqLnLH2NBEQf92IKgPcAQKecnUXAcrsBkD+WTvv/j2250/R4z0+zMmkfagH63Po8gI9++Yt6DcCTz792SMfzhp7Y546ALf9nRIIj0IYdN9/5wcjlriIBnQKCBDpyHAEo4uTDR7QD9IFgp6Mzh8BHiD7STouQARDMKdCQPpRG+oyY3I7VQx05z7cfAD/pC5sK2IjDSH6O8tAiR0exuU6RqOLSTrQTIFdHAP0OxSEQfeSYIoGHamSgSW/PzZMO9ftKQkBzz5mP/C1ywHtGSByzWzu6eGEHGpEO3dl1A/DKIwfdlYf+Vn/z4mWSaroxT36+TNc/Z/tPS6rUk696SvbRbOQ4DDVIxODNN9/824E67Aog+DK0axto/sUS471+sa76PFfLg3mwTXnQv/rV3zxLLRb2tT+JEBec/DW3Az9yhR/rCHAioC9FkEV+BausNN0qpB2wOgJa76GAr3rKq0Pg+QbA3ttow5pgA+hp5K6RAABNAYx0IgpebvBjaeVvbMSqwB+onZzsDoEBPwY39tloHIICB8EAB73wpcARwKBRIgEk6xoA0s9F9XwJjbAA/HdVPo6A2psvUwsq8Ktcjo8iADk9HfkT+s/pRfn6fuPw8tXUSK9M51E6IDJljW2B8L0ca2iSH9YCPOjf/OY3k0KwHVCtsVuAc2RYFKhTAugxItjZd2c1EtBZExB0xPNghKYRgr3pL0R+C+BTsZFUOwwcAk0fafZocXUUGAFhEEeAqeWhERz0oYyM4FkDAPB7+n3/AOBxBKDUA96eg2M7GAV8dZyi+89iv8IiH1s7W9/TqadRIv9+34iYsOjW5NTR0jU3Yra8XWk8EE1l5AB/t0kt59S+IhjtBuA9l2p7tYgAsPrfM478wfUtVykShJnovpAfvb8D9v9jQmm0XFblJr6nBQ7HzXoUpiRvAW07Bom886yq4esc9Ru9Wq2+WFn4m/KvonPbFgTAR3hJfqVT/S1tbo45v5Ta/U+IUHN2ewvlWoavCVDBgM8dIDQzAPCIACN+vUDSg3I0mRF/Z1+8CsKbg8W+br4dEH4rAHn02QUAH1B1BKIRfRQZaOb++wtgpERuNCXA3D9TAPDo3TVdFU9rk+vi11qmWQPgUwKt3OFMM5fOa5V0FWgA8IV89bKMfbcKA/AJ5MC3hG4xetAUWboWgPSIrmztgYb6lY/0AX6dCtBtgDr3jz0cAHh1BFgLQD6OLo5KLhKAfFS+OrTcv5ubtAjxC1/4wteqsqfP/3IH7oe23477KfOTUgqepzoCXB8hK9q4puWLUk/mre3rXZftfg1dP6NfR5AzOzCksNXG+sKpQ8AioZ11jD5SmCXPHSBZWqhxqCOwsBh/5Ag48HNFAP9IwEcd4I940kNqwM6iJlb3Uy3V61STSI3dbc9nkZQgCQ6B3j8tBx6gwxEA8LXj5H6hp1SBX3mVd15H9p5x+AfAr4DP4j8e/sNWmlwd+Rez9gsCcDQa6VcH+E0AR5H6APxL+7bFxuYakNvb3D0nNzIVQHmRQ0C+UoB/zZyY7xJI3QcAq3rKs2tguWyvAVC5HK/Ajzy7AZYrxi3k9FMiAEwJ8JxGI3ys6P0r35MfkzfR+2+Bse/n/dfwE1rivti/Y5f25b5LjIBjbyeFMaJV3cghYHEQAyIiA0wNbOxEwXk7oldERwl7uSyD9oTghyNmkC/JCviS3bAyYgfQtbglayP2BGoaE32/3E657Or2n9q9ndf3m8hNdD/7dKo0OlTyc8CPnFIiAFlHgNC9GhA+GvEz0kd8YV+z3O7TZ6tJh6rDQ/rMgFk/8gRwzCwUvi9sESJHYmPAKCNWniPuovtp5r7jCOwsY7G3RZ8s8hMHZOzIH+DHEWDEvzZHQNOHOgRyuSGrI/+tefihI2AeEiP6yDDAn5OL9Elf36zfr36XI//0MQMy4jn/adFf00Yn/5ocgJObcJwBHvSx5wEQ8t8I4OVKxyHIRQYsAODmABzOIYgcgVwkIHfwjxcoPxiR0YF7NtcvoXkAGznldwOBH/2QSrkqx8hf0491BNbmYK1sMSK82odXwNd0+GMpI370Lw3orw3o6VAi4EePSAe8j/yjeJoJAvzoKQX4Sec50pkBFqNu7LPRS9keOLe5mrktuuF94GhmjyRZQVou+aTjCOzswby08kifvZwufM4J+GZXDxAa+w2BoYv/dAqA9htKo4iVOrJq7x9//h5n/2vWxN9DC/C+3kNRn/gi8EzbMcvGk2UtQN0Q//T06d9VPz7zmU/X5wHMfKTZ3wPmgF89fV58Wh1HAB6HgMhAsUwjn+0ujZWWOrKXRYI4BHuGVCbfGemCxMHQGYCno4anniHFETABzFNcqDcyQ+3xFbgoAuPmGTnaiNLTgx8KiIgB/PA5ykgfOXUI4Mk/lSrwR/YW9nxzgiERAJf3x779+owd+bu94AeOGNlEAODdIWCKwIAah4DFrzuLEJCOPhTgV0eAfIAffnnjDUDSQbq/SO8pWvp+H1Q+Q2YO6KMi3377rR9IHlOnkjyx52iByQE4R6v22+TBrmOi66unrX2vnDnPC42JToeVGYG6nm3DUkeAfKU4Aszps8oZORwCHwkJwHEkKgsjdBHcTkK1AD72HfgF2B1oLV0B2fUpmIQHpiw2ZK2BRwa4PrmPACKOgI6oGPnjEMDnLlMdAuRJH+sQjO0wym9e1EXebJJ/jCNAPSK6mKXXhRE/dCnPEfoALjxU3x/SI6oOgQI8wK/6AD3p1Aeq+chB1SHwELsJEAlAHspuH/iIej+wSuMQ3Ky9Tf1FeudOZ1v0gHLYZs1sDgOuAaqTSNQCY9/nyM6U3rQAD+rBtuXB5zwARs5zji5r7NW/yGctkWSHLJEB7wBMkrUEvqhQLPjBQja0dofAtkktLGbLNqtCRrqsylZHgDlDFmNRrKcrkAvwn2ukTz1yFAckFwlwwBeDOAZch2Q7qyOqscDvhuwHQK/An+Oxw8Os2+HI7+6TTwB+bRGlSzPAuThEAnAQSMcegM9zFC3yQx6ABXA93Rp6rCOAPjQCfvKhlE99SFeq+eh15HRqIugAiOgRCSBiyNRhYcDPOSBM0fA8b+2FjI4+1nrdFc+26AP2GDgdEJmyjm0B3utj9Se9I1uAB/9b3/r9lgWAgReTTDqwjXQA/oIjaBTAxwGQ7CICfuT0aGHSFTBIZ7X2Ys5UgufUPwB4UpX3dAmB0B5Rfi6d/BzVcnLyHpkwwQjw1U6z/VA9HZVMPCN9HAAo6f1a3VTuGzljeWoL0GNHHYJLW2zJVBIHUwHwtsi+nBhLA7n9Nt1wfd6xn6MApwJqTm9sPpEAdQQ6PFMGVgDto+02tnyXt6m4tTlWM6PMxOEIsNaA7gLg50CwlUVSos9Ue3n2g/alvTX/WP6P//iP08fRioLVDwygciZ5JHNyU/6BFpDu9oDklBW1gLYhISqibOyrwdmqY3DHngdw4auk29WJgB5HoC1dlKv72+/ZjS2GmlloWiMAM+u599aTc9Y/I3wHQIsEyHlA5bdtTntfAehjgYLrxw489jRdAR75iPr1RwKSTkSHkwDJZgoAXqkC/7EOgdrN8QC7j+gN6OEBOEL8HFjEyYVuX45YJp25fngoEQCeH6YAACIFJhxl2hc7pMMfSxXwj7WDHo6F80wRWQKRNwKDTA2w9ubSdhMgx3uLPSICfnAY2xDNY8AR15E/6dhhdwLtTj3I110F3E+NZJG+2W7r1f+fe+21r1c2pv3/tOT9UkDpfkv9ZJUGsqkjwFUSwmq1dfmCDToPACN0YEQAdOQP0POir3xZdbLQCQli2CiOBauMNQIA8KO2NKBf++LFlMOIghEJwEhHjj4dOnyOAtQ5uaH5WXt0xDJXH9mnw+R6IzkFJk642wx0kAB87KtDQHqO6pRATp45/KUBuE/9SIgaOw78HKVMOxodOoFL6H8jzxnlKNX25b1RubH8XQM/5XfsWvvM7IAw5MZS+oE92xnNAPv0i8Hb/RjPjK3BYfnNel1/FfWw1JR77hZogdK5C5vsd1vg6vr6/6tSL1+6/EpFZ96hRv5EJVVGUA2g1BFQ4E/SzcmB6M1sX9TGFvMpwOuIHzs+8rcOGTtsj0IOGgGjOgTIj3UM0BtKAX5G/PCh/khHILKjwBTJ6eK/SA7gxyGAj+Q1XacAyGckT+gaIN9wgITt9uiM8DlAQnaPMHLdAuCST7kR3dj2TUb+KseItJOevbGqcZhnpN4B7MNq3VwLjbHoFUcZQewTPiSdg7XK80PqpAiWeZ+j3oMROO8l9o+lagf7OXsff/zsrUqGbdG35Bkw3Uqqfw71GVVv4g+0wOQAHGicI7N4UPUd5sGupwB48K+uruoX4dVPf6p2AKrvfVV/ClAACCMaBX6mAIgEaN0d+DlJ0AQYybMvmpG/OgRqD37vXx/jsslpUzqK3AgZx+C+HIF2LWOumbtPMizmQyN3XchFlF0ANhPDJx8i8YIDnAB+eEKuufTIcVgYkPtuD6sB9suzq+sUdoXAe0VlxI/D2FxfgiZGojnA4HnwI305q98LbP/g/fBUPlZ05NfwAGS3N/KHLnZlVwhmPJ9thTbF12kf6RA4n4PQ/94cVdqTdiDCxHNBueemWh71Iv29997/udSB/lGSJ/acLTA5AOds3QG2r9fr9waI+dHAyALoOAIK/HmHIHXkQ4GecnOUDjsH4DmHADtReTn7qoe9sXrYoUPFEes4BDaUixwB1cculI66dP1I6qVECMh0YLYEBX4Hcht5I69y2GMRZ8FHdKyHuDYAurCpip0BfUdvb34vgGQIx2d0iQjoCJb7gz1o5yM+RCKYUjBB2hc9p0cCv+uf+4cAvxbnjoAt/mO7rcrxPtEOPE8ALgCMnOrfF8/z+847b7P4776KnsrpaYHJAehplDMn4en2ngdA2UMjmDgCvPDR1wGxCz0W+OnAWawGoNKxAIB06ORTLnLwUNLRVx45aGSfdOSgWg/SobQ3Ay3SldLB4gh08y2C40O7tkROH2lGzPCbXMVMEIBHDx4goAMmAgAP8LOoj46Buf5L392RAJ5zHAhlFwb8jPgpf2EAp9cT3Sf0CtPDHs+d53MwFSF1ax/a1+XsB/eL07jW3HAVFD4X+uc2E8p3x9AcJT2AkmMMSJ9xX6OYvdRnZ44cEQDJ7rCPBfi1YmyD1vQentXK+IyHPeQeA1NS3AK857HElHNsC/DgHmxjXgTOA2D1PB0K/RQdGB0cgE/lIuBnMdCSz6OicE+Ujj4C4AjocQRy1czZR39mB57s9/hf5LQp7d1ObaZkNB2ejl+nBshXyn3kvupuAJUHAAFEzc/xAD1yyu84W98iEDgEDuwWEUAf4Hdgt6WwNv5H7HiqiyLNIcgZpD1pX+Th29/AJLe8v4EDEU0BAPxYaPjUEAA8+VDS1RHg+eF5jvwBpty29hEj1gQsi/Rc7+wjX7bYn2LPRnEsxxbANugDeodf1AOKU9bwFjgITsPNTJIHWoAHubeteRG++c1v1ibYbqOLAenAKAfAB+ChpK+tg0AeyiKh5oRicsZRByTbaAzAR4Ces44eHSDbFEnPOQToNeUoFCV/DEegkUvp1L9rJ0nObfEVAzbuB8DR2DP5zJQA8tiRXZm+bTKqD/odyiI7DdEbouBAAPCM8BnRs28c4Mc+9xu+rKD/7P0BYOfkepXLRPSj/Ey63hfauRi4JqDRx7NhANouOAJ0pHzEbmtltnyUwPbv8y2Nwp4vWlWHuXydkHbneaUc7uuMyAO7LayC/bVHO099seV8QRUjH+WgMaY0jtj/T5NQ/sFypsxhLXDUTRxm+oWVok1550CiYecBWMdNiA8A8A7MmlUjAKz+B/ijEaWf4c/JLCNvk5+AZ3paDh1TBNjkjyzWxSO7+vU4Vxj8g4BNv4LWG0cAaQBDIwBxfdFMFPvIw7elGo4OnxE4UzKNxOFfjNzm9nU9AJ/IAPmhlQjYFbgjudCwZZidjuNh2e6w5OzccT6Rj4LFiKxJCMrZ6oOictJerHngvUd8sUzdysxH+KlbQY79/jjMUD4Hzv1kSgC7yEE5DwCeAYk6AGWkrK7QtP+flnw+ae+o9Pm8lEdf695IQPmi1ecBXN1s6q9ivfLy6rPVlTAVwJG7hBjVEfARf7kuvPrDEbgxPGORIAf91ELlv2ibnwI8X0NDD8DHAYGSjhwdCIBG+t1R/KpkcW7nHuz4vrvzDBySfCPXTu86EG2HgI4WYGaqgP7d78uRgIca7cliQMpj5KdAr7y3r0QCAEyAoDnoqN2OTb4MtASovBzSuQDPOPGH2XNHxxvcQt1m3oHpxOL0QCHMke67EMjIAT/Nl2kffa4w33wbBEPk9FOeE70NRNK4y3z2WB2BfqvdVICf9xuJyJ6mX19dTYv/aLRHQCcH4BHchKoKN1fXb1f0lZcuaweAfoNtgVVe3x+hf/LYDcCNZZEgAIIcFEcAoCZ8wYmAyDWOQQJOAL8BrCQZdWjYGUqpT6ej8dB6stQAOjVP6TgCCuxReulyWdXoKofWVORGnhtA5ID2xBodeoe30K52rMgRYoUvzBHA3ZmbJ8l9crngB/Vot+4tYUWcW1m9P3mwx+r5GpZ0fzggqLeMA4kO6AdkbmfhYNB+OvInpL/w7bC3tZvf7lg1Sa1ffj94flq55QSBLbKk+STb2Vy+C97xD5676LmkuOvrm9oBYBs06SVlgHQrqf7pTa8ZE396C4ATp1uaLGgL8OAqovCgt84DuL65+eVtA5yoBxAS4gMw5nYUqwKwA74ZQ55IAWsAvMMxOQVaIg7aT1MegAW9Xffbv7HLdWi5yAI08OjBKwX4SVee9OGUGRtGXImn3tih/p36GkISEUBeKfdD203tRQ4b9uhw4bHH/SFdKXpcLdejcp36qEDE88BESES+6Ws5UX0oToEfQAeokTuWYs/1baTvQC8j/wWLS+WI7lmwBsfXkthiCwIbXp78AFCZWsq1j6gPZgn9D1Y4UnDa/39kw51JbXIAztSwA8ziCNTbAX/x4Yc/q3SefPHzv1dRFv0oEHqouRI68Oehf+toiBQwksMhIJ0DgXAQKFcBkP5bgQYAokp0VHTwkT3kh1JG8AC+8pEdlcvz+G+RxZROB047Qblu2ov2Id2tImAJmg9gI48deKW5fOSJ6OAIkB5R6sV9jeQ8PXNdLic/KEeSi62dWIkDsLURN3wHuNXAQF4dCZ6ClZ3Q5LhuDs7egH/GCU6UI0dxk1zwzQ13GHR8kCRp55mvuXMLo34M7S9GGe0RxlHJRTq+/1ff/1GPel8SITkeUW5Fn+yUdmQLTA7AkQ1312offPDeD5PNf1YTjQA0AJqkci+2RwKkI6beAD8dLsBPfkR1YAfgqXzjIKT3lnJUbizfAH+yS7vk7KCHXMTr9WBf07FDOpQ51871yn0gXwEeu0oV2CkPuVw9VQ577gjojUUhQ7mOjJhn564XIHEF+0E5fBOA6uIIII9DAB/RDtCzap4DjOSgIV/8ZwU7QJuDDWnm7ql4P265w4BHHlSU0D9xqX53odo0kTFk9mlfnhdoUPzo5GgRKY7B++++qycAji5jUri7FpgcgLtry8gSnuzBtubF2NiQYlnYWGPYex2V7ScIEnpGkA4V/lia70CGOfB0YEPrpeXqiF6vJ5ePPHYBWCj5UEL95AP85HcoHbQ4AnTIyOcAEjko9YWHkk79SIcnP3IEwnpI/bGLHRw/ePKjdPKhO/8WRoI6bR/khgI98ko9YkBIH+C3x3Vn2/R0O2IuVE674Qjo0b9aD3ief3il3C/Skdd08o+lp9oD6LV8HAO2PWv+LZ7I6K2k6ee5WuAgKJ2r0BfULg92b5vzYvzLf/UHreYZ+kJqh9sycouJABZguCVa/6T8KF/lladeUbkqT8c2VB59HdGTDs3lIwfluuE7128jRK2n8ujn5vSRA/AAEtIB0K1s3+zUyxSidOxpPgdPERFATin3U9Pho/woHT3o3kPkh/1mPhLE8zLUIWDk7yN6K5jrpx4Nz9ibnAxlEV/gKKFNvcPnBcGBlOcDcew731wQSQcp2/8OCo3IxEE+sP+f/lGtEkIZeSPUzMT3tcCJ48s+k1OatQBtyxCY6F3veQCXl5e/Xun9xV/85d9W9PJyVREfgABIvNi5KYBa+da/XARAAeGWausn9SAx0qP/o+Ono4vksZej2MnJnZof1ZN93TsD4j2r7KV7ytWTEZHWE+BfmJu436bHiHTaM6qf2hvK633N6Q0tf6xdLRdgwzHSfHi+Fri1RXkLO/ERfmUnHCrwo6/4OOMrmfbZa6bkcvXw+8ScAAVkKO+1is3tQeB8ANoTeRYHkw5tzhFJ3Q8jc+qPHJTIBjwOAA7TXI4WHLv//+rZ1TvVtT350pNvVLTcBfCripZ/14kUV0ZxBPAAJwfAGuYcpHc0eo6CJpveAjzgvW2/3e/q8wBK6Xo7IB2Pa5/4IwdMmAfA4YlgwytFHoAin/KGAgZ6d0Upl45N7Wo+vMpxHc3++STBVADyLkeCUhrK0gEMAJ/NUAz0WWSoZu6a57ppJ/hTyxlqh3K1PABL0+EBQvbp42UXOAIm6JENeZDRh+4N8Bv7ySLePOlKuY8e+lcBeNYWWKSgeRySB0k9EG+eN78yz+r70dHvE7qHNL1v6/X6rarYnu1/91CbqYioBXpBKBKe0s/fAnoeACXSseQ6IuQjSgcRAVXTIfVbiDr0CPjVCh19ZEflT+UpT+1o+cojr+2EnKYjr1TbO9ID8Bv9NABqHIx+AOD6qFejf9yvu7IztHTqP1Se9lR5IjOMlD1fAJ90tQOAc+6G3icArZHDUqK8n57KokKJDGk+PJEOeKVaH/JzesjdFR17vyi3jABEi/8YECEKZeQPP9EztMDkAJyhUcUkD7L24Dz47fMANpufmv5vi52TWKYMdCpAjWp/iUMAVXnlo44KuXMDDB17rh7akWm9VL8B4nQlufbo1CNQ8JG/NdDWzqpf2Gds4ZuDitouIPXW69H2Jh958u+KbqzeS6u38lE51If6RXLZdH1w4Wl3eDHEfeZ+SXa5JLE9BUM+6fBOBfhz9l0v+mGRgqh+qsZ7jmNA6F/l7oun/Ov19fekTPo/SZ7Y+2yByQG4z9buL4sXIZ0H8MEHH1ViTz7/WktaPw7UynwAZuiIX6tGR0/Hr/kRT0eq+XSM5EORo5yh5ao+dppQrKfUPwjR4yBofXzxHwc3mboDO3e/bba42SS/Ub3Gme8fTwoL8yD0foi58mt3+KEpR9tDedXP8QA/csqTDqU8eKUAmKYP5gcCP/b8vrOIjwyjYT4hfZPjOXB51Tee58TYkPB8IRDp5doT/bumREYiu6x1+c53v9v2XCOFxtNl7VT7wY31ppwjWmByAI5otCNVWNRysM05D2Cz/606eFh+A6R+cRhx8Bbt+MoX3xW1StFx5gBBr4EORIFC5U7lj7WvHR8dLJR6YZ/r0XR4qOqTjh3lkWdAubf7MJun27vnpBgAyIAfO0q7I/0kwUd+kOfMf9K5vqH3GXmuC4p95Uk/lkYRAOox1q7ffxoeA7SzUdoJ+bml72wkr4CKGWi2HQTw0YPyfMCfSnEo9KNI2o7Um8V/Wi5ArXrIRenkH0uJAPzJt7/9/WNtTHrna4GDYHS+YifLUQvwovxX//XXI5HedIC/N7MnkQ4y12HR39LP9pgalURHQ4elfM5Yrr7oYx9ey4ns0OGip3ZIh3r7kGB0bl9vk4G3Sznwb9Pi521RB4CKxewmyezTLpBilr5gv4A3C0QC2DbnhuUH1y3J5fkQaYDF9Smv8mP5XARA7Y19fl2fG+AJ6Qf3F6+bz+3ycSGef1ErAzbtxXjwyKEXpSOXo+hjL5LPOSyRXi6d+83q/5z8sflEAF5//XUWN9sD7h/fwHQQCyN7oudogckBOEerHrbJg35a27Na2TpARoJ0pFCqQj5zhLm1AOhB6WfVEaAjQ04pAEN6jkdO6dhy6OAoDxrZceBnDjdCbq2Y8TM7mnZv280CMU/2KQAL6TjwI2HAD8vIlggA6UMdAeRpF3go7QOPnKaTf1dUn9NzAxL1jp6DXH6kF6Vj73mhbP8bW19G+qp3c5Pwvnye6q+ean7J0x9qFqH/aBmlyk/8ES1wGggdUeALpMKDS9ReL50Hn3tQvykclPE//I//U9Indqfatk+Zz8Zqdo7HEdgyJAoUFPBVjBFMrgNUQFFe7cLn7CIHjexGdjoAx2d0g33cQ6+X+ijd2og/MF9s9ykSMCue1qr74tdqCl9+L1JNnsTTXjkjyNFeyqu+5sMjp8AfPebIf1Iozw/XQztourYX8vdO5/4xgqgfO1il8nn58UGBKfNBWwDwedBKTIV3W+DZ04/+c5V68eqnv3I7l5EqoUE6EGSatQLJf4AnH0oEgI4nAsho5I+dSI98KMAR8aRDz2UX+1of0ou5LbsbGQFAfxZ9BMYEFkV65bbBwIdIAPcZvihsSqBIUwJFMCWgnz+mXkSA4CNKuwBAUORzPHJQlScdCvDzHJLuJ2B5wnE/sDv0eTqulOYsfi0nVz7vV6fcYDGiyqFPO/Ney7k9qnY0H430I4Prm/X7VV7P/n8GQKrKyF/TJ/4MLTA5AGdo1FNM8qKUc2e/MDstBwDg1zIU6P1FBcj84JH0fhEBoMNQe0P5qIMDSLDTdFCH32/tQNE/lqo9rZfanTMFoBk53vRszWb50TcCQKbY3ejfa3G3TwA/nyXAV367Sa/sYtnvCKjRCPhpB70v8NjJyY3Nxy6U5wd+LPCjr/cZe1E6+Tmas4/+2HLUcceOUsrX+0J6LkKHPdUnPaJ+AiAPdCQo6Th0JB/Y/4/IRB+wBSYH4P4aH+TTnV14wvV5AFTnn54+/bvq92c+82k7DyCpMTLEEVDgR/+hKR0OAAHVeo3tONGP7JHvdlkrYRmsCkcuotS/4Khfidk3HbABPSO2zCrxaOQP0FMf5UlfLO1xkQgA+UOpX58pKE/7ajp8lK/l57Ypcp9oT9XP8aoPn9Mbmp+zp/VWefiuXOoOhjoCQ+t7Ljnu+1D7DEDe+MEbPxEd+jtJntiHaIHJAXiIVu8vkxejngReXz2tvwmgogA/6ZwPgCPAyN7zbbU3q3FJjygvOh28jjCiEaXaQ1/T4ekY4WfB4jmOQlZ7s84i4mRptk0d65bYKAXgORnPiW46YkFcIwFz/0odEony9Trav52b5yKgV03k9tuX66w4xMu6d/Uz1eJhXp8DeLTguS/wUOR4XgA6ePK5Dwu9XyagwIleRJHX5yuSz6VjLydHfk6edsjJ63uO/PNC6W/efPPN3n6s5zp4cKf9/z2Nc66kyQE4V8vGdnnQD7Y9L85Xv/qbsaUyZyjw4JHvDSAxisPATIF2nPTL6gigr/Kk56h3lDJCV739LoW6dQUSQK/y8AupMI1OfgT8ABJySqN8HLGdhv7VwIm8RwCwk4kEADgKvKjnqAJ6DvC1vIin3Og++POB4Ega6evzGslpcVwH6VF7qhzySnmvNF3bO8qnHOzQD/hHjCxipe+N2juWj+4b9uhv+Mop6T2UgU9P1pR07hY4CELnLnyyH7cAL075eeA6xrzc2XdSbWigIwSAPLaYcug4og4sp6/5dKDasapchxfgn+GBiODCOrLdViFcBIXtyNvivpmF9Pcs9hM9OjaVA/jJR410rt+nTMUBQf5U2qwB6O83ORcAYKA87js810H9SY+eC/Q5aAY50tGHarryY7f7qT7lUA/4HJ3bfUcvspuzo3ra3tx+0nlPIrsO/DaVhDzp8Krf5GtO4rnP5CI/tv3RVwrQazoRAHY1lfnR/n9VhZdFNCRP9C5b4FwO4l3W8Xm3pW1MiIsYbe/ngVer1RerC/+Pb/zwryt6sWxDPh0ZwA+tZA/98WLSASLLYkCAjJEe+XRoDZ9C7ciTDlX9QgAfuQj4C4tUEGJHPqIdwDdBPmMa5Uf2ttYRL2xOHx55/Woc6btNG5i33DZbBKhrAAjto5+jLA7UXQDoNQ4Ajxk5iQIICvxtqfEcdiPNCMCiRX/IK9BG9k9NB6ixQ/nw56Z89pfHhfIdsG3OR9PJJ53PAwPM3BfkoOoAkM45AL4I0MolX+3C0z6Ut9lu69X/n3vtta9XedPnf2mhx0WnCMD57weerDoClAxitO5F+cKlgzO2m3pD+G6++lSlsFy2xLAxmPoLa0sS1RGgI4kMIh8Bf0dPgL8D+DIlMRTwKQdgX9kc/doiBs5b5MB5zTcee1CAX/mdOQR7QwwWFe7gzXFAbmFrD8bFLyj17qkCPx12rqTcc5HTj4Be9aJyFKDRU3meS01HPkvtfI15MJXTBOJ4rdsWO45vO7vDAawR8B99HVIS9aI8yT6Z1edos15/cLLRycDZW+A0NDl79aYCnn78rD5C89VXkwMwtkV4MbXjVztEEIgEaP5Qno5GR/wO/AL4andOyD9YdLeQ7XSEUbAD0Ec86TgK8EoBcEZCmg/we7pcF3rYQS53DgBySn3krxnOp5YAKLnvni0/ssCCIdE7ls2WJ4ajkX/OTi6/yAB846j0++sAdbnzP9VYdn0owPr7YNfn+ewakes+mvWDwcZZ8PqMU8tKf/zxs7cqIbY131JgwHMrqf5pQxJNnvhztsDkAJyzdftt86ArdvFitD4PfHV1Vb9Ir9qBQDubw2YKQIvIdfwqH/F0DHRg4IFOBUT6pA8FfpffpkV/2jjkQwFwAH9nAEwIEx550hcWEYAnH/ktIU/poBXI0YPmHCiNKGwNPwD2aCqAfMqJQv963xUIGRm7HW6oJ7R/qD65ETATGUJuKI3soU81o/r4c+pLZPpH5tiTzSAljqepkgbYkRxGd0X/VAvatgkHtkNz5XJ9XL+2ly/+61huJ2CnndpwvA84rkydoeeRw0bl4K/33nv/5yJA/ybJE/uQLTA5AA/Z+gPKvl6v37stFgE/Moz0FRDIh/oLHUwFIDeU4igw8nfgH2hgZsA/ULw8F6+/owfI6dDgodhXnnQcBHgoDgkOgjoEUeSEDhWvD3sX9hVB+KJIx0DcyNcd8xEDatZY6vsFgGhelI6cA5Q4RKrnX0W0+wIwYQcaOZA5oEefEXzDJ0+Keuae+wau+0f4bnfgD8pVcRwNnTIAUFWe9iQfXuXgGzlSHgelX3nnnbf/4XHUaKrFoRaYHIBDrfMweXjKB88DuO+q0XF3RpInVkSBf2vft9dQf1SMAjnAjzw8csojp3QtPfuKHh3Bjc3qLxMAEwEoSEfOaLmGI/2ybY2SXQD8XcfAXIeZvao2N731EW8CshxgeHmGtMgDJIWEshkZI+f6wY9GzuoTygUZmWSvJ47fiSP3THHlTEC/gzD2+fe1BOJA5cp/rPk4WAC91pNFxmxj1vwenuUx+GbqK/eoTEl31QKTA3BXLTneDg/+wXvAi/TV3/ytuoTcFEBUDV5cIgShnAGf7sqjP6RjpCNsDuTp7zBZzR+Vp+kR8APgKg8PsMNHNGcHvQ7gW8ZubUfwGvBHgI8d6NICFnh3pEO7wJ9ydPcB8nMcAhK4QfBCuW/u1xggcX6BfwNB9B6abYA/1UT5qH5cb5R/9nRxqLzdTyw4us1zjxyBoycWdKI625gPmIlehQMqU9Zdt8BB8LnrwiZ7vS3Ai9B7L3iRvvWt3zflYSHf3pJ6Et2TD6YC6HA9xC829vYZ3MLOsJfskNWRv87pR4oAPUAOjzzp8EqRz8mp3lo8IhwEcwdU/M54XztABMEtpxu2samEReB/7WyxGgDkwOhI0lbkfkdHIHvx9/1j5AjaHVS/zvuusJU3st5RLXNz/+T7+xwZsnS/zxm5XDYDC+QYYByx/5+Rf//cHgVM9E5boP3236npyVjQArQ5rjqI3nsewOXl5a9Xdv7yr9+sP6vJeQCsBSD0DKVMfTFJ5wWFhxK6YzEXc9p0pDgAUE0P5/5ldTzlbWw1fzSXj5xSBW4APSen+WN5HICVreqCH2tnY47S0j72Ax/aAfiZOoBXhXnyHwEA7g9iAL8/F3Ygjh95nAEq7jv2zk07ACX1w6GhHlwffI5in+vK8djTdiUd6jNFEgFgSiWqN+Xrdfi+frt+/JmZ3b+leX7qAHCfsQtl/z887w9rVbiOaBEgdnnO4NGb9v/TEs8H7R11Ph9V/8TV8mAkoLDzAIrlRX0ewLFXzwsbOQLH2s3p6f5+OpjyulqqRAJYjEcHpcCPUpROfo7m7KN/LuDPOQI+dZABfuoJdSAx4KDDJx+6E2AlXSnfQiCioPkRT7kAbSSn6cijP9dvP9g4UR3fnUVECInDY592wT7pOR45pSHgI2i7dtgs4PLW7kRuKJ/rjYCffBwBimHRZfRNBeRy1N9LE4yAPrJzfXU1Lf6LGucRpk8OwCO8KX1V+uijpz+t0l+7vLCvAyYpRuraEfbZqNJOBX5GGp2T8GwfMovbfRfAwgIdmaN8AX6t91iAHwroKseIHqDXemg+vMpFPEBP/qd2V/XPq4UtDrQM5DQy4I6AxY8YsRE31Y4aR4/ykINXGkYCgq8hqn7EA2xRfjado5stTjbbEUBLmt3rsqU1hpA4Aj5VZQUykschAFij+iJPfR3IbaQf6RGpQw+qkQDKJz9L7X3DEZiJwdH2pMBIn+dMxJ29vr6pHYBp/783yaP+MTkAD3d76LuYAqAmDIlb5wFc39z8EoHbFOBXRwCgVyC4rXvoN3a32/aUHFMEqksHyVQ5i8uij/Zwgl80olSA1vIiPnIY1J7KKfAD8Ll05KL6kA6gA/BXi3pmp9B05V1fgJ909pkDZKQrBeA1XXnkeH6i+6N6d8UTaVB7O9tdwcduZrv0msx3zKS1NXZ868GAcT5nzW2S4yuTaEUAznOdk3PA9BF/+7X23QAYCij3saFJEPt8i6EB/nT90XvuejwoQbl3lTzt/7+rlrwfO5MDcD/tfEwpOAL1dsBffPjhzyojT5588fcqyvuswF/lVX9hh2A9Bx18ku7+x243p51CB7ML9vHvLQIQOQJta4Xv7yci0O5GVTrmc4Afa6YcBX54AB+as0M+I/6PDK9wBKDIKU86I374gnaVE+D0vhPid2BnSoCRa8DvDGDd/dM5bSpi+gAWyUNpB2A5R8D2nTpw2lqS2TzViKOXO9sXreC5udeM/P159pFycgjIj+qrI/1IjkWT3v7Svp3zC6gnL7LwRNp4vxrgTw9QNBJHHqr1JZ33Q/NP5Ufs/8cjw4NjQHRqFSb9ES0wOQAjGushRT/44L0fpvK/lghn7AsAUEcFeO+YELgj2mwDTAaV12I8oGAjNL66hxzADw/lAB7WBpAeUR3hqxwArsCuvOqN5RnRM+LXF478CPgLzhmwgve22E/rEd1fgB/5iOd5ceDn4AcDZAdCDBklIA+QMzJFLHQM3KHAQtLATkH5hJTMYLnIDNM1vbxIH5l7dlP7yWVzJZcROb3eWeli1n84AvYecWBPZ7Ge17NVbDmXlupBhMRRzB2X9nWJduecAdqpoUkDHn0Ffu47wI4clEV/8BHVuf9ILpfOtuWc3JT/OFpA+6PHUasXqxb0aAfvxfvvvts6WpMOmX4sajI6iCh/bDqLljY+NGxbYM6T8wp85AUy0LG31ZzjhD1GvBzIs7KOX4HbFe3HsfkAP/bg1V7Eo/fS9lf1Twd8W+1PvgK+Av/ORoRMwTDSJfSNHajeX4A8yicdijxUAdgdNhQ6VAduQGESdHsdvQSQPBY+kuf5sAiEWi/YDWH2nl0BtGlDJmGzRi6dsEh77i1Std+n1w3/meetsMWGO11rII6Ifwugc13tBI9gWHIH0K0BAHClWCMiAK/33dPt+RkM/BbBQf9Uyrbl0g6f/1WTfos0Y+LvvwUOgs79V+eFLpEXo/ee8GKNPQ+Ajv3Ulp3ZSCgPCO2S6HjdEbBs6kWIH+BHW3l3BFhOjaBRBWayNR1gj/KRJx95TVceeYAfPgf4yEEZwXIAEPBGftTxNw5ZWwNH0fV9RN/24ByoDYD9PgPIGIAacKEHsEVrRBq15CAgh/lo7h+9ofRlIgLmGOy216YqjgARtG16AveFuBoWodK1AVyv1gdHzdOZWgmmSAB65HGc4VnVj+PnPAJG1Y5kOztUzhVG/tiag35r/z8WGODQv5EOpeHbDyS5Ez1rC7R7i7MWNRmXFqDtGTKBhXd6HgBlAhwAL+nQ3DkAe75nb68pHSEh/63s9ycCgH13AAyRqIfuJuh8ZQ8DEbXFYQrU8KoGcGu+psOrfsQr0EdynXRC/DISwwFAnvsHz9y+ApSPpBHMUO4jIfcs8Ks9H8KnDBwBFcvx6gD41Lg9V+4QSgTg5ZfSA9lEAg6XNF8kR8ClZC0FIX3PD354u1k+161AC6/5yuMANHP9qXvAgWYNATzPA/ahVFcjAOTr3D+RD50CQJ7tiNhlCoLySb96dvVO9fvJl558o6LlLoAUCit/Vnz5l7a9FAWOAI7B5ACk9nmQ/72jzQepyVQoLcAL0ntvNjfX/7kSvFi+/GUUKsqq+yg0CeDe1jnltwO/hErVpgO/ZeTqwUgq5wj4SXwGoOvgDH6AHMCH5tL1OnK8hvJz8vNVe/vfnlXrKNrQmI6WdnOA8kV6+JGmyJAaOzlq8gr8ABxA5WYE8D0980ND2IgTCfDP8Fp9iLjjHSNfcB6COQJDgR99HAmAr0lPjgTD0M51myDtgp7LMZdgERYAlPwcn2tWgJ9yh1LKVXm9fvKRB+hJV5506Hq9fqv63bP9D5GJPsIW6AWZR1jPqUrWAqWn/UH185VX2g4AA3BGUnQYAIg2IICi6cr7CJMRarQoyhR15I89DbHqyJ8OH3lGpM7LD4YVChAK8KJWKPCTzxSDR4JHnvWPnRzNAT/3hfvmoX0DRgCqaR+Ai5ZIdOYXYjUKHAMFfuoPcCkwkw91ORKERsCPGIADX66ua36Wv3YGrKzqB8DVEWgp3WI6H1kyx6H5OJO1m31kqfApEtrzlrFbP3OAjSjXF7VT1D68v1E+9pXqyJ98Hfk36QQeSTmOlv1Sa43SLSsMaG4l1T/bN1pzJ/5eWmByAO6lmQ8WwougbyIvTh2zxLO+3mx+atZaBwJx3o4DhAkpoEQ1wcPf40mYIB2YOwKRAUnXkb9kO0sckHEsBwmtbapgwbcG2PeNI2IWKGceRALUIVDeK2J3wfODiILL24+hoX/scr2dEb/ZA/i5bwA9I0/mgrt4ni4AOa1nh+8aaIsowhmvQDYUoAC0diEN5/VmztyAGAmmPIBldwQQCCgfWcIR4PPKHF3tEbNdev2a60vtGV0f5wh08qm/tp/Vj/epKSeo+HOWfL2+/p5Umf5Lkif2MbXA5AA8prvRXxdepHQewAcffFSJPfnC52tp5krB7eUSKO03FqWyBiDKLxZWjeDgFdWjwwegySdSutt4V15nAfwaQdjaqmy8Izp+DWFSDlYpjxF/xAPMyEGRz9Fs6B/HxAxp/dQ+QKfb8VjMp5ET1T+aDwBLgaoDeFIgZlRPxDqsyuOfMLWFg+qRIiJRNiWwtUV9fDyJNRRbn3LuFFkn7Le8L+YB2gtFfZii8OsWB1St5nblqHzE+/NsoY9ZsPg10r/v9O9897s0ZK5ofGBeBWv4nNqUf44WmByAc7TqcTZ5MQ7eEz0PAMBkEVHTcQx9H/srC4DrSYAd6YV9D88BPUlQj07o366yE6I2w8110BwpY6s9qyEBjgCOAUsSODrXt9WBHBLazwF+R7/TAIcTaAdd3U/vh7YDvy22ZETcAL/Edhwhj7zPILVVAMCjPlAHPuQI0QsQNtU5sj4USDlWv8gx5XlY0ECmB/BjziMAFklqP1WVlLUrazCMXTAVYNe5lOvFPpT3pWlHXFa5bygMpLQ/zxHlDFR3Mc7FiKYCXPDIH3/y7W9//0jVSe0BW+Ag2DxgvaaigxbgRfv67/xuIDEsWUPN80VyxOXbPOUcbHs773yV5Hbt5GGFllJEADbENQZrtgUBgAJgb2cXEXB7R2ryoZyNBOeBfSkuy+KgcNCMh/ipB4v6DEkF17r2BcC7AqelADzuqBkAsi1Nrd9VdXB8cCiglOe8hYy2LA4QZN/ukyOyndkAc58AeW/p2IvqjUOGnFJC+QCy+qdzO7EQPeThh1Iie0T6Ij2domMtAOnHlh+VRzqRmddff/1DS6NnkDuSCcVgcKL32gKTA3CvzT2oMKBR7w0vVjJCCJQ58kGmu0I4AtstY9J2RG5lJ6utbZ8vFrYzqkmKRQIsVEnHSAeGVI4S0cjJMfLDEYAnIoA+AA+vFIBXOU2H39gAlwiD2ovyG8cjtdvWPCEADzsK/JqPXESbEWiSiPRVDnsAPzxz3TwnpJ+LOsBbAdTHnwsT8OuyEL6f5CcV2+/SCYEAISP+CBABNDHjLHo832Q07dke+TfpSB5HmQphzYI7kETCzENorjOVgyOgpbpDul/UIYrSgWmFbqL7renl1/9q02W7PNMyjNeOAjE6mtNCJFib6FEt0LrpR1mYlE5tAe4BCEwPggPAuQD1YsDLy8v6KzLf+6s3flQV/NJFWv5H6JyOCUrl9MUlHbovEoB7BGCWeAV+5Nfrtj+yu0nv8fwiXc76Knrvk4Vo2x72vcMnYSCNHIGB6uXXElO/BMAD6OiPTe84JBJKZlufAxoFZehdAQvlYg/AjYrXiEAoFwytKU+BXu0w9046erookvzdPj1vOsIHEAFu5KF+vRxNLUPtJVMDpoAd3i9G/rRfYQ45EQDS0aNc3ld4mmtm5WGX/Gg/Pvm835QDxQGA1ymA+WLVcgCQwxHCrvKUe3V19ePq95MnT75Z0Wn/f9UKz88fIPP81Hiqad0Cz55+VJ8H8NLFq1/paxIfcWpP0ifcl7ZP+9RX5o5EjgCqAD+OAOlj6Wjgx2NZpEeZiIBODSgQUy8AHx4K8KvehjlwE8w6BCavdrYM9XH/KDigAEmQXe7WS/6jAifyUb7ajfSx48M1c5SwSz7UAZuEI6naUZ6RsAI/xQForA0pLCS1X9m3A4IpniWIjKGBFOCPxGlv2lnbj5E+EQ1eX07aYw5tYc8Vu0JYI8goIiqfdH0eSR9L1zfr9ysddind0o9GAIz8b4lOPx+qBSYH4KFa/shyedFKz/utysRu9+naAZCBS/m1wIHIIvUwHC22wdf9EF9ZB6qRAM+33Qhr38hOzmHKyCjrCAjwF8LjCBARiICe2tAhIhfxyENxFOChV9YjL62jvrFub+lTJ+ZZMRQGcIQHMLCrgMFZ8wCz5nMyYJQPEGF/LEW/U27GEJeLGJeNPdKV0h7bALhVXiMAO9svuzT9jb04D90Rcv3Ul+vYy4vdHMCVnp+FrzU47n2nHGg00idf6T/+/D3m/jVr4p+DFnjo5/45aKJ7qyKeMVMAFIwn3TrD9Hq9fg+BijaA2VbPOQKE/rEFjrZnBMltaAT8W/tKWjT3nwv9NyVkfuGpIKa8XciWdNu+uLBthahBG4BOKWtfLAZQpzVNG/YrmuKSRWYYkvSthYSXs6QPryPZqPsGWAm9zzqOXdKM9Bkxaj4RIuwr8Gr9uDwAGB6q+moXvpHnVz+Nyo/S1YoDqQE+E1aX5pg58ONISKhf7SlP+5Xf3a6zOIDLyzUFj0BYOfCNPV77lIIjQHtxxDYOk04REAFYINAYHvSrrH59Ad16DVIv3n77rR+IJP2VJE/sY2yByQF4jHelv068WHXscn319G/7xU5LBS9lzd9go6uX0iMVrQF46SKNRbe7JLezA3cSPDaOzOBIQFQzLsTyFfi3s1T+wuaOAXIAGrML6+C3dlAMi8iQV4cAgNTysJcFsKAjxy52TqU4hntz2IgQYPfU8tQhUB6AozzyO+0DIiIoFD2SAWBC/DNbq3JpzwPAP7NdFxv7vLJ2hNh1x8mmWLC/tM9i4j80MJ5a0iMzVn/ak+uDj4CX8x6Q0/bieh+KEikY8flfXnFmKZome6iLmMot9LmfmuThW4AX5eC94cX76ld/s67xzka4RAx9hGLXM+c7vsYzd6p4MzQCkJsCWMyTvwLQw1/dpC51ZVfHxdLsAD/8XVGmBMr9gbVJgB/7CvyebnP1dNye7rsvUoePQ6D5OAKN/TbU0sGjpxQg8iN5VcD4CEhUHAAj3UeUBlSKt14+CkK1XSS7rLZDaCsrZ7clXDKUw3Or+czx0w47A34cAbznhUSGlkGoa2vp/hKy60YcASIs1GdvjgXPtbe37fpYpkX33i5cF/q0F9eh+cjdFwXotTzOZ+ArpZp/i6fpbyVNPx9LC/jz/VgqNNVjWAvw4v3Lf/UHNaKUU+51T8tUgAJp1HH6d9FtbpqB87ERAGo/v0ibF7ayG2BlB/HgEBTL9AhqJAA7UK5Hrw8euSy1feN08JwEF+CUm6NjjjrkbiQgqTbpbeDHMPawTzrUI/6ye8Dz8fhIgCIPcFk6wOLAhLzQHEBTb1HrsEPlehTrJPT1+aV+zcg4DSi5LoBf5/pxFBT4Feg4CIhtd14/a0/WcBY6BSS3mfojt/FtslhM9ea+kAqNnguO/u5OCSVNVv9jZyylvdn+qu2DvVuf/2WWBd8HkYhKS0ViU/o5W6DfPT9niZNtbQG9B4TImMxnGyDOWr0WYLVafbEy9B/f+OFfV3Rp+3UI7QKYDZ+KYVsPL/jMF6VVVpq/aBGg7gaI1gIUFtrf2cdXokgAF9mUnH5pLxIBPdcZ5avdDs/RxhwoA98RHJawso/KsIbAASCj3unozSPh+riPmIkAg3wOHGIRoKfbD4DS0x3Y0nMCwHq+/Bh6XaKWZy0EMXSR35bV8BbKZ4QP0O8J8XcW/elr166aBMx8xN44HG35kDNHjCkBv2+enjRnNiXFLgK2/fFcUC6f7cWRBph5r7G/MseaepHONkAWuWKPfLWHvqZvttt69f/nXnvt65XMtP2Plnq+KKDyfNX6k1VbPOGoRyKE1rpX5QubDt7Ybp7WzTFffWpIswD8dBgUrpGA8oQBM5cO+lDgp6xwKoAjd2/SeQKM+HEEXrpIl7W2KQFOmGPuE8cARwAgpFwFfM1HTqnqFQC/CgoP4NERerbEzDlBDgDZ+Of2XKP+QcfeTi05A37yc5EJgJ5IgQO7jPw75WiCRwzSEwHg5BwBNXM0L+1YsB/eHROe1FQCwM8ieJ4Tr7dFRnAINhbaIiKAfa0v903Tuf88n9wf5Lxcqy88wI+cbu8jHRrZ5TngPiOvFCDX9GN5nnf6C+yUBwD9A78n+vy2QAtUnt/LeHFr/vTjZ/U2nFc/vagdANYCFL7aPUGpjiBpsVlh36W3g39IXyxSl8pUACcCko9DkIsAzO1jLdF2QL7ih11bkwfrM+108GREgL+3od/MkAHARx5KemFHxGI3mBIut1WmwIyv9jZBgAF9xXvt0FUePaUAiOqrHCP8HRvBHcjPu8aKenE9yms9ydd09HF8OOgHORxWeHdIzF1mlwfA7ov8bA3A1rardhwBDN4T5flh6gAgp10Y+euUgwN/NNWTqT/lZsRGZ19f39QOANuSbxlgwHIrqf553gdSS5v4QS0wOQCDmulehXhRGARTOC9W6/PAnAfw6qc/9RUE+yiLAiNHoBsBSFYWi+QgMCUA8FNGNgJgUwEr+UrhetP/6GkkgKNoZzJC3Pv326lJogA/qTNf55pGkDgIOALwLs+Q0hJmth3Qy+PuuEIUuMGA5Vv96fBRBwAZaZEO9XyQwBaTASTIAZzR/XW54AflkO1AS0JA9XqUD9SyyXNG/iJJaH/BK+FJRQAAQABJREFUdj7LJyKwsP2nAL87BkQAAg8vGvlL8T4VQDqOmvJ+H+R+eToKAdV2ZLsfT5vyo4F+bqsR1eMI6qPJ7733/s8ljf5Jkif2MbdAfy/8mGs81Y0Xrd4O+IsPP/xZ1SSf+8IXfreiLAasft/+44Qx3ndGVoT2WAugjoDuCiASoI7AYp8epe43Am7XIv/b+ssygJH8Hzo8cBegUmCGpwQAWx0C8nMUew605gg07ZgsABwc6UuEAPseEQDAyTCqHb3y+D10+Iz4MaMdf+PosZQEyTZlQMmUQVMuofZ+/aGOQbu04RyLM5uIQDv2s7CIEhbpwJCKIgK66A/9Y6kCv9uxCAzPzZJIlD0/OvLn6GAeD72fbpcf/oKkBN5fso+lkQMa2XvnnbeHTgFwa3igeJUj01P6PbYA7889FjkVdZctwOeB/1nxWwfNcjAPAKUjEaYCFra8eGvbmXxXwKY9VaCOwPwivd9+gKCN/AtbC8C5AEQCoNHUgPRzxZyPvthVAog4CN2BHf1OUgDQC+uQZza5QMRcG29vB/fMO4ZTifRmOFRzQ2oHfDEIwOLAkE06gDIWYAFwBY7GEfAWSkVm1gZQH63n2HpxfRFV+8jhUHk7ckCPnVeAHHTjn/Ntd2VNfZs7hc5tSnmkRddPfkjtQdI5f38+QH4xEIX+RexkVhf/YVCfm6EOBduQsTPR57MF2m/N83kNn9Rag2AH79H7776robi6PRoAGNc8AIpquSNArVTAeI8ESK0BfNR0rcLCOnpOEkQOikOAI0A6I29Gxo1jIDMohoMCh5jpUByEDv53JC3BDC8IXTOQFnkABsCX7HLg2wasnY14d9u0y0o/ToO+3jc6dp4D5ByQgimURi79aoC0nRMBeFvqeA5g7jgCYrIzsjdHwRf9DbyB3Bcx32H1/rAIUwW9/TMjfw6WUn3KwY5HCGzb4dx2N3gkTw1k+NIfOfgq5CICbEM+UAyRygMiU9ZDt4B00w9dnan8nhbgReq9V7yI5XkAPardk/V6hW4l0uEURTp5eLu7vpVb/rSPBBWyaBChTiSADKFzW31PJGJn5SzmlJs8jZnN9e5tuxeOAObmIIT1kDgEdw1QHvoXgKYeOCCsLsf96AKwa7R/BHYV+HfWAJreNnY6x33Z29HHPhXhq9wP4sfgChx7nxSgOJiG9teXBXnkcDBYC0KFI4en4yDI/fKTATkq2p5HBv5QyhlKcez8wCZRHDpiF7WjWT5KdMT+f0L/gWt8dJUmxRNa4G7e4hMqMKl2WoB7wlAQLGFfHn1bjZT6eeCLJbO7yS4dOR1dw6ditAPBAaDjoXadbwZ4rD9J6JqAnR2u31kTwNSAGSYSwMifSEAEnO4IsOrd7JDu9Q0iFYyYkHMqHbqn39EPgI5FjWqWdt/YiFHz4QH++aJeAlLOaKQL3djIV9Oxiz6U5wAeB6Zz3w35cQCQV6Dk+shX2gFQE8jp4d917LUfc80OeRYX8j6ooO8OkQzkGYn79ahHanq0L8Dv8m43vdZMAUT7/r08i5Dl9v9zvzkICF6nAHL7/3GYqC79xNWzq3eqtCdfevKNik77/6tWeH7/AJPn9wpenJofjARs1te/rJriYvnyZ283CUBKf9nw+Bm3pZvffAZ4ayMa1hC4hEQCWBOwXxsg2ZqA4iY9Yu4IcD6AOQJEAlgMVXYpdRFziwRQHvUmIkAHq+nIFwsGHJ5S/1hY5NNw81ZmGpjQn+91e+AtyUM/FUCRnVnIlhEo6XTQ8CurwL5It1sdAgAeeSICdOikR5R2i/Jz6WOBH3sAPUAIT35EGamTzzkL8DkK4FOuLy7MKVq+6wnPfWOxH+Zy7YseoX+1jx2AH3l48u+LAvyUVzrqH1S/e7b/ITLR56gFJgfgObpZh6p6c3X9dpX/ysttBwAdRjaMZABO/UYA8obj5f73lMIiQSIBviZAFgfO3HNoD8F9bUBw8mDjCNhiQwv5EyGIOlZN57pwFDQywPUB9PBKWczFwS2a77xEDhhp+YiaOWCGghK5wM7Mtls6b+czrGwIvO56LLWoRgTQV6rtpPnMZXO/cWQAKACbuWl1BDr2LAF9zY/SVU755ohezennCZ3353ZTeT+4Pq63aZ/kOOtzoe3L7eY6AfJuiYdTmjUth+XuK/fjjz7+SVAWAxTN7vfEVWriH6QFJgfgQZp9UKG8OEwBoMSL1joP4J+ePv27SuAzn/3MbyNYUe2YbudVvwmJq6evHRb8bpcAOnIE+HwpH/ux3YHFzSZdzoqrspDmOljdrfUcynO9OAKRHt8e0IOIcBzQ47rhB1NzDJjHYaRI/bCztyNg4TvUhsArO89gveH2J0mNCKAf1RtgQg5K/eAjCjBG+aQDfPA56kCbExyaL46ZqvF55WhqQ+tDe2o7cT+1XXPXTz52qZ+WS/q5KKF+3n/4qLw3fvCGOgDtBzJSnNIfZQtMDsCjvC2DKsWLV08Gv/33/+l7ldZ/+eX/4r+9rQ0Q0lHdzjvmNx1W5AgUtn1ObS8sBM5UwtyO/FuxzcsU1CFoIgNJgIiA2odv5BP0Uh75HEnMR4hIj+jcVl3vbdsgI+FInnQ6eHgO6skCvik07YzHlDL8jPcgMrC01f1EFGbWznzW2OtjP4ZeD3oAFI6AXmfOHvrYy1IDcgCb8nQEnrVjAk1kx12zw6riSOh7FAN/e46fQqg/PFTTuf++Yi4XssJQjp54ANCI7X+EAAc2dK7iU/45WmByAM7Rqndrkxfp4L36t//u39URgK//zu/eael0RISEFwaIwJJODeguAaYE9nam8IV9bGdnUwe7ffpWAJUe6hCoI9AAP5bSWgK+mkaq03XynxZp04En860CjQQgoB016RHd2FqCmc3pR3KaTnuTrvehkMgAckpxvEhn0Rk85XA+hI5wkWMOmhEzQI4j0MgdXluCPProQSmnaWcg0OwaECrwNsDeXz7X6eXwESEA3nc3GF5ZOqv7WTug5Tb1TJa5T8zxe3lcmCU0ckgkKmLtzEfAsevoQFUYmBwQmbIeSwv0vy2PpXYvZj30nuBBA1U4AvCvVM1Ufh3wMxV98wf/z99UlL/OiMVG3KQ7tZ5HOybltSOlHN0u2JwgmACexYHIQzd2Zjs8VCMBpA+lfIVwqPz+JjXz7ALXhuZtWyAS0E7t43TmJsmwiC1qxz5LY9IY8RNpuJDvz2OLkTr1UJ505KHI4QiQfixVwFNALU4c+eIYUL9O5MDWVpDukQaLWDXvR7LQqZ8Z1veE8pDP5eMQ0R7IswaAtQleH/NECN0jD9VdAEv7OiCLRZEj5I8deOpPOlNPn//c575W5fWs/gf409fDmo+RYIqBDPxEH0ELACaPoCpTFawFZMjj7cILxj3jhapPiClf6PrrgJv1+qNKY7ka+HVAG/LpYkA6CC/dfmg6QEEkAPnFMgH/1kb6M1vbp47A0j7WstukyyYikIsEUI7SHPBv7VgDHfk3wJ8sElHQSAMRBZ1aaOpxGPiRi9qR/GMpwI/9mX6v/ljDogdQSfJgFmBUBeoNICuAuzwjd0/o/2FfAe5kYnfDxwVMgt0aKDDib+rL/U2vH/VFvpFLKZqvclngt+EAz92MCmEoQ31RakZOswF+0rebzY/5HVD6J83Go9b0iX8ELQCYPIKqTFUY2AK8aL337qOPnv60svPa5z7bWgwY2WZEEeXn0ungmBpg9wB67BaYrVPHyaYBdQTm9rGgnc0IzM1jUIfg1MjAapG+orwrXqaKLTrbmqdicZecI8C+fhyhlrERDO041g4jfub4d9ZuS1uLsbUjlDX0P6JqLVEFuFbmCQzXj4nmuUwICO4xRQGPPJR8+MZOSmFNjDsYgSFC/1xvUz/8cwJzyS5yTbmH84cCfy4CQr2gOvKnPqfSX/3yVz+obIzY/jcB/6mNfg/6vSByD+VORQxvAV4khh69mryY//juu29UAuwG0A5Q+V5jPYl0LJq1tSFWM/JM1WWRIIv9tqvUIVpgoIgcASICXo6uFZBzBFxu4I/dMgH/BUfGLtP5SstNilzeWCtrRCNnng6YuV/m/ld24uHC5pzXtgYistfYSRI5h2BmBwKx7XBuayp2fo5BGqkyEsZ+zm5Uv5PTfQ6fiA8zXgasMrJXfMbRYSpC66Py5OMY6POPQ0A6+gA67YUdpTk58tFT4CfdQ/00Bxl3RAn9Y05D/cojB/0Pf/p/fJ/fRhmIQCV7Yp+HFpgcgOfhLvXXkRcPx6DeDfDj//cHf1qJ/4t//rX/vqLMHXIOQJXW+zdfmqOREGo/S5PH5aryVpe0t1XEs10aWkaOQVNGOrFuYWfZ4wgUtiiQRYKcLLwrmNlIFogM4DGsPESQLlsjAnPOzjc5gP5Gdhs4b3IbcwQYtzGVgD2uh3akXZkzJhKAHCNwzt4vR+Z1+y7sbpVHqlIUKi0KQOcAiHI2tktBvmbcslkx2O1kkGAAzHkAJN8dZQSdLAK8tBPlAMTwXSBtPZaIOcVBQI9Fji5gP5j6Qo4DqTRi0uS3LUT3B3kAHy2dOkG/3SqltKx9wEE4fNWUUr73NmcwV4dKbqyG+hsL7V9/8u1vqwPQFmg4XmCeb+tXGoHp1+NpgaHP0+Op8YtTE703vFCsTsN5g+9dDEgHC2DRfKQv6SisY6BDioCdfOxAFViIDBCiRq6cS6x/btmutu7vHzhzXB0CzhMgsoBdqDoEpCugM+KfXfxaLbK/eVpTHAH0YsqBRSIhHbfkFjgCmp5zCFQeHgcAwAH49H4grxR5TVeeEbSmhzxfHRQAUvkc0CPPc8d1wZNPOjxA3l2L1khUv9QOubQnfCRHvsoD/Ar4yGMPYNd2wAFg7t/lTBDgxg6UbaLqABABQI4RP3bgvX5Wcd7D1157jcV/fBzkY5OFZ0ACjyle8I6Pg8BEH64FAJGHq8FU8tgW4EXrvXflC15Pcn/87NmHleFP/dorraOBtbDyqF97QZN/MZtx+Goa+TPiL09gazkkdDBqr8unCAAOAWsCCg60sZC+OgR+0jD7721xQfmtAysi2VWHIFo8qCN5gB4Ydz57QBEacqUZ4KfjnRdp5O+AtbiwjrH/7OKhjgFTCzgEUruQVeBCUB2DDkAhGNEM8KtaVA+Vox3z6eANz0tbI1deVA5WVD8CfLUDkHvteKvk+YmAn/KVhg47ISdTUKBXO8qv12td/Jc+S9n1rOiX1ASXqukT/whaoBdEHkG9piowmayx0aZleOG4h7yYdUTgVx89/U4lWjoAv9+oNCcDlquda+BfSAQAWYAfHloeMFPr7bbrumfFEYDubHm8dnzos88eR4CIwAL/whyChQHIzCIErBnADosIcQg27eMEECvUIWCkz8gfQZVTPl1tJc2AxjQ3CWAWQexdtw0CHMtlciRs80N1YiNQQJWMJseA/HLOuu5Qy7l+Q7aktuLAokx3G90Xd0isVOoplTk7G9Xv1IL1esaWo/oAPvXi7qldAB+5jiNlwA/gI6d6pEMpB0o6NBf6R46TQJlH1PSf/ezt/5u0gNIPaba8KJo98Y+hBQCPx1CXqQ7HtQAvYA38LAb8u5/86M8rc0++8PnaAZCpv05JAABfJUOAkT/ATzr7iuGdak/iGfojjeA1VR0CXTOAPGsHcAQ6iwdNUM8ZmP26BUSu8Zew2KbAK0cXQ0l3aVs6sTXHgG8eeH7mh6+xMIdnQ49segC/mmEqoXEE2hIRMCiQoaX3XSMAyD0U1XoPrR96UXtwPcg1fBQ5QCJRtavA3QF81A34OWmydM3JaVGm6khUP5GRP/XAEUd+LFX7//7f/+/1QIJ+5ZY9+p1bSfXPCfi1RR4xPzkAj/jmSNV4sSKI5YWskZVFO5wM2CxeS7e83LZnPU6aAigBoDUCBfjpUMp907X8di8by5ujRevqzi1kX9giQb8GqTUOB2fZd6YIULSpgsXcphJs7cDWPkeMI4A4DgF8xzGwVqSxyLeBfDG/SUPoSxtJb6w81hwA8PC7zFn+K06cs6mFnXX0jBj1QJ2l9cDqCHA97hBsb+r7RcdPPu0KD1ACcDl59JCHz1EtJyd/aj4Oi16v2tXr1euKRvIs6mSRJ3axB9BrwMUBPwjpc35EM+JPryH2mnJar2MBMFM+FPm7ouJ/Fn/4h3/4ptjm1ZHkiX0eW6D9lD2PV/DJr7PeI4YK9Yi/vHycOPh6MeBl+Vc1zZ//xff/z4q+8srLX64oI3cW/xU+FZAcAKYEcAAY+S/MASiXF2ufV5n1v7IDbNW3XDyW5M0h2NniMKYKUNSOnK/ckQ8lQsCaAU83hwA+R9VRyMnjILBXAh6HYXdhl22RBRwLDjgqp1RaRexljhzAaQndYkKHYL9ptzf7/W7pVj8BDAVAESs/WXDw9uZ3EZhBylP7d83z3ETlcb0K9HzdLwJ4rScA7QCvAspLaB99FYPXkb6n4ylaAtcJjSIA88WqvpG8j8jrGgAcKS3v6uqqnvt/8uTJN6u8MgLwK5PBAWCxX9o/210TwIDl8ANFwRN9kBYAPB6k8KnQk1qAF5F7yPYbYtu1A/DWz/7+/6pKYVsg+563dsLNKpgbAPi9hgL8dCyeLz/ocErESADFYiSr5dwW8+EQEGloHIP2FAEOwcKONPVFhJRrn9P1tQSkG73Z0SwpQSMHIt5hl3LCEY5AYQcYsZSysJMNMeDbGC0SsCXwYgLeTsYzQkQfSmQAnn3/rBbnjH21pwAoeOLm+BHhP/YBjJyjgL0cpX6RHOUgB4/80OvFwQLwQaVIn3Iol485hUtyOiF98C/56zPzgxWAuQ6lOuInn/oC/KRDmfuP3s+9OYhRPYgA/Nmf/dn/ik2h9DP0P5Lti2RoYs2f+EfUAoDHI6rSVBVpAV6k1kjvlgwvYuteMmf3P/8v/+Z/q2T/9b/+N/9dRUuhyE6V3fnzkb8tStSOhQ6po2gJADYjd7YjuTzdCQk2UI4cgo4jQDnoG6U8ki9sCgGeCII6DKQzxYB85DAMjSQwNdL4W+m2cvMoJxoJ4rghxwgWnpuqAK888tw3RtCkR/KMoHEQIjns3BXVcuBxSLQc6kkki5G3t49NwXi6RGYK+zYA+tjnYKVmzp6cRKlXNJevgAvAA7hLHOS2Wee4X55gP0jnfSGfyJu+r1oP5JX+0R/90Z9WafQjt/L1kb2VNf183lqA9+J5q/eLWF+9V+xHA/ihrakAPhL059/7y+9Ujca2wNXFS/UQZbVKaks7+3S5WNbI5MBvI3/tSOh4uBFRx6IdHSMQ9BSAdnZAkOefOGWAHaXqIHTybe5f0+F9d8K87cFwoBHbGBt5fvXTrX18htyh++4ZWWo7YqdznxqkQqSXMgLuzSwTKQ/7OT6yMzadciK9mQF6+bzWf7SjnoMB0BNBAdh3aamLmycdx8Iz5Ef0/CPGewCvlHYkXXnSGfmTr8BPBICpOt5b5Kkn9VH+6tnVO1VZT7705BsVveUARPv+cQh0Hw4Dl8rM9PdIWwDQeKTVm6p1oAVAnoP3sHzx63MB/v7v3/7jyhZTAfsds9gpmL39/9l78wC5jure/87es+8zGu2bZS22Bd73hcVgzGLAhEBeAoQtIYHskPd+yQ9I4CUhEAIhJARISAj5hbBjiAM4gME7tvAS27Ika9dIM6PZ9+numV/VOfXp6S7Nne6RJVuaqf6jz61Tp+rWPXep+p5zqsrZnllplw+If37/Q0I+HxQQDWk/P3JIJyOHgKPUz4ce5Dzt1raPcxkUF+uSvlSXSqprkiBDLAfkY5kgfQJlnYITMhzDKaqUz59jp8r0tpRO5/r8iY1MuQGDnz/jOuZSN+BJOVOBj/zp0GiWj2Tp+MjPUC8oLcP3Dujw8iPe3PEorgGq89Pwnymlg/fryVy3d50Zvleg2M1DZQAFcs8NhbWFnAl/1nTj1TR30n/+keL5Jg01waEyIDeeMWJ8JIsOPzWjI9JMEKgriMssMxCIGbBznjjK+3jX3Xf9syeD74zvDdk8+fBpN74P5AI9gzWQ+xafwQ0NTTvBdM8LR0/DQICeUCwBJhaw1uru6uuu22qpcQV83dJEeZnc+/KKCnlhzQdb0hXlujCNMYFK2vwrdR9AEIOtw/7iPnSaa/wNrhxyfGh8SwDyrDxGGsqAIJN2lgIGBPDjKB9K6uFDTJpy/kABfhz1LQm4EHzXAuWnU3wv4ShNeUGBPuL0Td4gVTpsX57aMwME54MmTf5sBwjn5Ghc7MLJ1TZPKWeiP0HC7/hjen7Wv6B8kVuYyazXMGfHRT7PL+ViKUtquwE2z9kJ8k6uOMoNCmEJbuR9V0SG7xbmouMn6I98/72F7w/MeD/Jr6utvdIep1KpTsdjAOAH/fkDAPQXkD/KPAsoncZZ0NQl30ReLOmQjTZ44RgAoCBeTOFjwrvrzjufsAIjg4Myraec3QKxBJTMH91P5XHU/5DEdfCZD6lnCaBe/2Lgx1EsBOTHuRBmP5SMmyjh07mDD30p0idYEvJYDoozJhZqUGpcLjmMEpztjpvKTPZQBldR7HV8VMJAi4cF0zB85BYYEjJbzDtigUbfYuGJnXRydqCS+4Qw8CglqNQzkfgdPg1gGmx6Rjt+v6OlY56OWAHCleR9oaN3bOTNa6m3xrcYeB0+L6/f4WfaNztPEJZQXHQmIQ+Mea7lFhsLnqQZuOcUmiPB+8p7evDQkR1WzAwQei01AwBL7I8DqHJn/7mUWU44Oms0wPfhrGlwaGisJQDfP4O6nDSWgJtf8YoXWR1++E8/9LeWGguAJVFFopIPiKbLtSP0kQQfjkxHLtKzSN8lM5YBv8OhHPXwAaKcTynvy/mWApA8iIs09cUNDMj3KRaDE/guinqhlgLqwWLAwIE0+VA29yn1Nkdi3QGuE4oJOaKDoqIYin4ojxh80vko9yef3OnO57miPXGI3u/oM+1Cb17HTj4dPHrO6M2Vg488NCPnGGZWQUHf3Nh2unqyBgLC8Tv+hb63v/LLv3yDrei2224T5G+AQ9y0PwYCWARcizKAJHckS26gZ6QGCnoYz8iWL91G+fcMMAg0YgCQ4wow6pIenaDAn951r8QENDc3rbKqxBVQ7lwDfGDKShXK8CGj4+aDS9q/HeT7fNJx5fyOHnk+7KR9OfJ9PvJxHZs/MMjIxwQfZvK9+fYnOyCgPmhmQOC2+aXd6B850nEIEjlo0Yzu3kgaSv2kTxWlXtoZly70fNzf2OcqpuOOq39mOi0dmIkpkBGweS+kwzNr34vLLK4cAwHWz/DlyPcHBPDj7le+Dp/z8F6S9jt++GadD/87IVm+C8DM95ftw5ctW/YmK2Cuf8DVsVDTvyvmfE2kAj2jNTDnQ3JGtzg0Dg34966gWQEsEIQl4IN/8uFP2Qqrq6pk5M4AIFGuMQH4Fvmg0HHzIfbTNM6nyNFBk2b3suSsydEvKmnKkUmHQNrPhw/1LQbw6ZgyaW8WQobvBgSkob6lwK8POehCBwpmsyaKCqVDpSNhKWGCxOhI2K4Zvi9HmsqRY6BA/XFpykHzXTdycTTTYfpInDQFn2FHTzWnmnJf8tWbzwLA/Yurp9ABAOXNLB/5TvB+8N6Rf8trXvNqe4yLEJehYYWof5S0iKnfiSziS110l+bfO3oKLAFYALAICNLBFWA+2JVWI//x1W9+wtKtWzZdZ2lVdY349JgWaIICLTvyBwA+kmBAIMJz/PHh4UOECPxCBwKU8+uBz8DgpPM9ZJ+p1xsYxAUf+gOCTPnYehVoMVuBJZEpF0eLS3Wlt7j8k+UzcKA8AwPScZSBQlw+/ExHDyOOxpjWQdIF1xNX/yni0/HHDYDI908XNxCIGwD4HT/1xVkAyOe9Jc37dvhwp/j8Lzh/26/aPNNOmS2UZfrHxI/JP6z4hxIXEfU7kUV0aUvmUriH/gCAjh9KTEDOUsEGGTdbTd3/s4e+a2lDfZ3sllNVXS0WAT48viuAAQAfFFvW/hgIwKcjJq1S8f8MBJDAMgCfNPl+/aQZCCAHjcuHj1zcBz2T7w0IMvwCLQXIQ/OdDzlo3G6N5J8u6g8QOE+hAwXkoWa9AXnO4jp2OkrkKLdQfVEOU//ExKSY+hOJihzTv+8aoFwcn3yfxnX8vhxprpN03ECAfN5L0gsdCLz61ltPFvkzjYXgP7l/tCPQs0sDdA5nV6tDa+fSAC8kef695cXFtycDAvPhEVPfl/713z5rC7797W99r6WlZUl9scs16LDE7VtvRhky4KCDxdxgy2T/6FAL7fgp63fw+fh+/aSLPcRNe8mn3bRz2ovaZjTF+aF0PKxoaGKvVU9urwOi7ZHPUE7oGL6loNAOIx3lThegI0ilc/cEyJz3FB/EDTy4PAYIyGXWlyjS/Z79gQIdnwnaEz2ah0ueLzp8KHqPuxzOQ8dJ2mxilePrp+NHjvRsvUU6UGZ6QSZD+Vwn9ZNNfaTztde/31wn+qB+v17qL5RiAaA9f/eZz/2DLfuTH/1on6VZ7eC7wHfCZtsfFgCfr7nh/6zWAOjxrL6IJd54/x7Sd/GtYiAAndMSYD4Q87oEEm62AJYAoth9xO+n6WDpeP10ofcurhx86qGjJ52P+uWR9+uJlfMGGrPlcwcG8KH5XAhMW/QHCpT392p4tgYAnD+O0nH5+XRwPt9P01H5fD8ddx5fjg60UHm//KlK0w6/vqwOWLIYAPhyceUZACJv5PzvgWSZhcCO2INLL7no5Zaa82Lyx9Sfz+fPAMAHGjoAtpWG31mngTkflrPuKkKDrQb8e+kPBOaMCTDlxMlPcCAugbvuvu/LttLm1pblllYmElJfVaVWA7LI5wqwZbN/DATg0bH6fPJ9irzP9ztsP590oeWRo12kqQdKh8WHnDT50IylIMPIjcqPGxAgHjet7bnq+BfaofoDgDg9cb3QhZ6HclAsAMn0tDznJcUa3JqeTkvH5acpV1ZSLIh4ekaRvy8fVy/l42hcR448zxFpf0Dglz9hAMCuna6CoZFR6eg3blj/Qssy76vM888K9gP5MxAA8QefPzdhEVNQ4SK+xHBpTgO82CgECwHpHJfA1Vdd/nqbQWyAWWdFYgMmS4p1xO9WDDQiMvCgMjpMKoUWyqejjZOnvkIp9fnyCx0w0GHRkTPPmnpTbiEW0lDkoJTPLLWM68Bt32o6PO2Y3Ic8PVOUg7hYmS6Z9lYK4oQFUjpWOpS4dIHVnSD2bHX4fsdMmgYZ2KpRrI5BPh18Mp3OyaccAwaTzkG4lI+rl/I+zQw0PNcC+kee54yBAHpkIMB9Qt5Y6HMH/iW6tPfk1JTw3/r2t73RypryIHyK0vHzXfApctCc5xBmoGe3BnIfnrP7WkLrVQP+PfUtAQz6oPO6BK694YZ1ttqP/MVf/qOlfpAgswSYbuS7AEjbstk/Ong6aNLZMvbYzyfty5H2O3ZfnnzaRRo52kHa/yDT0fsdup+mPQumbkBAOX8AAH+hyJ+Ogw6HNPXlo3RE+eT8fPTn80kX2g46XsqdKppySzObWS9zVunn++k5CxXAZEAQJ8p9Ip8BAQMB+L6caZ8MWN78lje91spk+fpP1uTPqRgA5AyIyAz07NSA31mcnVcRWp2tgbh7GrdOgD8QyHEJmA+4xAaYDkBmD+AaaG1tXmVPymyBfAMBGkjHS5oOlzSUDtjPh48clI6cdJwc+XHynM8vT8eP64M09cVR08Gdkg+m3+Fj+vX5tIOOlQ6CNPk+PV0dvH+efO1AfqEdvt8xk6Y+OnifT/7JUr/euHS++k92QMDsiWQqLR08iH+ejh/k75v8fQuAv7sfl3BKnmcqC/S51UBcZ/Hctiqc/VRowL+3+SwBCxoIfOVr3/oz28jztm25ztJKt5DQM40RsHU9k5/fsVNX3EJA5EPjOv5CET4dvul4Rf+kqT+O0pHn69jjysfxC+1w4wYA+RA85y30PMhDF9rRU87vyP2OF7kzldLeuPYVRzNiksi3l0Ff/+BhW8clF1/4C5Zi6jcWAx/x0/HT0ccNAAj2s9XZX0D+qodF+e93EovyIpf4Rfn3ON9AAJcAA4J5LQJ/+uE/f7PV78tufvnbLG1qrLckWuisASmU9ReHxH0+HT6WBdJU5Xfo8KGFytMRZnz4VBBDC+34/eInOxCI64DjOnb/vFyfzy80zfnjLA509CBd0vnq9zt6X552YyInPx8fOahfHr5fzzNNU69PS92uTtPT2v8WF8/tkiD/8Sd2/out4xduffXfWxo6fquF8FuoBvzOYaHlg/zZowH/Xp/SgcB1L3hBu1XFhz74oQ9Y2tLedp6lNdVVch5cBJjQFzp7wNY1148OPG4AQBkGAsjD9yly8H1TP5aA0rKyHiszNZVssXShHf7JdvT2XPZHh6spG92ttxN+XMdPB0a5Qin1FizvouwLlc/X0VPPybaf8s8VNe3mfZu3CWYgIogbC0DKPbBTkxOyRv+HP/ynf2Qr+PY3v/lzS09jx2+rt79g8lc9LMp/v1NYlBcZLko0EHev+TABOUD+0IIsAujYdDwSK4Bl4MYbX/w6m9fS0iKmAfYaYEDgBw9SDxTETzquo/c7buTjaNxAwK+HDgeE6A8I/PrzDQTiOv6Fmv79Djmuw/fbx/X4fOrzBxK+XFy6UETvly+046dcXPvJL5Sa88r7YEzx0sGRLrT8qZIrdg90MpkbzZ9OqoX+oR0//6Y912++650fsZQO3x7bn3kuT9bUrxWY/X848Gjo+D2FLMZkXKewGK81XJNqIO6e5xsI+AOCHNcAyjUf6JygQfif+vRnZIXBK6686pWW57sK8JljIaDjp8MnTQdNmvqh5JOOo/4AgHLUaz7I8gGkPQvt+CsrK2S+9fj4pCy1TDviBgDk+5SO2Xzoh2ye0W+dL2PT/gCg0I6S+ueqcy6eP/99Lpm5eIV29IW22z+HKSfP77S7sc92x25iTHh//KZJmutiIEmalS/ZJKq76/idtsCn/vYTf23pnT/8YZelp6HjD75+q9gl/ovrDJa4WpbE5cfdez5kcRYBBgJ+fs6AwHzgZCCAJrEM4Cr4jXe9W9YiP2fD+tdYmbqGBrEc+BYCOmA6ZupjYEC6UOp3/H45BgI+P98AAHnToeYgp4V2+Fn1yGEcIj/ZDp817VnghvP5lI4eudON8OkQ/XaQNvk8l7CE0uGDpEnnCJ1EIq5Dz9fOE06ls/Ki5Iz2t3T0EylF+EO9wwvq8Km/gIV8CPaDUjR0/Ggi0BNWjwsqWXoaONmBAJqa10WAUNyAgHxcBi99yY0vs7z6hsbllmYNCKRj9S0FVsb+/AGCcvP/x3X4lIzr+E27jluZQmMAGAhQb21NtZQfGByWGAI6erO8jyD9oqJIkD5p0yHNuU99vg6pUIRPh5+1AA5NnZcWiuypJF97McXToc+WO7mFj4pn0rIAznRRiQww6djLiqMRW3dyOqqxNF+7rIz9gdjLinT8S8euubP/dPRJF9Q3NSKni/buPyQm/a989cv/bKVB+JQ8CaRPh05H71PyOQU0RPejiSVM4z7+S1glS/bS454FkJeP+FEUFgGoL5djGaBQvgHBe37r926xstdce+1llm46d9PVlpoliaWdxsQuFN85AwMrY38E62FyVe6J/wwcFmrypyYf8cOnw6d9Pp+0T/0O20f6yMd1WH555H36XCN62mOug+dLWCeL4Ono6dCpHxqnL/IzHXupPsakyfcpHTwDyJSLlUtPa//b19W/05a59557vm3pJz/xMen47XH2r4AOH/F80/iQYwAQOn40EmisBuI++rEFQsai10DcM8GHmg4eRdDxQ+HPaRnIZJrNBzi2NN+AAFksBVdcfvlmy2ttb7/KUgYGJqjQJiPjA84ZIAjT/DFQoOMmTX4+Srl8cuQzECANjeuoT3WHf7qQfb4Olet8ph28j9gnU9OC2BnY5WsHHTnbSdMu+KTjKB09SJ4OPprU2Lm+gRHp6Pfs2f0TW8cd//392y3Nh+ytjP2Z6/CD+DTDGJfcgd+hk/Yp5fyOH6RPfo6LCmagS1MDcR/7pamNcNVzaSDuGfEHBP4AgDSUgQNpsQxwQjYjIu0PCOATS0AaysBg8+Yt51rexvVrL7I0UVkpHUZJaZl8CBkgGOQl14Xp3cpm/3zknp1nj+nYfTn4vjxpv+P3O/x8HZpfPlNvns1tkPNpPhN+vvZQn5GT5wEEjwmfNHI+pYP3+YWeN/J87PlM8yecJ639LB08+XT06THdE2dweLygjp7yccie/CwfPqxCO3zkGQD4HT75oeNHE4HGaiDu4x5bIGQsWQ3EPSv+QAAF0dH7afj+gAC5glwGCMcNCMh/5S23PN8e33jjTS+2dM2aNTIw6GhvX2XTZYmEfEDLS0ukPQwUGBiANBkw2DL253fcfr5KnShXaMcW19FT70JN+Pk6euqNax8dPHLQk+3gZ8v7/RQ5uXShiJ3ScR07+T6S7+09Lh39979/+w+sDPPtM/Legd/Rkw2yJ30KO3yqzNfxB6SPpgKN1UDcRz22QMhY8hrI98w80wEBCmagcFKWAiopdIBwySWXb7dlsCB0LGtfb9Ol5RU6UChTCwLTy8zuiDJgYMU2g3iteOYXt4RrRsA7mJlOobecnOmoaO4P/fQ0AyiRZ4W46ah4Tnk6dhaY8euN6+BpzEI7es5H+Qx1iD1SD41Z0Uj7qbhgOsphiiedr2MHuU9MKIKfTM7kIPif/ey+R2xd+Tp4zncKOnqq8pE7aT/f5895X00hfwQVOn40GWheDeR+tfKKB4GggRM0kO8ZomPL6bBMLXTwVBiXppyfP+/AgEpNR5QzHRF+voEBclAsCQwUGhsb22xec3OLxCKsWrmi2qZnikt0wOCCySzP/rAkmBUQ5YONiVxzn73/hXbktCy2Q3cC+RC634FTr9+Rw8cED0I3rgLJSqXU9z46npIOHcTe39/fbQXo2AeHhjpt2vfFW958v9PY0XNaOnaozycdOnw0Eehp00C+j/dpO3GoeNFqIN8z9UwHBCjutAwMqHyhAwTKQRkwkF67ZsMae2xmM2yytL19WaOlqVSq3dKammoZQJRXl8sAoqRYxztlMWvC2zL2V1ySMw5SZgH/cR2yXzSug0Yu01HDgLogOTpu2OmZ4kP2eGRkdNRSOnB7bH+7ntq1y9L9B54+YCm/QpE68j6N69iR80328Ocx3SPid+R03D4/X5py1Av1ET78gPTRRKAnrYF8H+uTrjgUXPIaKPTZYkCAwujYSfvIP1+a8r4c9eX0mH7wIULQOAsC+dBnOmCgHp+ycBL8+rq65RxnUwYY2by5jlvbWhvm4sPr6e4Z4Hgu6nfMvgzIG/5CETjl8tF8HTrl4zp28ufo4MkiKI80HbjfUcP35eLS8P164IcOH00Eeto1UOhH+rQ3JJxgyWgg3zOXb0CAovwO3k/7cgwMfD5paM4AAWa+gQJy0IUOHOjQTtdAgnadKdS/XtL52pevQ6f8Ajp2ivgdud9B+/l+utB6kIP6HX5A9mgm0NOugXwf49PegHCCJa+BQp/Bkx0YoOB8AwTkntFAgUpOdsBAB8cAwk9T/6miDDgK7YAXel6//XHpuHrn6cj9Ij5iJz+uoz5VHTzn8euDD/U7evihw0cTgT7rGij04/usNyyccMlroNBns9CBAQotdCCAPNQv5w8U4uTgQ+e0MJDpUwYSfkfo80n75Z+tdL72LaAdcR05VRTaoeeTj6snX7l8HT3l/Q4/dPRoJtAzRgOFfmTPmAaHhix5DRT6zPoDAxQX13GT73f08KH58vPJ5Tt/vvLk56MMNOhQSVPO55Mm/3TRfB1vvg42X/lnmp/v/OjF7+DhQ0OHjyYCPWM1UOjH9Iy9gNCwoAGngZN9lk/XQMG/MYUOHPxypJ9peerJRxmgFNoR5qsvX4ccV36h5fLJn+z1xHX0oYOPu3OBf9Zo4GQ/mmfNBYaGBg04DTzTZz1uoICC6ThJ56OnqkOnHjrAuHS+9uTLp/58ciebv9D6T7ZDp31xHTv50NDRo4lAF50GnulHcdEpJFzQktXAqX4X8g0YfEUvdADhlz/T0nTQcddF/qlud+jYT7VGQ32LVgOn+qO3aBUVLixowNPAc/XuLHRg4TX7rEkW2pH7FxQQu6+RkA4aiNHAc/URi2lOYAcNLHoNhHdu/lscOvD59RNygwZOmQbCx+iUqTJUFDRwRmrgmb7joUM+I29raFTQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQQNBA0EDQwJLVwDONEF6yigsXvmg04L8DIep90dzacCFBA0ED82lgqSwqMp8OQl7QQNBA0EDQQNDAktOAj36WnALCBS96DZyuZzxYChb9oxMuMGhgcWsgWAAW9/0NVxc0EDQQNBA0EDQwpwZOFzqa82SBGTTwLGig0Gf6mQ5+/bXqg0XgWbi54RRBA0EDp04Dz/QjeOpaEmoKGggaCBoIGggaCBp41jRQKFp61hoUThQ0sEAN5HuG/UGubE/7wAMPXGzPs2bVqldZ2t7R8ceWzvPLt31tsAjMo7ysrHz3K0t0zsNgaZlTLYEZNLBwDfgfx4XXEEoEDQQNBA0EDQQNBA2cdRp4pqPxs+6CQ4PPWg0U+qwyqBWk718tyH/btm1327yiYhUfHR75qU3/5Kc/ebOl/LZu3nw5x5Zu2bbtq9lpcxxnGQgWAVVUoffNU+uCk8EysGCVhQJLXQN8LJe6HsL1Bw0EDQQNBA0EDSwpDTxbo/MlpdRwsSelgYU+i/7g1Uf8pV4rJN3T3fMdy69rqL8mOz+dViA/NTkp7PKKiuzsyPB3WkZDQ8NFORlRlPLSwSKgCom7n/num6fOWAsLcsHSgiYCDRpYoAb8l3GBxYN40EDQQNBA0EDQQNDA2agBHyWdjdcQ2nx2aSAOGcZdRdwgNR/il/qefPzxW+xBV9/gay2trasV5J9KJm3yhF9ZWVkOb2ZaAWZJaelmm0F9Jhbgm04w3zuERcC/Dh+5ct6z3Zft31//unPu2549e95jL7yjo+MvLTUX32Pp6NDQP1l61z33fMbS1772tYctzfoVqtezXZ9ZlxwOgwZOrQb8l/PU1h5qCxoIGggaCBoIGggaOCM14I/Wz8hGhkadlRoo9NmKG4TmIMUsDeQg7q997WvLbN727dtfY+nU+NTNQtNT11s6M6MAsL6+3iaj9vZ2oSf7x6wBLAgjI6O327oG+/v+1dIsy4BN2t/JxghoaQHFHJ7R1L/f3Ff/Psr96zp69P32alrb29+bfVWjY2PZySidTAny37V71+ttxqWXXvqwE8inV9/CEiwBOZoNiaCBKOIlDboIGggaCBoIGggaCBpYQhrwR+1L6NLDpZ4iDRT6DPmDTR8Z0pw5ET7z8SeT6RdZwbq62istLSkpPtfSkZERS6Jp57Mnqj/pfP2rVq6U/Nq6OqGn6q+kRC9j2lkamEUwPDj8FXuOo11H/9rSBSBXK25/ZwuC9e8/95n7m3M/jc//Xfbi1q9fLz5/H/HbvOxfcZFWz/0cHx39iM03Kzd+MFvOHGMRIDbAy87oM1gCfM2E9JLVAC/rklVAuPCggaCBoIGggaCBpagBf/S+FHUQrrkwDRT6rPiDSpCgf5YcZGhW6HueFWhqahJkn0xNv8Gma6orL7SUH4geX/x0WoHf6Kj6jrEAIAdy3HbeeVIFiJL6qIdof/j5KOWQo3wcf3BwSGIFDh8+9CFbZhFYBPzngfvO/Z7z/m5/3vNkBcayUs3OZwFAvz4dHhoWfd577z2/afPMLIFjTgZLAEV8i8DZYlmh/YEGDZw2DfDSnrYThIqDBoIGggaCBoIGggbOPA34o/gzr4WhRc+VBvI9G/7gEeTnt1egnh+tX1FWcakVLKmouNHS4pm0hOmnnQ/f8uwPRA9NpXT+flGRnn5iYkLkpqamhIL4oeXl5cI/55xzhPLnI3X4z5RiCSAmIK6+Z4BgqfLZRrJxzwPPgX//c+77S1760vtsw6sqK1stXSjyR6/+fZtnloA9jf1hEQiWANVH+A8ayGiAlzfDCAdBA0EDQQNBA0EDQQOLXwNxo/rFf+XhCtFAoc8Ag0Uf6VEPPt9qyzDR3m+ytL6u/pWWzsxMX2NpOiqSKOyi6XTOeUH+IH0ra3+kQdTMvy8u1uLDwxr9z3z/VEoBHxYAs3a/1LNq1Sqh/IEkQZY+n3ShlHqIPWA2AOVLnc+72M0agF/sdiMkuj1r5btCfdpU5VsE4J8uGvc88BwIHRgYuN82oK6uTlZSpDELtQBQDoq+uY/JZEqeq77e47KuwMaNG//ByWIBgPqWAKr09RdmC6CZQBetBniJF+0FhgsLGggaCBoIGggaCBo4UQM5KOzE7MBZhBrId8/9QaGP+EF4ohrmdYP0zbx8QfroDUROGkRPOg75p9zufMgR7Z9OK1CDTkyMiwj1gvzTzhLQ0iou52j58uVUNS8FUcYJjY2qxaGvr19EGtwKg5z/WFeX8IlJMMhX0tXVYhiJiH4H+ReXqLpLS3UPgtKysh5b4NjRozLf3SDZT0sFs3/5kOys5AKOTIzGGituoukPFFgs7rmQ56Oz8+i/23rM3gs3WVpdVWXJgn3/Usj8gfhJx1HWZeg73vcVK7Nqzao3e7LoD7ZvEfAtAcgFiwCaCHTRaMD/2C+aCwsXEjQQNBA0EDQQNBA0EK+BfGgwvmTIOVs0kO8e+4PAOZEd8/Q72jt+2154Y0vT9ZYan7xCbJswv2mH3KdnFEhNO8QOQlYpEwvgov3hQ8nH5x+H/MfGFfm7UIAIS8P0tAK1tJstYBC1VLl+/Tqh5eUVQqemJoWmkgoIDx06qOmUAsL9+/dLuq+vV+idd94ptLZWEf39O2Q6e1SZ0PQvvu71Lr9WKLEHa9fqeSsrK4VfUaHnJyYAxEoaC0GNsxwM9A/EzXeX+syfj2jhF0oFsWPJMRaHTxZYMMcSZMpI2qzx///Y8vnW+OcchSJ7ng8sJ6Spx6c8h8XFJYdt3tHOI++zdJ69GnxLgBW3vziLgOaePXs10N5AgwYyGvA//pmMcBA0EDQQNBA0EDQQNLB4NZAPHS7eK1+8V5bvnjLomxPpoxb2vW9pbfs1y6tIVIhvHx85PnbkQVykZxG5AigQP/k+giPtI/8MoneWBeb9d3d3S1VtbW1Cib4nJsAgWuGvXi2u7YjYAMofO3ZU8kHqjzzysKQ3btT1Ao4d0yB8fPlPPbVL8isTCaGPPvGYUP6Wt2uMweWXXyasZctkk8KIGIG6ep2NQCwAlgDWKcAS4KexDFRXVQuSZVe82urqtfZEcyBaOX++P+7v4Mj4n1vZ+vo6iT3YsnmT3Od85U1+jgXA1PdyW+bcLVu+lF2WPRqyednH3PdsXiHH+SwBrBdR7NaLwCJlDFM/tfXv27/vDy3NWpHRJu0Pi0qcRUClZv/zWQhmJfUoxBL4Ggnp50wDdAbPWQPCiYMGggaCBoIGggaCBp59DeSM4p/904czngINFIr4ORXIP+fegwg7Vqz4YytYVla22VIQuT0u5AfSipP1ER9pzuP7hJnfD4I/eFB99YMDAzmnGBjQqHwQ/8/ul+nnEekcYZOoTKhP3sxTlyxiA5Crq1Vf/vFejQFY4c0iaG1sFNGefj1vcmpc0n29fUKbm5s1//hxoQODg47fItQgbqGJhEbHV1ToioVcJxYBLBvGIrHSFjAWCQk+KHIxAsbn/hGpyP119fTJfYNXWlSkJ3KMdDRzsT0cGBqqsbTRXUcyObnKpp947InvWhr3S83MDNm89tamndkyZYnKP7BpED/3NVtmrmMfyS807dcJ8ofP80gsiuGLhWP1qlWix0MHDslsgQcefEAsAll7CvB+YBGgSt8ysFAQtVCLAecNlgM0Eegp08BCH95TduJQUdBA0EDQQNBA0EDQwHOnAUa5z10Lwpl9DeRD9L68n/YHdfMi/rZly2TltMqqqu3ZFflInLw4PvlQECC+f9JQ5EC80H379knWkSOHhZ5zzgahQ0M6/x7ffdKt/b95yxbJf+yxR4WuWbNO6Ko16vvvdvPy29rbhb97t/ryl3eoz556trh6RMj8gbxragQoR/jmR0aGRaSmRoE1FgDogQP7Jb+8okzo1KTuXUAaC4Zkmj/qN7sgCqvdtbPBxQykp3MBZ73jE2MwPDL+v2xB065jlpaV63lLS0vWC394SIIPhobUAjE6OmrZZtaE1rt69WpJE1ORqK16nsvfKxnen6ldLnwqHYklobamUhTpI3eK+Xw/7T8PC01zHj8GZZY/N+AuKauQDNOe11lZs1fB9Zaa2BGxqGStJGjZC/nl3rATS/rv54kSc3PmvpC5ZS03WAzidRNynAZO9mEMCgwaCBoIGggaCBoIGjiLNRAsAM/9zSsI8ZuV2gTaZvko1elsNtLzLgHED1vuMfP4161dJ1HfiarKa6wAvmaEQfj45OFDT0BoMfP+fXkQ9SMP/1yyLtiuBoch50Ovc77oVrdy3+5dT1GF0OXLVwjtPa4++aNHNYp/aEhc0tY3LvlFTpubzz1X0g8+9KDQyxySr3W+fWGavzK3WyDz7+Hno6wHMD6h52c9gMoqjS04drRLqqhwiNysiZdT5eHDauFoa1XLxLGjAuCjHTseFrmEiwmg0JVXXiGH527S64JfUlpSbY8PHj4iiL2+rk6CCeprqy+0fO7X5KTqZ3BALQGJRIXNjpiVIAnzZ2I/JOPI0a798Oai9Q0NappxmZzHl/X5ftqXJ81eCt/9Tw1JuOmlN0lWwq2ngBwUX38m7dafIO3TmXQy570ZGxlptTIm9uMvLe081v1HlpqFLe6xpLu7R25QTU3VEzbd19cn/EOHDumNs0zzy3o/lTE7q4B0PhpnQchpb75KTH6hFoNgKShAmYtVZKEP1WLVQ7iuoIGggaCBoIGggSWlgWABeO5udxzyZ1AmSMAg//W2iddec+0XXFNf5ChycyJ+U04mol968aWC+JvbWsTX6coumGAZyFcQhDc8oj57ouMrq9VnXuLWvP/OF/9Oqrr+ljfmVAkibW7S6Piebo2iLykRYGvXIxB5VvIjdmDdGkXS4+Njks8eADfddLOk02kN5ja+cUkTrS4J89fjovWXLesQFrMEyCdWgHRV9aiT0+tiVsD4mBpmJqfUMrDpnHNErqZWYwmOH++TNMgfPtdDrACxA909aklAb+g3045Eeb09Bvkbi4Mgf7Mng4jgc2eWAci/vV0ej8zKjaw3MDU5ISaMtoaGJltB98CANlhqs9H/LWvtYZU7r2OfMmIWchREet/998v7UVqin6hv3KGWo5dcc74g26aGWrlAov6zovylLeyx4PN9/cU13FQuejX1iOmhpa1N2jUzPXPUlulYXv07SldJFVhijOVA0vzVlpSL/HByfJ/lpZMpeTHKy0slbSwJj1j+8OjofkvnWZfAZs/38y0HfB/mK2Pz8lkK5LrzVRLyz04NFPqQnJ1XF1odNBA0EDQQNBA0EDQwpwaCBWBOtZwWZhzi52Q5gzGQ/43XvvC7VmAsNf5tBB2dE/mbeeHvt/mV1dUS3c9a+F7ZE5Ig/Djf/wkFPAZIq8ch1oZGnQf//e/9l0je9DJF4uvWrZM0SP8/vvJVSb/pTW8RCkJralFkxcp9w8MafY+v/rLL1CdODMO5WwT4ZvYEwDJAdPiY84ET/Z52UfDG5y3nJUYBCwGIWDLNH+sBgOC7jnWSlUMnpzTqf0WHImwQ/ciwWkTw7Tc1C8DOKZudGBpSC8PKlSuFzd4GIPpsWXtcX1crguVlxUKTbo8D9FNephaU6hqNRVjh6qUe9DA5OSmshsaWTfbAWADuszRRUSHfipaWFpHjbaMAAEAASURBVJklYHnZP/SczSvkuLgk9zF+4okHc96T9S7mYSKl7freTx+T92TTOrWobNugFhZfLzyPtCEdFQmSNZVL/cjzvCHnU/KLZqal3OTkuMx6mEpO77CykxMTguApZ/YekAef9RcmqivFpGTWfZByY9NRq5U1syiusrS+ufWdjlpiYw+ExlkOJpOTD1gBYhCyLAZ8y9XUJbXM+bdQS0GwEMypxsXBzOl0FsclhasIGggaCBoIGggaCBrIpwFGjfnkQv6p14A/+BIoZJC/IDiQf7I4vdme+q577lEI7a3BntUsuZfFJaUCjVmzPys/5xDEn8OcI4EcSAgREN+dd/5YWFdddbXQJ3c+JfSKK64Uii9/xu0OODWhPnLmn7/qla8SOb9+kC97Chw+fEjk8IWD1EH6IFd/5UB8//jApRLzB6Km/jE3P37f3r0isvOp3ULTSUWelANBr1mzVlgg/s4utQisW6UxAWaeueTjyx916wewPgHrClAvaWYD1NVVS1aNQ+xc96Sb7VDudhWkfGN9rZhA2CURfpGbFoElqM6tIMgKgGY3RxGtcHscjLoYhrKSSBAryL+tpelcK1hWWizPLfef8+SjfpQ+8tMpBZi79+hz89hD90jWtgsvEWqQtNCqCrVcbFqnJXftU4vKfz2kAPzmS1TvWzdsUAH3z3NlatGKPD7rVOQUmiPBc8LeFKUlRaLv0prq9VZ8fGziJ5ZOT6dlmspUFCkdGhZf/9CQWrCsTPYPi4FZv0He+8rKxBab3zMzKRYYE8sh1IRGyINYWlb+cpu/cvVacwoTO9AzcNTSiZlkj6Vm98x7LTUxBZKew1Jgs+f7FWohwDKQo9esikPsQJYyztRDvxM6U9sZ2hU0EDQQNBA0EDQQNHAKNRAsAKdQmTFV+SNkf9CFE1TuxfXXX/91W09xQtfiHz0+/BWbnmN+sX/vJM0uZ5s3b77blpthYrxNzPFbqM9/z5OPSy3JaUWO7HLXffSI8Kc1yDmzkt65my4QftrNy66uVaQGUqf8jMMLBkFp/Q6ZEq1vfK3C9/9YMQ9LRblb498gIRFNpVS9RNlTfmREfeyH3YqDnZ06nXv5cvXdX3ONuGiNj1ctAClXH5aCIReTUHFAYwioF4rloG9UkerqNaslC6SPHJR1DJgVwAqB8BMOoY+PqwWlukpnSeBDT6b0erG0UC++bmMZEha7H4KMKV/Mc+IsNeMuFsDsgXCxLViZKBWLQD7kH4f0aY/vm3/4EUXwt932HRFZu1b1NNCnz1d1k973imIHTN3Kik1uJca9farfz/67AO1o+YYhqec1V+qskHUr9X5y/kIRP/JQZhtgcUKv0UxaVlosKyt5pcgWle2w1I8NoB6fYjGYmEiLRSGVnJRZGNTPfTLnFT4xHSUlI5Lun5mR2QpRSbkEQ5hyV9pzVFaUi4UAS0FPd8+jlj8xPiEPuj8L4YmdOyXWw3xnDlu5OX6FWgYoioWANDRYBtDEGUD9zugMaFJoQtBA0EDQQNBA0EDQwOnWgI8iT/f5llL9PvL3rz0H+RO9b0b4260gUekjI0PfdAXj7hV8qc9EBQsUMiP+n9pyrPjn6iiYgKhBIBRce464JKM7vv9tYV2wXX21//P4k5K+4qoXCR0a7BfKCn9YAED6vs++yE1HnnELGxKVnvHhOwRc6tYRkMqz/kDoCaf1pFuhsL+/T6RYeY/ZBFgWQNrnn79N5DgvVbOGPogfRM5sAOOTFdHyRBNFhDJrgFiBnMyshPGxS4p6aQ8iWAxqqquFxXOBT7rcRdGn3PVSzqclxaoYs16AnyXpCrciInsMTLhYjYpKXesfvfsWgIUifk7+9MFOOQT5TymAjXYp2+Ttkvyqtq1CQfxRpPrqGxkSPgthLt+gs06Od+vr8KkvyoJ9xiLQJXJvuH6V0PYWAeyurPXZxwHVjIgcTE3lWlh4jpHC8mK0fKHlGZjbZqmJERGLQKK8TGICLG+un1mpUZBxskjbQ8wHsli0eE7SzuLDc2+uQ95/Vnw03xGxEEy6dR2aW9qvsXU1NTUOWFpSUSEPRG1jszwQL3zhCyUIo7PzqHw/Soqix6zcgUOHvmVp1mwDVYRl5v7yWQhQtHtDM4XlujOpcPCsaiBYAJ5VdYeTBQ0EDQQNBA0EDZwZGvBHY2dGqxZHK3zdMtjKQf7mUgWyGMQuTlAQu1kx7LBVQ0NjgyAKc5gzwjazBQTKbN28+XIrN5lMC/Q2PtQrbDqZmhaoPjY6LOdtcvvT27y5fiB+8uKQEQiQlf72P6k+3HLn21++cp1Uga+UefzplLgkTSy2Xj6+TM5HTADpsTFd0Y+1/tnFjuj9CeebZ2U7yh07rDEKazZeBEso8//xpROVTz3DLkqfPQZA4uwdwEqA7B1AuvOoQtYf3y1AL6qvVEDT0bFSzrusQ33R5eWKXKmXxoHooC3uPmFxAJGfd975UqTK+f6pp5LofRcbAFJkHQDuI/dj1SpFwpyf+8n58T0zywDfd2WlxhwQMzLpLASz9QDw4ORS2tHv9iL49Kc/LQIgf6RHkmpJOXZILUob16v+6laLixuxEyjI/4QMx5gaVkvA1Zc0Cuf6SzYKrakWoBxXLGOJ4/mLFfQyeJ6LikokSMFYDH5oRfD5Iz4wPCI+/ER5uZjSJidG5AaVOUsXszioj90h0SftwqJFGksRzynng7Z1rN5pj1etWimmlrrKSrEEJNy6BYmqagmeMKEhwq8pLhcLwcDYsFgGWI/gkUce+bqtZwGxA1Y8+xf34ATLQLaWTtMxndJpqj5UGzQQNBA0EDQQNBA0cCZqwEepZ2Ibz7Y2+TplkOUjf65LLABmBbCDlsEa68ODGv3/wIMP/KHlg/TNvvESI1BdV/cWyzcIrtXSwaFRgaLsC9/V1S3Iw/h2BWGcd/5WKQfSB0HYsoX8QIr4fEdHFaHjOyYKHV/0lFuTP+2N40EyIBTO3durLlJ2A3zI7eJn5jGLiLluoawgyIqAzF9vbNR8fOZRkaqdee4TE9reri5FggMD4gqNQEj47GkP6xj4lgJ87+MOAR84sF+KYAFoqU9ImpUAWTcgLrrf3C+RJ1aA9qSOa71brnyJ5Dc7ywDXD1KvcDEEIHwoeqa9xDagX6nU/HFf4ywAJaXyeEbMEsACMO7WTaAeaNxzxfoFn/qbvxHR48fksYzK29UnD/Lv69HZJFNul8WxkX6Rr6pR5L5s1RZJl9eqZYDzLpSWT6iF4XnP2yBFX3TFZq3XLICQ/WPWBZas7LxCjv37kErNiMmsqKTkgC0/MTkpPvXamuobbLrr6GG5UGIufEsZ95fnktksPI88P6ZeW13sj/tdX9/4tBXasGnzzy1ta2kWi2JNTbUstcj6BqxTYNalEMuAsbwJLSsplu/O8NDwvbY8MUtbtm37pk2bnx8zkGPJVJGc/2ARyFHH6U3QOZ3es4TagwaCBoIGggaCBoIGzigN+Gj1jGrcWdaYOF0yyAJaKKRyvn/jy2+x12mibGUkjs+5r69ffG5t7W0CbQ2SEKQ/MDCci/R7FCr29fYK4o/T2YXP3/46m9fc3CROz0ItASBE6sUCwHxukD9R/SBO5EFOIH6QfvfBJxAR2rFOfdwgXeRyhEyCFQBBwEUO6Xd2KnJcvnyFFKE9vb3HJY1vtKFekSfIiTX/aTdR/+vWr5dyWAywRAjT/IGgoDt2CICKzG58IoIvv9Gt+c+KflgoiP4npgCEPnTgcSnfvkmj3zecc4GkaSe+f2GaP1b4w/fPbAuQOJTrI728o0OqYNe8lNs7AL0mKtU3zoqSWAA4L7suUg6+P8+e8/3rv35RRO54QC0x5QlxLVMsAvHDIB8+lgDyT5VFgNiAmjK1NL3g6ufLKS7ers/R8NCgpEHenB/K9ZH2KRYAZg0wWyAqKj0kskVF8uBUVyZeYNP79j2tmxx4FfmxLxOTEyLBuhQgfp5Hivtp+FDWx2hqahbWC1/4IqG1bvfKybGpH1vGoa4ufZEkd3b3SfagwDJQWlYiD1Zlebm8kCZW5N9sEfP+iOXjFFoGPNuia1ggC9IAndOCCgXhoIGggaCBoIGggaCBs1sDcaj17L6qZ6f1+XTH4Gp+5H/xpX9um1tcWiwIvc4h1OGRMYEegwMDYhkYHBo+bOUGh4YknH50ZESgVHVNjYZn28ys39jomFgMqqqreiy7rrr6HEu3bNm03VKQHgiPecU2z/4Wivyp57FHH5XyP//5DqHMl2dlvOddeKHwt25Vn2tlhSJNVvADsTz99B6RY9c+EM/BgweFD5LefO65kuYPi0RzsxhWYGcsB8wGMPOjJY8VBrEIsDIglohlyxQpU5GPxPr6NHbh7nvEBRqlkjrbwbcAUB6khaWn0q1cyHxuLAAXvejVUoS1+0Ga9Q3qM6c+kD9r1INUQZysrEiswo9++CMpeqG7Dxdsl8ch4jzEdMRZALjPnH9kaEgO8fHDp70//OF/C+sHP7hDaNf4ekSEguxB9CD/HCGTwBIAn3KkG1rWyGFTqyL3k40R8C0CL71qudTb4SxLnA9kTzpDUXyGoQfIE9WP5aa0rELe64ry0pVWklko3S5WBcsNzweWNNaz4H2ZpZM5Z8byxGwSyj/2mFqaxtxKlViu2p1l6FWvukXqaWhsGrcH+w8evt1SY1mb00fP92jj+vXygBIzYJ7XDbacidmptdR8WO62ZHBo8NuWbty48dOWml9KSeY/LlZgzvObUsEikFFd4Qd0UoWXCJJBA0EDQQNBA0EDQQNnvQbyodiz/gJPwwXE6YzBFIifU4vP/4EHHnieZaxZtepVlhLFPz4+Kc43Ez0r5ZtdtPvhA4d/bOXMfuzinPRH3ib6m/NZsdiRuWRm/Z1/3rYX2ySzDcjCIkAaBJdJu5XmiAFgxbEH7r9PRL7zne8K9aPpKQ/CqKxSxM9uf5vP3SIi+N6RZx48SL+ttU2yKhIVQtvbNQqc+fX4yFnzHmQMgsLXOTioiPXYsaNSj4+kON/6deskn/UTuF5iGrAEDAyqj3jHjodFfmTDQaHLn6/XWdqekHRJQn3M6YkmSfO3ZVoReNe0GHpgL5i2FwvQipb3qUWkpVGRcFq3sY+wDHz84x+Tui+4QM+LXpevUPnzzztP8gu1ANBQLAGjbj2Ch3c8JFkf/eQ/IDIvnS7bOG/+QjNP1iKABYDz+esR3HydWhrq6+sQKYjyfhHdj8WlvCIxYCsoLpoR5MzsmvvuU4uSH9U/i/TV0sRzza6TzDbpcEi+NrObpH6WsKix7sXdd98l7W9r1feJiymvKJPDX/qlXxaaSFQ+ZQ92793/mDAK/OM71dbQIA9+bWPjJlvUn0VArICxQHzQVX2yFoFgCSjw3lixnE5kAeWCaNBA0EDQQNBA0EDQwFmsgTg0exZf0mlrepyuGETlIH8T3S8+vauvvPKdtkUgfoMExDff2zug82f7+3dJi0ui6y1ta5PsaM/eg+IjY56wyJi/htoagZysIAbfp77FgHxiAc7dtEEhoMtgVzjkfEpMwF13iwsv+q//VMQ/4PY5N7uh+UUkXVqiSIJMLAH+/Hh85qtWK8Jivjvl4qL/mScNwiX6n6h/45uUKrAEYCkwsRPCB8n7KwKya54/q4DYgaG1u6U8yH2wRpE/7S2Ubhq7IUf0sZ77JV3UIcAwJy87MT6azE5mjhND+vzcUvW7wmN2AQgUZIkP+MILnydyxExcftllks5nAfBnBdCAgf5+OXznr/2a0OODGmsxNaEWkHE3vx/5Sm82AHyf+nstLNRisLldpq1HU03rpWo/RgDkD5/03ifvEfnU8ANCt56rsStXXXW1pLFkgeyFmfXH81nuZofU1wvQj7A0lZWJgTDCcmVm80hpfPU/+tEPJT2L/NXH7yN+Zocwu4Qm+LNM4ENXu/dtaFCfN3bn5H3DYnbFlVeL7/3pvfu+Y8sSi0Q9hdJCLQLHe49/wtb5DGIEgiWggJtC51WAaBAJGggaCBoIGggaCBpYLBqIQ7WL5fqeyXXE6cYfNAF9ZSj/5OOP32JP2rZs2XstramtFaTd3d2bg/jx7VsZ+6uvrbnU0uqqhFgOjIXgEZueTKXmdQ77FgEf+TPi9vnnrF97vq2/rq5GncY2YX7Fbl49iOOBn/1M+ERx+1HDkjnPX0lZRU5uoqJc0vgqQS5Exbe2aPR+S6siWQofPnRYDn0kTz57AoBgzHWTJZRdBfHls8Y9swCwADDboLREkdmqjRul/G0tOi//QNH+nHpJVEwrf+PMt4SFz5/8fLR+ZLWI9JWqxSJdfGzOIvmQ/02lb5Vy6ZS2n+ulMhDpv33pS8Lauk2vi5iHSy+7XPjcD6L+QfyUp744unu3Wkj+7M/+TEQOdCqyxRJAOZD94MB+WAVRLAeUp9D66g45XFam09aPJfV5In/PVLUc+jEC5ENB/sM9dwjrxS+7SOimZWoBONw1Lun7ntDX/2VXK7I/x+2WCYKuqEiIHCtZGl+6pEtK9DOCRQpLTXePtnt8bFTk7ncxNseO5j4P+OiJgcGiIIXMn5/2LQPIsd5FS4u+b6ycWeH2fqhy60GYWQtiIkgkqsZs2Sd27lTTCBUVSOO+R2aXSvkwrFi+/GJbVbXbk2BmOv2oTT/99NPvsnSOXQnzzRYIlgCruJif35nFiAV20EDQQNBA0EDQQNDAYtJAHMpdTNe40GuJ0wmDJRA/9QrU6jp69P2W0dDcLMh/3O1md/RY9w7LZx6/PbY/9gefmErKLAB245opnrne5rMbnoniPmTTkVsxzI8JkLx5/nzk768PYHz74vSdGB1cZavp7OyU2nY+pQgOxD88PCR8ovjr6holzR/InrRPQSzXXXeDZG3ZotH/+KankurTJnqfFe2oh+j7keFhYbErGvklxXpbiGVghcCkm5cfZwFgXYAjg4qYj7g19+9evY2qTwldXqZI8tyKf5L6sLAUWnkc8qf8rZMfkMOZtD6+A86ni4UDOSj3mRUMr7/+esm66mr1bbc4SwwWAJA/lgDqyUe7enrFd/y/3/cH8v5gCYgrh4XAjxWIk4d/qQs12b4mF/GTfzilz2ucRYB1CCb61eKFReKi7WKYiy6/6BypqrtfLif6ycMpSbMewcZyRezl7fqZQJ+bXMyAWVtf5NEjFKSOJYA9Ko4cOSLy+/fvE0rMButpYFnz3zves0ItA1K5+XvNa2+VQ7PuiFDeS9o3U1wmSNt85+62ApPJZLcInuRfe0uzvMhdx3trbRVYBqiuvbVlrT02u6E+39KK0hJ5sM2siI/Y9AJmC+gNC+sEWLWd8KNTOyEjMIIGggaCBoIGggaCBhavBuLQ7uK94vxX5uuEQRLIX6CiifKXsOJrr7n2C7bKuob6aywdHx07bOnBQ0cE+dvj+X5jExoljYyJCpZ6pibGBLIQ3Q7ymkrN/ERki4vVWUhBj/rI38vOJLEIPPrwz66zzIMHDkreuGsX0f1E8+PL7O7pErlq5ytkzXthmj8QCD7+m256mWRt3qw+VOQKpSBmLAHd3QpABt08/Am3Ox+WAJArvn7WNcAn/pNUvZz6WJ2u9PZ0Q65Fo9B2LVSuuWKPFNmW/oTQOF8/9cYh/9VlakF5ScU7tZ4pfWyPHVNfMVHdxDZQn0/vufc+YS1frlHyrABHdDt6ZPdFv3yh6bHxiRkr+5m//7Q0tLNT2znSr77ng6Ma3Q6iLk6qnkDinGf1uM5/X6YuY9jRynLlrypTZN7QrhadjIA76Ezr/cYi8ONj+hz7FodztqolYeu6a6UkUfc7nk5ImnZSPxYAYg/gv+r3/1AOeW9A+sQA8J6AuJm90tWl+jne0yPle47r6757l1rmeP94L5ld41sGsAjQHs4HsocSu3DllVeIaHe3nhdLGu0lNmlodFQbQsV5qI/4fXHfAkC+iV2QG7l69Sr5PrF+ydjY+O1WZvnyDjVd5F9JMFgCUGoWpXPLYoXDoIGggaCBoIGggaCBxa4BH+0u9uud7/p8XTA4ykH+VNDT3fNf9ri5pVkQ+9DQ0E6bPnjwyC5L+TXW1wm06R8ckrB0sza7hEMXF5eI79+3AMyk02ts2XR6SmYPGCRPVUKZT1xVLa4zu4a7OO0mxidkpJxKpyVtkK4gLrPrn07AdrWYNdsH7WH/wKDkO3aG/Oi/v7fNJo4fPy4QyETlSh5Ior83pzrri8uU1XKa/4IbZMAe3fCCF0r+hz/0IaFNbne8d75DkWtD46lB3uwBQOwFK/yZ+yLn/Z9dOj/9B62i3ujZQvxy8nn+iA1g9gAWAR/511SuklpYOfAFzW+QNJYadis8elRjOAYH5DZHzJpgLXlmN7CnAOsk3Pad70h973nPe4Ru3bpVKL5qqDAL+MNy4Iv29Q/Ic/eBj3xZ3repLvU5I1dcvk8Om/rUwtN7JBdo+kifcvnoWMvqHJHvjapPfmJc9ZWo1Od827YLRA59srJl99jKnPJ+AgvAje251zN0oT7/vAdE+6NPf7bKkFtX4/hxvX4sAOxVMe4sXeyWiSWFWB2/XVXVNcIiVoD32LcE1NXq9+Tlr3ilyLMnRG+fvs+0t6RUZwPs3X/wDv9cC0nHIf64OrAEMEugtkZvWHIq+VNbprWt9aVeWTUFmU+pxw+WgCyF0MllscJh0EDQQNBA0EDQQNDAYtfA3I6yxX7VudeXD/nnSB86cOgLluEj/+HB4ccs3yB+SzI/kD8MkD9pnxaVlBywvLHhMbEA4PNmXjExAVNTSSk6NTnRYA/q3S6Cpn4Z8hdFemvNimICHcfcvOIhtxb+QJ/6FPEZ9zlk729mBhJKpfV8RP/jM5ZGZP29+92/KamVqxSx0t63v+Mdwn/LO94t9Dvfu1Pob73rV4XeeuvrhJ7sHz7qsjrVP2utr3Br3G88RwHBsr4JOcW3hzS9Y+q5fQU6ky+S9nRGSjMWgcpvCR/Ef3396yVd6laOm04rkJl2NwxL0Yhb+ZC13qVQ1l/CzUtPJNSXzXNV6dJHOzX6HAtAHJKnynz5yEGbGhvkfdu0XC1bD+98WrLwna8sVUuNe3yjTdUy7ZziJ1B8/c3Vel97R/W6EMTnT/ruIrVYuYUgDXJslyzWwsdC9T9H2oQ/MCavI8VjaWnjBslr3qDtT488IOnDDzylZZwlzK+A2BRjuZMsZq3ARx4LDml2qwS5m9lBksX9Z48A5IlhIO1Tyh88qNd7zkad9ZBwMT5Trv7RkWH53lSUlYmCnulsAL8dpEH8pKFHOjsftMdZloBrbJpZWFmzAyzb/kqUnGAJkOfQ5IlFysksORIsAEvulocLDhoIGggaCBoIGogYZwdVzKMBgYjs5tfW0S5QdWx8XMJkTTSqOi1dBSB+fP/z1DtvVnqm+JAV6O09LlCatfFBuhUOsYGwWTeA/b+JFeAkRBmD+PAFEwWMb3BickqKEP2PD5Qo4/XrHdJx8+WvvfY6kV+9Zo1QE3MglBXFQDINzkLxpje+RvI/+4+fE/qXf/VRod/9rvqg/+B3fkfSWy94ntB8f/j+0Uu+9DVtNVLlNQrwolG3XsM3dDPG6JERvf7Bbl1vwD//6YoduLBcLRKvrHuBnPL8iusFmRQXRYJU2IuB3RjRK3pmjwKiyNlFjrXgSbMHAj5dZkks61Ak/OhjYsiKXnTjS6QdzD7x9eAjf+47/Hzphx7aIVUmuh8SWumi+KtcFD/nA+EPdKl+fD5pn05UiwEtanIZh1Mr5WjN0H6hfU36ABCdPzSjz2/nUS0xNfGkyLE+gCTMnx/9T/7Gqnon4iwYLlU19nM56ulRnz57S8zMOAuOe1+4P9xX3lfjc5fyvK/sEeCqzxB/pb+pqXLJY/YMPn9m5VCQcjwn/f0awzDsLElVVVUi2s3umY5vvk/rbIZZd0MvjApjKD7/Qmcnsd5JnCWgv69PLAGJRMfL7SlN+99rqflOi+lsjhUDbbb9AXqJBVDuEv1HGUv08sNlBw0EDQQNBA0EDSxNDTy3DtDnVuf4gGgFgyF8RuhG6MqVq/7ICoI0hwYGJeqfwlCQP5YA+D4l+r/KIXnSyFVWVf3MHj/x+BNiAVjWsUyylndotDJyk5H6PkEKIEF2J6uszPWJwmc+MggAhBBFo1I184sbGtSnPj42LvyR4RGht/7mrUITDiFIwvzhkwbJgDBJv9ghy/vvu1+K7NurvtKe++6S9Htfr/Tmd/+upH/lzW8RWu3O4yN87ocIzfNHOV8EC8jrWzTn9S1626eWK90xokDhi3s0qt4vX2gahL+9RpHZmmJFtNsTPhBxyFDwf3ztaYccQXgg/7FRvX/4dKGs7Y/vn5pZq36j8/kSE4KFADkoCJ801OfHpe/88Y+lCCsC1rco8l45lBvtT70+8ocP9X3/ifUrJGtCp/cjlqHHay6T47Vr1OfO8zy2Uy0Rx8rVUgDSB+FTAXsIkF69vFIPh/WGTSQPSLqh62knsl7o3r17hWIBcJknvC/wQfzcr4mETCKKiAXIZwngvQbZV7o9CHjvS0r1M5dOqR6YLcLumDxPWAC4n1kxSfJdGpuI5MKqqqvEIkr7oT7yJ02+T0H8WAD8fD89PDQoD45ZD+Bcm9fR3vHbTuZtvqxL6wXPZtIP5HnjZgsspiM6vcV0TeFaggaCBoIGggaCBoIG8mhAYU4eoZBtVvqrr7vY6gGfsUGUCoWdcoaHhlvtYUNzo0LlKJKw+TiLQBzyd9VFjITXrd8gUGLf3qfF+d7X1ysiTU2yjEDU1qq+TEb27IqXKtWBLiN26oUiB0IACTNf2OxjKKLML66tVV/kW371V4WPL9r44iRd4Xb5q3BIAx8m0eqct9Lt0vduN9/8/374Q5I1UjmEiNDv/+AOoaxV/7a3v13Sl12mCA5EjwWANJX4afj5KL5rEPAl9YqULrtYbm90/4DGCByY1leHmAF/NgGI/5V1Osa+sIZXDcSvfOcKjrCQ0D58/6Sh6JO9E0BqzPsH8WMZ4L42NEjwtllzXS0QM0fEhRr17bpXqm644I2coiCKnkCG+dLHunrkwj/+Tw/LhScaL5HzbJt5WGgzlq3jB3PO78cAkEbIj/qvdsh7IFKTzkyZAMNoulQtA+W1Cvg63eO21u2+F7ndA9d13ylVP1inUfD9I0o5X3lCLWJNrVrf6mF9H1cVHRGRskk1Pfjt2rNHgGp0xRVXilxxsT4Ps3tf6Ps642Z1oFeeQ3YRBLHTHtYVYHYHSJ69B4gVwtJDOSyFzBpg1gjfEWKLqA9LBOWh5juy3h3PaQEo1OdPfSB/vn/wfYrFtLSo6KjNM++73Oja+trzfdmYtL6AxmgZk78k2ChhSVxsuMiggaCBoIGggaCBoAHVALAk6GN2vig68anoqKioaM6RLsj/+PE+Qf4oNF8sAHI+ZSTcsWL1YZt3//33iAWAefj47BnBE91LVDOIAF8fSNk/D8gQHyGIkf3H08kyKfLiG28Uuqy9XShRwpwfi0LCzSJgXnnC7SdOtDrIlHnMb3v7O6S+v/3bvxXq/7EL4cf/6mOSddnllwt9C7EBbgUzv1y+NIjVlyOGgfxMukiR42UNiqAvcUjt1saKnCoo5zY3NHkKMNIppVhGKHQC8i/WMTl8ov9BYmYlRynKiofsHodliPvBPHBmhfB8jN71bSlf8v0fCK2mIT/6gByd+6d/AWdeCkJFKC49OjomvtX/+9dfzwEbq6tVb03Fa6WKplJ93RIlnVQpFB9/83pl946qHHyQtp+O3IX1d2isRXGFM9gdr5WKVjufPSfbNPCIHurjHkUuJuHw1AHhH3OxCpMTjZKuKVPf/6pJRf5FSZ3vP7FX05nYBTVERMzDP3jwoJRft26d0GJj6rE/7rckzB9r8DObJ53WWB589PUNzDrQEsT2YDFgtg/In/qmpjRmCAuAb1HgPcayQAwPsSWcn3YmSqdX2eOZqOR+eNkUn38+SwCIn+9edh1zHbN7KnnJZEqeM6MvNY3mn+GW852mnqVGc17KpXbx4XqDBoIGggaCBoIGlqoGQLlL9frtdecbBIkT2CBYcfaVlmVGmDk6G+jtF0jQ0tIkfN8SkCNcQIIRcUfHMgk/37Rpi0CjI4cOLLfFTf1SC+cDWYMwOEWHW6sfhF/hool9iwD8VW4Fv/UOobCb3HXXXS9V4vvHR8h5SadZoc5FqRO9b/Qm5fFJEsV8wfnqsrv55pdJ/h0/UGQqCfNXWqaImzTzx5/evVtYb3UWhG3btiGyIJpB+K69pP1KuL64fObLT7pdFLGQIF/urh+9F5foY1dclPv4gfg5P8ifmAZiOtgFsdftEjc0rOsWENVOeRfKEUU//29hgfwz+e6ge4MYmKKrL7rOz8pJY+HwEX+OkEnwXPzvD31RIO6evV0igg/9WBrLiVqUIgXq0UqH3BOjDpF7FYP0YcelFYcjFUW7HlcfexTpPPeBkVzA6oB6dCipn8TDbh49ew8wS2Ffsbx+0VSXtr+oXJE/7QX5U0/k9mZoqlLk/dhjj0qjWKESxE5Lp10sujM4RcwGqK/XGI6+44dEdFnHVqF73Huw8ZxzJI0Pf2Zar7esXNvJ+4alIeWi/ycmtV2cn9lEWAJ5n1kPBDkoFoKikmIJzjHrAuQoFuSPJYByPgX58907IT0+LgpoamyUGzg0Pi5RHFgCzC6q8pyZ3Vh3urrlu22O3ZPlnzHz3Q8xACeoJjCCBoIGggaCBoIGggYWtQaCBaDA28uKf80tVZttEYPkJEzeIDNxLvoxACY6VcKFUzMzMlIt8DQZMUbAMLaet10g796n9wgEqSrTkT2Ir6ZWo/YZuVPuwIH9ckjMABaB2hr1hRIjgDwUxNnmfP4geHz5rGQGsgCpgjCoB5+3mEdgGoqPsdzNCnjDG39JcteuVd/o5z77D1nSJx6CSP7kT/5EMm+55VVCX/+LbzhRuAAOSD2digMMrhJnKaBKrs+siS6s/gFdCc7sxihporonSxSQcH9AfiA2LAPUix65D/j0h9w6DD1un/hZ379aAFjJkXpW7tZ1FkqefhrWnHTLq189Jz+OGWcJwFLyVx/7qBR99NGeuCqU72IB5hcqPHe0XS0ZzTXrpdDMwfk/cew2eG9UJfLHhvS+LdNg/2iVtzJhNHCnyI27WQO7ex6XNHIZ5C9cs2eIm93wVJU+HyX7D0oOu1Wywid6c8UinpORqXFh1bhYl4qEWgKQM3uSyCHPw+rVqyVNDArvJe8tz+WE21WQ95f3kdgBLAflbu8Jdo0kRoDzQ0uK5o8FQC6OxiF/5E0skSD/vv5+UUBbU5Mg96KyUrlTWBrHhofvpUyg+TWQa3/MLx8kggaCBoIGggaCBoIGFoEG5h8eL4ILnOMSxFc0B99noRuh3d09x6xAQ6OOwM2IWaCqQWiPZRcE+dfW1Qr0yTcLIG49AH9E3NjUKDEIF118iUC5hx78mUKdjJNXo5yxBNAmkD9pLAJx6wggBwURgEiJAZiaUqTM7oFEB4M40s6ZaWZNSFUlLrrd+Ag17SjnAXn/4PvfhyV0oP+4UPZtH55QW0Kr44+bpcjs7192KNJ94ltfl/T7PvdPQtkPXRLz/IFo5xGZMwtkjk/eWIpEDkSHHkpctLePuIzvUuRTbs13LAroGx/rsEP+A/19Im/2iBCKJWRoaFTPm9Rd4SRh/vqGFIG7rQ9gx1LaXcSNdfeJGIfYgi7jE3/9cTn6zu3fnVN0IqkxMtNlGyU/NaXt7nOIusrtBrhcH5M565iLCfJPVmhMQf9BjTk5NLNCxDcqwI+KJ3ZJWrUYRfj4H1R1RjxPUV1u7AnnBNEf9gxFPvJH/nBKZw2UOIsd/P171XKwboPGwLA+A+twmF09RXRwUIAvxaImtwdH5O5PS5Pq81h3rqWF52hkRC1DIH+zm5/UNTmR+5xgiYCC/FlJtLlFoyQon2mQd+DvEojvP18sABbPzHfP+fxB/pyGtLGsCmv9qhXbyLPU7F7qB4/wHffuWHappXscLABL996HKw8aCBoIGggaWMIaYHS0hFVQ2KUfPnxQovBb2tpk6Nna0rTSlkxPpwWZmxG25IP8WRnQiMwbA8CKVn4rThgRT07KCHbr+dtlhLtz55MyEXhwsF+G5mm3bzf1+JYA+FgE8B0Trc28fGYLjDsfIdHClB8fV8TNPGKilfF1p9M60Aa5Ug6fJusC4HMvdj71z3/+cyJ6+x23UURoa2+u+mpcruLsHFFJPPjQQ0Lfe9PVQv/wC18VunK9Is6TRfp+dD6WkKEhRVTMlgBBQ0HyaZ2mbOZ3q0UES0nSRZ2zpQLztckn6p/1EED+BunIdREDknbIf3JKfc2Saf6qMivrqeUKvk9rHZDCV5yJ+XD3h3ScJeBz//AZqfJLX/5nv+qc9PiE3s/KhLb/55l0ncidn9b8qmp9jqJ2nf/uR/uD+EtqLpVyGAxA/jknzUqsLD0sqRY373+sQ33m7/vj9wl/y/Mvy5KOopEBbedE1z7h94xpuyoPHpX0gQOK4Kcef0zSlS4mgFkERW7XQWeAiDY2q2Wibp9aADqdT5/nZ+PGDVIPlivePxC9ZJo/ZpkQ05NOyecnMiuGisgyF3vASpHMTiGmhD0FqK/32FNyyHoRxKQUl5TJ986cRx7cPbv1eacclPc9PTOxzvLYJTAf8qd8Bvm77xxIf8KzBBD1X9vYuMmWraqsbKUOS7/xrW/xKNC36XSIbKHcY0AwswHUtJArs2hTXPyivcBwYUEDQQNBA0EDQQNBAydqgFHSiTmBk6OBRx99VIbIZgUvGQmXlBTLSLG+rnZ7tuBYMqVOxmzmPMeFxgD4VdzwwpcIhPjB7d++zuaNjo/liJRXKNLIYZoEFgAoPkIsAS3Ox4hv+fzzL5AqzEhcKIiCekG0UBAvPkfSZqQuRaqrFQuBaJ7YtUf4//TFz1KlUB/552QWkCCa+7df80qRft8nPiH04mteOG9pH+n7wiD/4ZFRyRp3esG3D/KHcv2+5QGEX+n0YlYyyzkVyJ8V/9in3V/5z4/6p5JUOtcSAN+nFe98k7CGjygibV0jAC6zMl2JW6FvalJ9xszawBLwja9+Ucr/3ec+lVN1W4M+f90Due2Y5edadrAMaCRHFB07qp+mi13x5o71Un9TnQN8XXq6mb7c5xyff05jTIK1+tvcbIihZVr/ee/5sIiuXK3Im+sjKr66Rm1O6Y4VKucqvvgagKZjeITnYfB4t+SMdupzPjaovvrRMo0N6DqqwQdm10+R+8Y3vi70f/3yrwhtahJDX+Z+sB4ASD+VUn20tCqdchagsbHc7wEWBqk064/rTE5pe9avVz2UutgU9qRob2uTUnt2537esFBkqkyNrbLHFVW192d45iCfJQCLZ3YZe4wlAD7If8Xy9uXwLMWysWPHjkPZ/AKOlyTyRy/BAoAmAg0aCBoIGggaCBpYQhoIFoACbzYjy5e97BVSou94j1gCzAhYRpBNDXViCTDzu2WCvokBkFkD+aonBiBRXiaxBBNTyeZ8ZWw++2+/+KZX3mnTX/nyl8QSMJBUHx2+YPYGwCJA1LotY39YAuCD/A8eOCj5dXV1QvE5zgaHKwIiyr0onetqwycIkuU8jW4Nc1bA+9CfflDq569Q5F+0QdWUODoqRTPR264iP/0Xv/VbkvPGP1D6ql98G6cUClLPYZoEiB++WdteDlkbHaQH4odSHxYFEysi5eBjAZmZUR+3Xw+WBfQ+OKDR4L298phk1panXVCQf9tgPyyhB4tyo8kT6y8SfvM+9R1HCvwz11scufvr1kXAEsDukA88oFj9y9/5R6nnnK0aJZ5zUpOoz8FpUTTYOSgis5aAXAsB5Q9WlsvhhFtB8SIXTT+WWomI0pTe/13T7Y6v9SO0sapeDlmrf2jZiKTTr/ojoalpxUB9vYrUo/SU8Gsb9Pniulm3gRUQsYCIsPmD392pALTrsbska2RYEX9Rk7iso/IWpZ379kk+K3cS08F7+69f/BfJ/423qiWg/ZzNkmbXvmq3jgcWPFYMfPTJR0SOv+XLvRvgMhrqVS91boVBov/hs7cAzymxO6zkSUwB5/HpgmcDTE/LAwTi933/q1evku/bsrYWaTiI3z/vbbfd5h5oPyek59JAsADMpZXACxoIGggaCBoIGljkGliKFoAZd08FwS/0/jJ9HSQ3NNAng6iUi8I2PrpzbZ3LVi4Xakay/2XT+fYGMPN+FXJEiojwifnRsbau7B+WgKuvvuZpy7/rrp9usHTcrUkPssASYPPsL84iwB4DIEl8hOxaRtQ/iKjEIf9pp5iSEn2kQBDsTtfv5q+vXKkI7mtf0+j8pw/o0t2FIn9tvYmCdusBJNYrwtEIA3Pd//MYIjkUi8DnP/gXwj/0E/XJ/vonP5Qj5yP+abe3AbvwEQORQexuvQOeByg+fx/54zOtqMh99YjBgOLDHXArCw4NKbIF8eH796P/x906BESjR7ku8mj1jM4GqHjR1XLdw499013/LUKxcNQ63zfrNnA9X/m3vxO5r3z9P4TWL3dIsmy9pIeSe4XWuXTTCrV8HNmtyNq3CBibQE49WAiEmfV3oEwtJcd6FcGXNspjnpGY3VtAWctKNGaBef+RU3fp6z8qAiODel5mtRQV6WeB+fcVler7r3YWCJ73zAndQc+xI3LkI37k6jZcKYdJ14DOI2ohYBYOszh4X5Nu5b/OLgWy//j/fUXKv7tePw/nnr9d0vUOwR8+rOdnvYBzNslnJ6K9rONR5taZKCsvl/LF7j2ddrN22BUU378ImT8sG9x/LAW81/5ugqws2nO8V4IG6urrxLSSLwYgKi6WYAjz3ZNTt7e3yQ1YuXL5tZbBSn9xyJ/3xpxHK5Ba5C81exiOfA0EC4CvkZAOGggaCBoIGggaWAIayIUhS+CCT/YS8S29//3vlyrwjYH48AlPTEyJZaG2rk4gRV1N9U22gImCP2xp52ENB/b3CCAWwMrYXz7k76+wtWHT5p/bcv39uivhIw/vEGissftR1N3TZbOjurpGoTe84Aahra0a3Ts0pFHZO598Qvg7n9ot1P/juokFIA2iKCnRATcIhPnE+AwPHxY1RHfeeadUXZnQGIPKKkWI/vlA7pVVigDJr3QrAUaNej3w6y68VA5BUnEWge//6Gsi1/fm/UJ//a8+KbSurlaoj/xHXNQ/SIPYBxDUtLMEsB4C86x5PkD+Urn5Y10EZgMw/37Mm21B1D+7/bGvPPXgM2adgOM9alEh36fpG18srL5d9wr1DAQRz0GFQ4r4frmOBx/eL+WI7u8eEOAWtTUoopZM8zcYyeMYDSXVQuBbItauXSOiVeuU3nv3bRQVimUBi4B/XRqTb3aL9CwBOZVkJVb/wq9K6ik3f7/VW9mO55X7gcUn4SxcIOEBFyvQ+fMfS334+DlVTa1G49duvERYg24Fx97jGgsw4CwP3E8sOamkPv89/f1UJfSJp/R+/s3f/72k3/9//o/Q1hWrhK5cqZqodXuBzK7QOS35PI+SMH/cR/glFWoRaHQrnGK5472mnE+xhPn8yOHvmrrc9xW5OEtAW0NDk5VZvnL5RkuN5WClpSB+3jvug83L/qWTKf2wzDID8p/VRexRsADEqiZkBA0EDQQNBA0EDSxeDZQu3ks7tVeGb2kqNS1D9ER5sUBPf6Q87eZfD/b3iiWAtbNN1K4M1VeuUSe4GYnL0L7zUOcx21L2t45rdZxFwLcEXHzZFffYOhobG59vaSYmwPmG17t5vps3b7HZGV8h0cKbN28WPruFsdIdyIiYAKgIZ/3hQ61MgADU5wxi+NxnPyvSIFem8yZB9Fl12UOQf5wloKy80iuhSfhlziKAT7XmSC5QYOXAD73+Jin4a5/4nNCOVWuFgvyTDqGBnED+mbSL8vdnAYD84bO2OusmoNcpVz8rLBL1z65xvu9fGmf+8B2PjKgFZ+1oUrMU2CEWEfUPo2yyi8Mcyn0FqbZlfMYlIoelYXWHIncQ6/CEVlObwOak6d1PqIWAk/izBcZmDpA1J8USQOZkf7Uc7ux9XFmOlicEQEYt9e65q71Y8rf/ot7XPrdnBLtgcl8rEhUiR0zG9Iy8thGzV3i/B59+ROS6D6uFTE8++1+1+kJJNLqVBQcG1CKCBQefObEjsyX1qLRMb1irs2gNjms7kNtzQGcpfPTjfy2s//f9HxBa7XYJrHWxKjwPlIOy8ibPbdrJ+1H/zHqgnE+7uvS5weLp51dV6/2pqXEHvoBLV5SWShDHls2bJLihsrpKED/iIH9iD+DHUaPXfXF5MXw1kcRkLhV2sAAslTsdrjNoIGggaCBoIGggSwPBAmDcYk4fCnFmlYMPSSGC409NTB60h5UVupRXOiqZsemSKC1DdhCh5dnf1KRCI0Mlf2xEqzMrwAnUZraAcdhLOO/Rrm4Z6g8ODQtUjUP+Wnv8PzEBHStWSz0/+u/vbbPSZk1vmW+LzxOEQ0347CtclHF1lY7oe93a88eOHRPRIbdLGfOC8R1WuUXtqR9Ee+jgASl39Kgi8Joa9f1z3ooLLpJDkGy0dzdZQrEE5DALSFSUq5d73SpFrCPEDLj6sSzsO6YI+gNv+RWp9e1/9lGhGzaeIxQED2LnPmd8qm7N/JkZHqfcxqGPRKJSMogZYL0EVk4EKbIXA75P3/dP9D++Y2IdVq5WBMz+9LSi6Mrr5TD99Fdh5VB81yBCLACsQ8AKjsmyo66cPhcg1opGnY9/eK9m+5YATsbsALOLBiyhPtK/8fKX5OTvOiYGs+iIs5T4CJndBY8P7pFyN75YLU8dbgW7H7qYkzYX80KMxvLmFSKfTstrnDlnv9tt8Y7bfyC82ip9XldWZUTkoG37jUIrEpqBxWxsTPXB/fWj5amF3f+wmKXce1FWnkREKDEt+w8o0P2bT31S+L/3u78vlFiN4hLFdMSwUAmImtk6WKJYnwPfOoib55rvw969emOffPJJqhRKLEuzW0G0vb1d+I0Nqv/B4WFJr165Yr09WLVKgxeMpalVMtxf5rwuuMhfZyFbdq7jifEJ/TDNlRl4sRoIFoBY1YSMoIGggaCBoIGggcWrgWAByH9vsQSIk85EB4tzj13bSiN1GhIVj28XhOhXj28RaqKuxTKQqFQfWHNjvfjCWlpanmfLDg8NChQeGRl7yqb7Bgdpj01mfn4sABmsE3DzK1/9Y8urr629zNLS0lINI7YJ8ysp1rEgCEK5s9HqZl6usNgtrFsNFdHRo4oIB53P84izeFCeFcr271cLAHxiAJhXDL+hUQwUUeoCRVxYBCbGO0WElf+Qj6Mgfz8/Y3lwFofI+c59i8NnP/sZKfp7v/8+ocyT5r6CkEAucfP9sRwwr572EFPA2u3EihArge8fCwDloOiP6Ph1y7Co5M6mSL3m7VKk6vBPKTonPZSsEf5atwZ8rfMt0x7WtN841ihyu8rVYHZ8UC1c9ZFaBFYKzjNrv/erXDSQe98PHdDZAU0rLshpB7MCcpgm0diiCH2jhtxE+/f/uxPR9iJfVaPnu/U1vyWsl1y6Tujd9/9MqLGkCWU9hXXrtaEg766uXAD5xCM/EfmpPnndo15Ho5X62lx4xQskPz0tr6/Zh16j95mGzn0lBgTLkRQyfyBn1uNg9z/yK10sEes6YAEgtmX3LvksRJ9xuzC+8x3vlKIgep5P6jPvuxyyrgN8VgDFAgW/ws0OOHpU9fL5z/+jZGGxYLdRkP/atapvdidsaNIbt62+9lZbkOvlfWDWC+fDAkGamIVCLQGPPPbILsoGWrgGigsXDZJBA0EDQQNBA0EDQQOLRQPBArDAO9nX37/fFmlva7k2uygWAd8HjA/NH5GThk5NDUh1IMGysnIZnCUqE+fajGXtLUJb2loFkkyMjcrQ3LcMxFkCxkbHxOdmLAByHnz9PuKXTPPHWv1+GiQLcgIBYBFgHjnlulzMwJFORfDwQY6+BYB8oqJB7NDIeQ5BwMhD45A/+dRLGotDdJFaHkC8IK7Pf/7zIvqud71LKEgKS0Da+f65j9QL0sHXWupWlIOfdiuwESPBbm1EV4P8QWjUC2XPBiwiK7X5UbPbB77ypa8TUZB/XNQ/9T2UEMNTdJWLDuf+kn90r/rgSde5tfm7yxT5D47vkaw2B/yJCVgdaewFswXMvAWRwyJUluyQNLEFWALw+W9yJ2x2lgBiA75/3/ckh1kBN7+wXdIvu3ab0L7eHqF796nvGssVMQAVFQnJHxkdFdrkfNgP/yh3PQLJNH/NDvlv2CYGtKivX2NGZu+jWl7w+bPuBfeVenwKYndbbpj1P8TQmBFjnYAWt0XIwJD61HlOd+58UmQ/9lcas/KOt7xF0k3LTFRR1m/QrT+wf7/GEBDNz3dg7br1Im0sj0J37FBLzRe+8AVJ8zy0tKyW9POfr7MemDXU4fYcoD7znMv3CySPPqSw+fMRvx+AhVyh9GhXl5pq8hcg+ATwO52/yOKVQAmL9wrDlQUNBA0EDQQNBA0EDZyggWABmFUJI8N5ddLb2/0/WkSjw2eL6xGWACYXgBR9S4Cfpp5kSpuRTCoyYWW44VJFHMYyUG9ljWVAaFtrk1gGmlqaJYx5amJcfGETo+NHrVz3wECfpeYnkMj4rCU8148BUJE5/ot1lkM0rbMckAAJEzVuVjqULFY46z1+XNI/ePRRoWVluWP8Fa3OEuHxk0lug57JR+zKnf3HEuAj/3zlZmvIPcIiodqPot1utgAxAb/2zl+XAtxX3+KDz9eP+ucs+ILxuYKM2OWNtdCR9ym78SV3ql4v9pB/3c1vlSJVB74rNB/yT1Yocp7crT7s4ksVCXJekF91UvOb6tZK1nG3+17V6KSkx0Y2Cu12+963LR+RNH+s94B+B1j3oVEe02h6VC+ka7+WuGyTWgaIvk+NKfIlff2my0WwvEleg2jrRrU0jI4oEHz4EdUP5ycGoN5FpzOLZdkyPc/DD94hoiD93sOHcupfc+6FkiY2w0f6RNlzPihr40PhV7rZIOzR4a8PkJxSiwLvExYhdgHEUkh9rLD5Fx/9mLB4L8hnb4+2Vr3fW7dtlazzzjtfKPflP/7jy5K+/777hG477zyhN774xpz0CRYtt2tkMpmS75AIZ/0VF0UaLOF4mZgjNhfJkp3vkJgb34Lwvdtv/9l85UzenLFTecos+uxgAVj0tzhcYNBA0EDQQNBA0MCJGpgX7Z4oHjiMNK++8ipVRpE3hoqZB+5rjtkCviWgONIB9HTk6nX1TU7qABakwEp9Y6zZnqiQEbaZty8WgYb6OqGtHcvEx2V8kRIzkE4nnZfWb1FM2kP+cbvlUdqsbyCHWC7Yg6C0pEz4HR0rhYJI4JP2LQXUC/UtBPmQv1+ffz4/zXmg4xNDcvjQIw8I/fGdW4ReffXVQn3fvzDNH+shsEdCckqRMj5jEGScjxjER334gvft1XasrVNfceWGF4tI87WKwAtF/tR7W4NaXJwhAXaGEjW+s0stAGSUt+s87wuLDghrYlItPENDOltkKKkWAGZvmFXeRW632+WOdRluuFz1uHX7tZK/Ypm2ZNoByZR7H1i5rsjFXEQXa0tm3OyVlJvH33Nov2Q88biu2Ee0el2dWgp4b4gJAPljWfCRP+2aRf6qL+qJm9+vrTOza0pVL5MTev+ZhcDKmyVulz527aMcFAsR6zKwu2dfn75PrA+BZYB8VmysrdXZIT7y37JFLQAtra1yqqNHjgi9+eaXC/31d/2G0EzMikP4xKSMjk/Ih6rIfR/4jpmYIvkO+TFE086S6FsC5CSn4O9b3/oWD6iaTvyFJuLPMafFIl58ceV4vdfiurhwNUEDQQNBA0EDQQNBA3NrIFgATtSLQm0zVf7ErAI4ziKAwwsX17RbK56Rso8cfX6Jq2BmZu4xmtv1At9YAABAAElEQVR8LsIigEWBFfloqalXKjCudoFgZQ6Jg+QzI3U3Qjd7GeSM4JFjZTHajc8z5XZLY41xEC27CtIOn4K8fX5c2kf+cXLwfeQPH+qfnzSWCGYBIF/fsFYOf/qTHwtdtmyZ0PVuPjm+/3K38mC5izJHL3G+/wm3bgI+X6l0jr9etzIdUf/b3/AakcJnXR+zwt8cVQmrpOZSl7VXaMU58xuGhsfUEtLXptHzDphHIOwaDekwu01WS31jM4qU90cHJF1Xpr7nm29+haQvuVghfMKtO8BCfAOD6uufBnE6xN/TrTElROuj71IXK9O8TC1LDct03YBlHXp/5GTmj5X/6hNquTiw5yHJAvlzfeizY536xom2B4nzfHM/qd+nxMiwFwD3F+TPCpqU470lduQEvrMUJNxzxToNvb29IspeEZRbs2atHF5wga63sHmLWq7WrF4t/OqaGqFE6dfXNwgS5v03K5FKOjU1Kd8D3nspZP7MZ0W/E84CAx9aXDr3d4v8OFrovH/Ks56Aac84PI/yPffYmRVgff6SSp/cXVpSKgoXGzQQNBA0EDQQNLD4NHByKHdx6AHfj4xk57kkwtLFt3Tbbbd1Wtn3/8mHpXypF92aqYfYAOfDB6ETPe6AeWZ/bsr5fMrBRw4+aerFtwwCiZ2n7pBVJhqXET1riXuxDNTH+aBYAEZHx4TV0yOTDaLOo6ImxPLSExC4NxuAClj7nnQcxWKQzxLgl6cca82z611ZuUO+rsDt//mfcvSGN7xBKMiUtf4VH5n9zJ2FJM73jwXHbwfpoSGdj3Cs81FhXfSK1wotb1Lf7ski/752RYLRiFoAmAUQYRigAY5WP67taJjRckO1mtE51CwHy+sUidY4U0BT+YX/P3tv22zJcdz59XmaGQwBghySABcitVoBIjdWu8u15fBKa4beyG9lkqLWEfY67E9ihxXhr+C3Dn8FxISDq3U41rL1ZkURJECESUqiIIEQCQwWgEBgMPfe8+Q+Xfmrvv3vk6f63OeHvC8mO6sys6qzus/0Pyurqin/nX/xjVT/QkLkk1F67ZZLmxNfpClb3Ys/Wa+Ru62K+fSzCbm/b4iXCNSv/1rqD7kAM3t+/+Vv/4vGxBs/Tc8hp/793d/8ANMNZc7/+a+kOfF/8CsvNuUffpgiHsz1M4dPJKFj5BjDzpdECMgdeMH2Z2DOHxUP+VMPJUfgc7ZO/znbc/+f//OvNSI1gm/o81/8YuPg2az+Zar/lhZJ4Xfh4CjFWg7ff7+pP5pngGyIPv8c5ovGsP0zFKETOSTCeFZz/5r9Pz+av3W8f3G9nwciArCfv0I6PBAeCA+EB8IDN8IDtzkCUBpAPo23+qg+3e/vNwamTz3VTJ4agK4RfUI4I6aY8nc031qrne2C7EH8zL1RrsrMGY7HKduYfQhAIMiTzQvPl/R0kvTYwYv2kANpgfQpR+9TlvXPDoNf+hWbg7U5ck4PfGTniBMZeP/9hBjf/sU7jck8927IH6Sv6/nJatbsf9Y9a/njJykyQb89Svu0yz4Fnjzl/+e/+3fN5X/zb/5NQ+tTzhoKsl9ZBEDX/ev4YE/pO28nBPvFF9Jc7peeTs/Xl36R2kWe9fys+4enHvr4H/1mc/l/PXpIUYc+eXLQ8Mwxf/T3aZxG85805X//IAXEjt5OWfVHD369Kf95AszV5++l/jIHjfH3bS99kCzIl3qlIFbkHr35/zUiD174jUTtuPmjOe9TmgK+a+vrP/OpB43cdPqooT/96U8bev/ww4ayfwDI/wvP/1pTDvJ//LGtYpCzLRjXRrj+h/GGJ0JANj7r/FkNsFzMG1H8wE6RRE5Yj1/v6NnIsQ8Dp/3xfvJc8RzVwL55MD76+HFDmctfLPCP9XDd3c+jGqX33wIn9e9XkuP3zLT2JuQMaS4Av2v7GuT3SvXq8XhjU1afwZBCSq0Av99tSboSh2j17eL5X+l23XXcbXggPBAeCA+EB265B7ai21vuk0G3f3Rw+OZGsN4Br5M+zZfzyNbx88WuRpmz54tY5UD8Xj3lIKQ7d9JQ8qVMdi87Z7FjHRGKsc3x62cydukPyJ8sZj60QR6sRpjZeud6p8LmVtkP4Ctf+UrD/+Y/SXOs2NczCD74oAmo5PXzT93/VINkmDtkLpWs7AefTW5nB73vfe97TTucR/5rdjoZ6+nffjvtOPenf/qnjdxf/eVfNRTk3zD1P0/dT/sYkAvg5RBQz578//bffqcx8Yd/+F83FD/PDfGBaNuISvI8fqV9+vsf/+P7TRGnIf6m7RH/4M0U0Zj/asqqRw8K8tdIwOd++39oRP73H/9viG6lRIKofOvvftZcvvLF+w1dfJTm7Km/8/5fN5cHk79o6NGHaTn2//F6Quy//dv/siln1QQIl3aYEwcRYxcK8v/Ugy83RezhTz3+XaVDOStWDzxluQgv/vo/akRfeeWVht5Pye/5lMEHn0922RmQLP+8OmOeEDvtKWXOnx0GqX/ppZeaywcPUo7EfYtYEDG7YwgfJH9oGxmw30EdsWue/48ef9KAtA8/SjkYrCbi+aK9+neHWGNDyUEB4SOn/HgEIN6OBVuzyUJ+f53sf9rR95vys6YHTw6a/U3O2u5tsbd91G/L3cd9hgfCA+GB8EB44JZ6ICIA7cDzKZwmxdpyQHIzKcdc0+FikSYVqyql4bbyW6/y9zk7/FmOQCkSgDG+vOGZ+wf5EwmgPkcAJJs/1wPdbYvsVdvBRoQIABEEIhK0S7/ZmQ15TreD0m/04bHT8ulb9MgQ1+rjjxKSMaQBYnz2sw8aZLS0G3nm6Wcaud/6z/7zpoTIBKsWPra5XHIH2MEPP7z/XkLa7K1OLgGrEpBTSgQA+dcN8b700vcb0a9+pdmIsZ6bTIiZdeTeznGcAUAE4M03/7axw6l6XzpKCPBTv9pd3679gicS8Ku/9V81Rf+rg/xfeDetD//SP06PP+e2Y+ftd1IOwHPP/NNU9EwiP5un/h187/9uCnhpknR9el4Sq/7f/yfNzf8n/+lvNSVkw7MzH9n5jK+pVT+30yNfeCEhdHa6o57VAS2frpgIXlYpUjKp0pPy67/66Ubgs3aqINn+2GEVx2SSfhJZ9fHUvW5E6979pxtHfepeOq2zsv0zauTePJd1DkDzPNZ2G361OGoe7IWdsviBrS4Awa9X6eelfp8aPe6npso3VeT61MfpHRPdnN5prM3pU8ncPnyfip1sqCvJe9otrXc6LEQCVJ7fCfT4neL5gdfIA79D2CPS+eoPX02hJypiz//WEwOuuqM/QCFEwgPhgfBAeCA8EB64/h6ICMDwMSQS0ECCjz98/0cb1ee/kPYuVzNr+4Dny5kvX4B2ytmtP/OtAEStdjwePbL+tR30MtKwBtGjPbJ+dZUAX+J8ad+x1QKcAsYXObkFXnvcN/XcN+WaVU2/kEeO+6vLG6zDfVAOMsK/H9u+BOQiMAf7Wcsd+O/++99okFxtp7E3MajEHGwdEWjKOX3vE0Pyj3+ZssiJGHz8cULmTw4S0uV+aA//EfGYTJM896eUPew/+jAFmH5r9GuNyIMH+72qD77yO43ed9/424Z6/3Ae/VdeSFnnyDE3/eff+/OmiCx1IgQv/kbKxj/6h/+wqf/Z36Z2iAi8/UvLFfjlXzb1r99JuRW0h3+enqadA0H4f//3KYfgn/6zf9boffqZNGk/mSYkTmSHcW/LE5ZhdQoRhcU6bTzw0lf/STOey3We9G7sg9gZ9/r5S8jdXow6m74xzHt08MnHDf9x2iivqtarxm79Hhht1Df7ezR808ixf/T5PlbVXPJcU859ajn1U7YMFeRPLlLqDdKbl0dL2rrjVyBw2ocelzl+Xao/Lru55veD90N5lVce+ddeey0tT1GBPk/QENBLxLcveYtKcMYtuuW41fBAeCA8EB4ID4QH9oMVt8NffCnu9A1fni++lLLc1TVMpYF4e/U2dzayOfqlRAxGNne5FCABgmhp+pAlW5922IM+84Zo+FIHUaidlXP2AHpTZ84PpK7tEUmgnC93IgcgL/ShRCRol/JsxxxLOZR6KPrMmdphZZu9zJuPX/wAQkce/ulnnm1MffrZBOjGX3y+4UFIvXYoMIo9ipn7hFf67W//YadotUxImh3dVrYzHkJzq4eHTu+lnQs/9zgt0P9XR/9lUzWWHQ3v30urKer18wka2n4S7AH/r//1f9vo3b2b9jdgX4ap7PWOP6bT/6mR5z7XNkdOv6CMOzy0fh6bfuTxXC2aceI5AomDcOGhB7bq4uDQsvdt3fvjtKx/g4CTPUJf1nBuzyJMvBf061g9RR2q8p3KHQzP32B9m/vn/j3THtLnvVM9IomU89xOLGCyttwlxhW586KMN88V7RAxgK9Xd6RlKhT0KZHbfk2U5Iy0cEV4IDwQHggPhAfCA7fIAztR7i3yw963ypfnt771B6ZLHmtiSbJnIpAv6hKSaBFBsjcZp4AEkQDqsTO3vbwTPmtvA0SkyAI9+oMGdibWcUUYfEbzwKBfQgQ6x8RqAyINIxAGkRDLlsY+/eU+4DVCwH1Q3+dtys8iHNjDn8q39tM4kFuA3X2pRoLG7BRphupz1XeaXK13r0dXZXY0zM/Fp1IWPHIzy+mo7NS++dFB86h+eJR2AiRSc/9+yg0gt+HJJwlKq5/xH/aPUV6Bpoj3gnoiZYxDXd6Rz3ISeTomj0iH8vxQWJJHTu9D7xM5qMpTrtSzo/1UPXhF6C3C3+quyl6jerJ993Ol7znt0V8eE8qVDu2/6p0V//LLL6ekkaqypBMLnZYbGJYMUbZzrSX09/la30x0PjwQHggPhAfCA+GBYR4A0A2Tvl1SCnrP5e7XrOd11uuXGuVLHcSvX/TUqx2vHOS77uUCJHeoUzw72h58RgxkWYPsDKCDOBSxKYLGHv3Ndq2i1C+QKEnUyDP3D0879Ack5kUE6KfOtYIUye04IrfDEBr7KbTQLbU8GaXVBfQDulgBeCgp0ATsq9XUdjocW2xAdnDEyiolv9en8aURJwLAunl2pEMe/8BD8VfLpyvkbdg3ORlW0cUkRAgYD+SyP2UgqMd+227XLuVQxgs97FO/L6W/Q/Vo15PP/TMBUhi432qdxonVR9hpIwWUdGlrx0aCAeFF7Ir3OO4z96MncbYFRKaOvffbXxB/PwDu8Gw7dk2t7X4rrulNRbfDA+GB8EB4IDwQHtjtgYgA1LNk5qLtk2mt/5hMa6DXw4cPf76p+qM/+qNGYjzwi5kvZb6cWQUAEl0ATdt2d16BVPgirvfmauQp36m8pRIEq1na9Jc5Zeby1QTZu5RrFi/lJUokg0gE7bPeX/W5f/yr9T3eIi7YB4HhN3jVA4nVe69p1VYee1o5GVvSvfWDQ9vWs1Q+XRhkF0UP+U8+SXvgLW3OXtSqJ0dpHf3dwhsP8sefUPzf3n+3hbXtKMn42LL4ekaf1wb5bq5MHi8iYSYG8vfGQcuxo+W0Sr81IkE91BuvUj3+QW4oRY/+o6f30fYLAJueP/TRU8qPm5bDE/GCn9lzOfDnDLW9qWbzK+8ZRG7+5OAtTybKh3tg2K/YcHshGR4ID4QHwgPhgfDANfBAAQ9cgzs4/y7qtHenxYOjRZOFen8yaxZUg1w7QjsYcgCIBPDl337xJ2UQI6sByO5t5/j2Q/4gfQ8ZUQ4CBKFARz1k171JvtQ1ItCV8jlyGlSCKV/8RD39yohIEGW99RiiHUrAhQgM+iAjeOzTLv5RREl5p5GaYe6f8rHN2cLPmIy13ADtLch/Ok5IHh59D/lT/9SdlDOwqlIOAOU9av0iArS21Qcry1VYk2TtxMvIDSASoPZ5XhV5jLNdVl10G1D/q919eW+csMO4w+v7qPXIlSj3gT58SY/6th/pCeE9ph6qz2Vv1YmdeYA8dJk2Tqxn0NPP3mSa/osY8VysEu+t/uEUwLG+f9bA0Pv1IofkANQ5KW9sTHI2C/2vKb/Xx4qaS32ltP5W8voe3konxE2HB8ID4YHwQHjgtnkgIgCnHPGjg8M3NybuP/VU2lLN7DGHybLm9st9WIMgzZyh4KjliEB3i3NHul/cIvw0W0i7lNNvEAvI0PvQZk99lrkTCei3fLISD/EUrTmIxNPjvvEHclruIUnk0PPo6CgBk6Pp7rl/Rf6lOX/a+/Aw2X32bhdRUw+dz21VwTJhgpWtMpjLzoPIg/BB/JRDKUcul9uDMbZclTaS1MUi+E/9jx2lPJ8ewsQO44V9zw7lKkc71O9LVR/e6zf1Xjuqx30q4lf9yTTlZoxse4mJnSXAjn8qf1J+aG7UUPv8nvzi7Ues/x+qGnJbPNB967YIRFF4IDwQHggPhAfCAzfPAxEB6I8pc0XddOUW8jZbozH3dLhY/NRMfK1vqi3hyxxEwZe7fuEzF51OE2/13St2+jJovLI5OuyXEDNy2g/a88rXa+Zq0zckc/PIM4c+trl3nZPEPhRkBu9R2inelwFe5vg9e5QzLowT5VDuCx6/efLIMfc/tQ3udEe/tR0rf8ciATx86Hu0NOePHsif9f+UV7b+P/N2AXJX5E858vBLixBMCvbQIyJAbsh4aQM1SXfOFHQFIs3Pddcznv8ZJ+ppl/GFRw4eipzWU46cPlfHzphoRHguVA99KO1A6Td62EHeo8jx++HJzSw0uZpbBMAiMciP5uZnZzyRg9Jf5v4pH0q9uf6S/ptvvvFDkfHm/kUs2OMeiAjAcW/EdXggPBAeCA+EB26JByICsP9A86XZpGN//OH7P9qYeP4Lnx9kiS91vvA9Jb7kl5UNkWXh5tUAsryaXADW7ypCHoqw6Rf9pH+UkxtAeWXZ2xkJ2GlyyLXr7NNcdGmVBNn/tAcyoj29L9pFrsdLEoUiN+xSPjZ5taf+0HrsQEH+8Auzq1/c66PHjUjyDtJ9qln/mhPQ10glPeQvgqujbstLOZMApC9qVUb+BtmXy7RvwYTnVRUs6zzbM0fg9+nSAm4WCWj3EUiCjIuOA8+JljM+2g149OChqrekgxbJWq0tYkHkzSIV5PxMLWt+Nks7ZtAOuTO8h5Rrv+mHR3m+vfpS+Yr3VZB/SY96L/ufei/7n/qzopzGOsAev5S8et1Q0gADN1kEp9zke4x7Cw+EB8ID4YHwQHhAPBARAHHIMZYvx50+4kv0xRdfMtWEZAAOIINjdjuXfNGv8/FdnWqfMeTBfgBQEHdFGr5voVMDIlEERHlHuGa0HD0oOQAg9nbuPn1zKvLBHvr99rQk8Z78dul6/77dyfCV9gs79M+rV8SPnq73p5zs/+rOp5oiIgHUQ0H+IH4th1fqIf8lOSKmML6THDI/SIEt5v4zUje5jPhtbniSJ+uTAMh/acuwlb/DVod2BsHSkOjEkOjKdqDLq7htVQtHBOT3yQqGjjvvV88/7LsgFYwz7Ul1/3Q9iwSsVhapsP7duZP2a0CfnAfWsRMJIDLA/fC+4H/KsaOU55GIodbDT8Zd4HvaSIDnV9rzsv/J4kduKFU9TmPdoU+kdodIVEUEIJ6B8EB4IDwQHggP3EIP7ES3t9Afe98yX6Lf+tYfdHRLSBNhvvDHzJGO0nrs0SJ9m/Fl31sVwBwkhoy2SLQ7tCAbET8xS7/VAMiAHAByBLyIgOrDg4TgoSCns74f7J83BfmvKjvEzJbfe+2C/DUSoOUe4le7s3vd5wLkvyQbXxWMz4h/2QVWivjv5FMkE+Kc2JkJR4bo76zSwvNJlebIl+MUaLOp/wr1EXPtlpOwslUUFfs52APCewYS1u57z6nK8Ty5yF8VhKcdnnsQ/9SScjLSMh55Sxmo0OP5Xtp+DG2EgICkNMyOkqPuuI4kl6MY+jKz65n1tJsaIo3uz/K7sL/mdg3OYqlreYMcB/X0z/jOevavVUF+Lq9Vr6Oz4YHwQHggPBAeCA+cygOFGdFT2b5uyuoLPo7YD+Ce3VCzD0B93Xxy19m+z23Kf1D/beh01p37A6Fs6o7/gTgoG6/Th+zS5hIphy5XgrzkPHnkWD8N4s7lcqHtg0hAIiKeWfQyUmKPfUNm3C+IjC9/j8+GB17QPuL0G556rxw5j9JP6uk/vNbnVRk2HvDIezkA3pw/eor4PR7kv0yHAVac0ji7k4AOc/7YXRvyHlUJiYP8V4YkmXuu7DnM/mQVisz9EwEA+Wekb1BeefqxtOeFHegor8aGZEkaMTn8ruOhPHLZnnPBfVGdn2cKlPKcW7k+X4jfu5d+Ju7fT2cuENmj3lv3TsRAcwTm8/Tec1aI/g5gl30m4D06nnZ/5tiRkd+Lsaz/5yyAtUUYdBUA/mcfAFYBaA4AcjqXT7vaX89PR0fpd/ILX/jCVzc69X4sH5muvQEVx2jyg0lkgCSIiAAcczb/yR0risvwQHggPBAeCA+EB266B7oTRzf9bnffH1+G3U/kVocvSnzW0PrLtpnMXazTLOZ4laBPab07SAUkAvLX0wHb5rtXIM2lfd+C3MiablcDJD1tr2utz9Ev9JAAKa1kLphVB3xmg3zWlquAHc8u9j0KgvDqtRx5kBrtq5zHo6/1pf4zZzuzrGs7VK2e204DNZqnx2tpc8F3ZK4WpE+78PNlQpS2gVtdbaf6Mf4gZlM8OOIxTgIzQ3Ye8s/r7nvIn9eCHnUp2f6Mf7e2qogMaPnEELV1v+pFAlTBeMaT8eF5JPLE+DjqvWL0qdBTCSkn9wB53jP6gxzt8xy0G4gmCd4P5KHsfwFfpMz5W2SGfSY0EqCIH7uK/Cm/aErEQyMD2g/qa3//WOuCP7kHIgJwct+FZnggPBAeCA+EB66tB0Cz1/YGrkrHV4ujnzd9md770kn6NOJ8bkPW62nCCqwGUJstwkg17LGuciBfkInWezx61LMjGsgfxCLAM6+jR64yqJQRm6y/ppx2oNq+Ii3klKLH/ar9kh2Vxz524UsU5I/c2k5dW03SHOb6KH17PzGAPTNoOR8bskfR6GySVg0QCaCaOX+QNOVKWd+/yDkmqWGW3y8tq37CgOYs+xRJGLHFZC/y021JEb+XA1DZfgAk2HStHOPy3LtFNCwngHHMSJxAheSiHLPUuQTJU0gEoSrskJflrB30ldI/LYfXeub4qYeuJPeH8sqQP4ifCECu3/PCm/vf08y5iRMpmB/N3900wlksxxokQnusqLn0gi4qdyv5iADcymGPmw4PhAfCA+GB2+6BiACc8gngS/TgycF7G1N1FvCJIgB0gx27lrYPQG/9P4K6EyDlRkEYZNkqggUhi1oN2NM3IfrILRcJwlLe6uk3ZIJitKfy2Edf6ylHDjtaDu/Rkl1Pj3Jtl3KlnMHATozUL23v/5Ht8LgiWcPWd68ysk0a8wwtsZAokZa5zfnPQehdsSrP4Vv50tbNg5wQn4wt+3+VsLfu/e9FEji1j73w874AtGeImLl92tOIAOVLyw1x5/7ZCIL7dRC3jnMvIkCDjr7jdrRyRCsXyAXPqRTXO2WmEu0fSJ8MDfTye27IPpc7O4ROJvaeEloiJ8AUde6fOX/s8rsAv7JTHTUSQL1m/1N+UZQcgL/7+S/+Str0kL+IBbvNA/rrvU0mysID4YHwQHggPBAeuGEeiAiAP6DMHek0JV+c7AfQWPjl48d/ubn4zGc/87XjJplrLCENdFgNAO9SZydAV37PCpD/wiazQTKUK0IGqHnNoE89yAl7lEORR472vHL0oCAc7KOHHeROSrE7kjMXWI0BcucwQkV8pXaZ26/T4xvRhe2MJ8vwK54v+oPdzMsqg0PbmW8q5eiV6NiQ5tLbF0CQtkYEWP9faqeG3klE7BX1CgKl91CfD56/gtmK54vnDnkiAWN7Trx1/MhDyfHheaJ8YpEd2mPbBOqVKvLXengP+VOvdKhf0APBw+9LiWS99dabKddqXwMhv9UDEQHY6pYoDA+EB8ID4YHwwM32QEQATj6+RAKarf/e/Nu//rONqV/9lX/wh8lkChwMRRwgttI+AHnO2ek3U4ZjS+/GriIbRz0XgzDQp2JfO+gpxT7rq9VPtIscyArkQT/g1T48CAx57FKPfXiP0o7K4+9slwYNmrPDG/eZ7ReQLfbWeQ/8rNlc0Iyuv89zyV3xzE1tFQZ7zlMxkv5QT3m+DxRytjyvQa7oXID481y/N/evUNb6MyKEgh861gcwZkefL54HLDC+8FpPOXL6HPB8Iof/4ImYrM1vnNkBv1raMhFTGNk4kUOCnYrVGLlgvwvtZ097wk6mKQDKDoA9OStgB0C3nqQMT2DPck5fHaBGOgsgl4juANXbI4Jzbs8dx52GB8ID4YHwQHggPJD2sw8/7PQAX5I7oyXvvvPO2zutnHMlSJRmQCggGb78QZYqRzl60BZpopEoyFB3PMROV7rlsAuSqtPXm0rmskHKithaC+mK9inP9igwSnuVzX2DmOknesgpjznq0fP4CqRqc9jM/fYRNJYTzafdUWz6mjuQcwuQM7oCwcvqAhFzWUWsniB7tHM/y7X3WqTXhtUCCzsbYMTqlcpeqx7yT3fMc5v96XXIyhk37/lp7SUF5L1ybU7leJ+Q47mAZ51+5u2C54bnnucRxI98XjViBbq3fpaznAz2A6C8RL05/4md2ljS13rvDACVg+c5gi9Rcgg4fXWH/O6Q1A7F21gVEYDbOOpxz+GB8EB4IDxw6z3gfb7fesfs6wC+TH//m99uVgDXM2kNlAHZ5p36Tpi9z1whiMHrnyIReCh6IBHK4dnxDzkoiLuEFBUpoa+U9rQ8TxmCaE1A+0k79EvtDOW1H8pru5ln5zkiGLLDYdt+Euwh/FZg5xXtqVBpHCrN8h/bYhYtV8PGe34F+dP+xOxSDrIjV0XnkLmfpSH/vMSGiEeOnGz3G8g9d1ueEyJHPB/IoVcq1/u27mAm75ORC+xibTskLuyFJ4tf5RTpa70i/1xPiM/W/0/tl3voxLZGLLJdLmTuPxfnFzKV4EfqL5o+fPiQVQBpS82KUJLbE1zEG+sK3saKiADcxlGPew4PhAfCA+GBW+8BnWK89Q455gB8w0cSYCUd+F3l/IlmP4DZbPbcRveVV1///oZODQoxRw4y2dRt/ijXOV0QEkgUquW9nef4zk3m653I0gcviMdDAEvboS4jf0G02CEXAOQHUuI+rNlTk4wwJHvbuw/uK+tJD7Tf3C9i3B9+phya68EP4h/kzprS7prcBZnbZxy8Hf96EQDrIHql/jK+JTmvnkgA9Yxf5g1Z5vfCxpusf06TRN6b26de7fM8lMq5T5A+etiFqh3KoTxX87ntmLkEoCKxnfay/EVsTE6JIX+qiQCMRzOKOpSdANkHgPcEIc0BmGDQBIjcsAOg+oXsf+b+s11ZpaB6zOX3ng+NNBARsvLar80ZAJ978ODrm7bqHVg/sjYPjR4YJQeA3C1+GXmDTSzIxgP85xbeCA+EB8ID4YHwQHjgFnkgcgD2H2y+MDu+q790m+PalkfpIPbpvc5GgXVGgAUQTpgDMLSbIEcP0aod5Nus5K4EyAeEtMqnyXXlzoqj3yAMtZv7axUgL6jKw6OHfcqhuR6ccEFIn/ah9ANexwUEX0T+e8755/ZOeQGy47nJ5niAckH3opUn8JbqewjSsYNca6erT7kifnqh+l5kC330GI/VIgHNlSF/jexNCEWhWKAl5K/qnAa472oARf7YBfnDQ7330lulgN5JKTsALubz5qyVk9oJve0eiAjAdr9EaXggPBAeCA+EB260Bzoo9kbf6QXd3Lpav2VNfWlrkxYJYHVAOYl1q5V6f7T07aZ70SM9lk3jSwjZQ8bYgwLA9gQ0qJ+YgoxL9wHSoyH04JXmcbgkxE9/FDFSDsLU+1Ae+Uwl2x87ud65UITriPWKe8ifB0UkGR/m/kdEKkwOJA5FHjNeuVfP/dAdT38o4qcdpUd2ml6OBOgLQgdU0Xg78qGtlTl/Kuxojkqm7OuEpG7kBPl9KXP/6OEveKjO/VOulN8Vz47Ke/wnnzx5Y1PH6avH5IjIHitqLpn71/Lgj3kgIgDHnBGX4YHwQHggPBAeuC0eiAhAeaT5kmQVABp8eTaT/XyZfvjhL/96I3Dv3r0mApDX/6MlVJGHu5zc9Ca2x7+3zljMt6whQl2P3kOSkm1O9jWGWkTGZDk1Z0zph6zz1lYUwed69NQOfBa8nAsP8dMbEDvjU9rjX7P+0cfeWdMe4qcBQbo8L4rEKUeNrP8JoQGrADn25CVrnHptJ9s3eeT0vUMPeaUze+/mqy7SXhylbP/FPCWjsx8C+mvL+VnZGQB6OuJaIiDoKVXEr/UlnsiZrgYo6Z13PasCaIfnCh766NG7utMqv7+IBD2BByICcAKnhUp4IDwQHggPhAeuuwciAnDGI3g4nz8aYpIpPp0q9HQ5PYwd1jw53R8gyxnSGBMJyA0bojFkTG5BRpBW3iKkhPxBUtm+XYDYKGcOEP7MqYfotVz5M+/IMIMgeoByHgZTz34Xc8wR50iAzPGL+JmzILPeuHMj1qLW89wgRn1+TmS/BzruIVXKsYs89uGx37bXRe7IeVQRvyJ/EPXBYbP4p1pYDgBZ6xKgqBT5a7s5618rjGfun+o7s+3r/6k/KSX7H/9hx8v+p/6sKBGU/LyZI7/7ve/+aGAbrP8H3BLBHah+u8Rw0u2667jb8EB4IDwQHggP3HIPRATg9A8Ac1HNQdpv/u1f/9nG5AsvvPDtDa0RdwM9yAVg7pEZdJCMh/w2NjZ/nAUwGacPXNvAL1XW/xIhwG6u8C7yHHlXg1UF3dK6/V4koGsYxAClFp5IAJT6k1K1ozztntT+UD3a0fZL+iB//EpkwNUzhbFRD9Zgz7XjVPAcgrwQA0HDVwK1tR47iGk9/oJSD5/bsQvKkVP71KOHHDzvG7xS7Gm5Iv7JKr3mR+sUSTh48rhRWczT+wjyb/3TfYNW9v5o9jyRnVX9hm/+vEgAOQArO/2vEd7xDzsA7hDZWcXzrP7dqXQGlfr84dfLPm31DG7tSpqICMCVHJboVHggPBAeCA+EB87XAxEBGO5f5pZ2+sz7Ul3lOdu0mGBs526DE0Ai43HCdms7P50v8aHdBPGUssyH2jsrOUUSQ++LLH+Sw+Hpl2eHcm0XPShy8PvSofogfJB/sR0RRJ85UtU/K+SfEbRB+MxbgzyntF9C+rqKZGiWv8710w7tMq70j+ee+hLV+/Dk8SvIf36U5vyPDlP2/5JlO9ZB3X+DiI1nf2g5OQDTaYpELCwSMHT9P/4c2h7+RX5lpxFeVC4A7bJKgNNWKd9CicRuqYoizwMRAfA8E+XhgfBAeCA8EB64wR7YiWZv8H2f5tb40tzqO75Uf/+bTQpAboccgFxgOwJy+tl64E5eeRXAnmcLjEY2V5k7kC5AUGT/9/eeTzGKLGchC0VkYrbHgtyHImbkyKbeV58OtHYu51t3bZEf/AuipH8ge/g62SJfbi6o95A/wopoaUfLkYcy58r4MocNr0iQ8qzPAFEgWf0leeppR3nMan1bvju7v3T/2IEy17+w+yCStlqkU/4On6T1/qzv32TfbP4U+WOPOX/26uf9onylyTx2mt7YEHfFciEzOLezOOTQPZpzKasWqlk6o2RsOQeewr7vDfdx1mcCkAPw8OHDn1tfOWaRiKx7C1bRfaE86Vtafjm/irfU2XHb4YHwQHggPBAeuCoe2P35fFV6ebn9wEd8LLEj4D3rFpGA5tN6Nps9tyl/5dXXv7+h0+m0mdQfV6tGj0gAH/aK1ODZ6Y8vcWi1TgGIHAlgp7GcY7Bptf3LCMayl3XHOJBljkBYtjKIG0sgUUVoyoPU0Mt2sMucKQI3jOInkJ6L/MUPWe+C/MG40RxzreRaMHePXB5XQfhZX0JCWd4EsIM89ZSD1MVMpXJD5/qxR3tDKe9ffm8M+X/y5JPGxNL4ofZUzpuz1x0BWR2APsh6bRGXif2AqD3szPJAJgvrado3YMpqhGn6GdPTAMeTZjETzWb/U6A5AEQy2np+HilJNI+j9T9HniSCRDnai+Xy3c315x48+PqG1juufrSh9V8KxVTVQWIrIrNEBlgoExEAc9A2wn9q2+qiLDwQHggPhAfCA+GBG+oB0OsNvb1zvS2+ODs+rL90U5rwctEsFB5Px09veiEf5PUMXAosgFTgh/Y4nwmQAgv1l3rSbFcbdC2RA2BbkucNA2m3zUXo6imXkaohCRBujlCwvwCKA5F/toveUGr2QZIg16Hqp5XL/bZ+YC9HPipWdWwHIlkfxQIlYoOYIibKodkvVkAWvp4JQTn+A7Fl3uJg2Mv1Zpdy2oWW5LReeeyWkH9+j3gu83MxDON4yJ+d/kD+ErjJ7x3369GR5ewAT0vj5tmhnAghOQIaCUBO6XKcfq5KOQCqd1n84cEBc/+X1YUb3e6wt+NGuyBuLjwQHggPhAfCA7fPAx30evtu//zu+PEnTz7YWJ/evffUhtZf/J3JMRD3pm7zB79soWOqKP1rOQAlsVG9x9jmj/W8yNMufEZ8hly97tDPie1bUHF7IGGNBOQG0oVmx0u1y4LUVGBEREWym0GQKj+U9xC6JOtr8n7O3h/ajidH/ydVwo5L8zP8wnI7pravxJJT5zTkZJPrrT1rkTl9QkhWDBJHXnn6Szk88i3fxRg6x48cdlTfQ/4gfvRLVJ8b1dc5f5D/0VEK9K3JoWHHvu5t5eblNc/lelGK5HDmA7kAS9oVQzkXyE4r5Edmbi/uaJJ+4ikX9co2MqxmJsC+BpwJQGSP8eH3Q3MB1C48evD70sPDoyYCwGmrx/SJwB4rai6Z+9fy4Ld4wHmMt0hGUXggPBAeCA+EB8IDN8YDEQEYPpR8WerHNF+izSoAvlQPDg7e2Jh+drX88oYy58oqAObeN3WbPxBI4nb8y17gthpgh2Snam2IHqC+TEcU1DLduWkiAt3SqgKJcB8gYyIEIy6snc0db/vzkL8itG26u8pa/fRNy5kGK9tREX5kpyLSf0Xyu9o4Xte2d7y0XbefQwIKea1B2u9q1xEjQ/KUg+ihea6+Sq9u+wKn++bhbLP6WcSSLDL3vHKQPwgc5AZPf7RckbTeLnpQ9OHVPuUe8qfe8z/1UE+Oct471skfsMOfs8c/gRJyX/An7Z01XRK5mafxXUzTm4nfxob8iQSs7X2eWgQIBI88+wcsLFIEAlzae6KBI+4HOzp+3hkH6EF5HuGH0keP3n1bZPm9leJgT+IBxv8kuqETHggPhAfCA+GB8MA19UALIK7pDVyhbvNl2iykff+DD/5m07fnn3/ud3f1EQTC2QAZSHtKivzTNgNVtdj+LQfyV3NkAYME23XrIMaENLKczbGrnRbJJsTPGQYgDpVXHiSm5crTTslutufkIOBvtQ+f9SkoUPrVExMoTASFuV3ug7n8rG9Z2vAgengQaGmuOSNTIJ2Fftp2k0VFdNS37aXnSstB/nKbqPXWj1OhdkpIH72zoow/47awTfaPjtIGc/N52vFvrTv0WQdA/vRH5/LJoaFeqfqb+u3xsrrWQnH6HKDn0ZXl/PA2I5efb2twxQOlyxtMgVwA9E9LeS5LEQHqv/u97/5oYJt5gYXJuy4daO9WiG3/X+NW3HrcZHggPBAeCA+EB26vByICcE5j/957j17fmF5V/7j5Mq2/tDof8SARmocHmVBepBn5J+SyOVF884e9or4JaE4Cesz5w0NBcm2EINV4c4XoQTMSoWAg9fxDfzCj9tuchpMBA69dd67fOgLyp189KogfQIach/RBUllunV5lI/WW/gn7eYgz6wmERx79Vq6LFUSth/hVHzse4ud59erR9yj6Xj3jx/MJ0icCACVHRZ977h47tJPv0yJO1Lflnde+8iJ83Df3MbNVB0tTZ1xpl58TgDtz+9Tz/BOpoV+cXZDlHAP4CbmzjgRg16OcAeCdrurpRfl+HuC53k8rpMMD4YHwQHggPBAeuNYeiAjA/sPHXNNO37lfrqzbJx1///YbjeWIlAO+4dJe35WtF28RRar3cgGY+1dkQLeYQlakC5IBKSFHeWs3WQJBMAeOPO1A0SeykMsNcrZIhppEtTwjsK6Yy6l+T1CXCwCBoaagiL/dLyEh8jaLPynsjfjNcYzvOq/7T/aYo/buH4TP/akciJF6qNwmxRn5qx36lwXlAqSLHBQxraccSn1p3ECyZPnP5ykCtFymOf/FgtcZy+l94X5B0vgVKWguzyGmNM6eH9GDjmw1B9tnjMepP/RqbTt9VpNUMmIVEAaMqh+0fSJ8+I2UnumU3w8xeM4sCJ+5fo1o0Tynq8JvofwQbqmKopIHLmf0S72K+vBAeCA8EB4ID4QHztUDO1HsubZ8/Y3z5bnVh3y5/v43v50+shTqcf9EBOD3pXkVAJih+03nIn9rhy9vsplBCjmbGWgCJJL+KZJvkTtIKFHsgxRpJ+87YAi7tZf0iDxgd+X0g24htzR7tNeWIzmQeshf1BX5U70v4mc8sj5u4PhIq2A/ibwDo5UTaUEfih8UGeJObhMevX2pInlPH7mMSB1Br14RL+rskc+ZGC3yTzky8Nwveh5Vf+X3QhQ0siLVmQXx5wK5yIhfypeWDDDd+mtTx++GDpxFHvG/NLM3q6cBYqDkD5A/8kr5vXj48CFnAaSQDSHOet2T6hhPkk+OyThyUVx7oPu/RbgkPBAeCA+EB8ID4YFb4QHDF7fiXk97k/iKjybSe++ZYb7Nmx0B7969+8ym/M+//9qPN/QOW3NtmPovIziJACiyYQ6zmqQP3tURH7jJDoinqhLCWa3oXqrn3+WKCAElXbpcpA9mEChf4NqfrtYAzrKjQfolDSIDmkNAJAD9nHvAQmmroBw5j+p9geDJUdD2FMl7dr1yEA/+RU55+rE2xK9z/CB+D2EpEgTBDgWI9GsopR/arocwPUSv7en4aD3vBUi+h/htbn+1tPfGkkuY01d7+CmXs49EjoB13yvuO8s7FyXE31PjAbAHcTpNPzv2etY7RXY1yP5fjWbND8NsnH4A8v3YfTAe/O60fDLIc6j3BU9kCZ6zADQCMLYOIcdzT69pxytHbrFcvru5/tyDB1/f0HqH1Y82tP47TKQ6MEokgB84fiAjAmAO2kW6T/UuyagLD4QHwgPhgfBAeODGeADUemNu6BJuhC/Q7b5cLh5v+rSa2qmAFWm91lNWA0gkoHcfS8wzFWYS5ACwzcBR+vAF8U9s7/uePSvIZwJkiNj9cAbZZWSOIZCR8chR3dKuPRAAEQadU80AJ/slfdCvBZG1X65Zo2lybJCwFAno99f6CaS0G1Dkr1n97X1uv5oA0aQaP9THRDY1IDK8BVIbivhBfHkYpb2LZktIH4TPOMBrP0H6lDM8iviZ22c9Pzx2aQc7UPwGnynPN89drth9sTfiV3M5BJUqFPkvrIDIwNwifnenS3sluu+Dmt+XB/nvq6fy+XnXCoc/PDjwTgF0NKL4JB5of0dPoh064YHwQHggPBAeCA9cSw8AK69l569Dpx9/8uSDTT+fnc2ebvrbprmn7gvyB6mAXJJQ/a/lANgy/1zMxcqQ//iOIYCjlKIwHicEvWRmDAWjkwmYMxWQC4CY9+XuIWzkM8I3Q5T37Xbbpx5Kt3MOQQHiYi1/2QIZzaDXb9oD8QsQo7qiXhd1eDv2oZjvXxB/ridSQ+TDKphLRY7no+XTnRbcgnhet6/IOgvYhbitzjJXiS7P80r/4LtSfQ45rz/0Y1/En1vCgN2Ai/izgl04yF/HAzVF/mtLUtdy5AdTHkSb/Af5o1/P+TeX7YZ+6b0n12Hw/WLwkim5AZ98/MlfOF0h4qrV/FRoefA7PJB/J3fIRFV4IDwQHggPhAfCAzfMAxEB2H9A+dJkFQAW+DJtVgHUWatNturBwcEbG4Fnn/30lxE8D5qRvxknB2B8x4bYTj3TtskB0EjAxBAHEYGMYM0APEgfHvvKU35ail3aVXs9BCrQNX/xggzNAJEB9gsgGbtn36A/7Wg9/dO5feTI6s8RBAfxe/YV0cnt0YxLFWnjBuzAuwakAnsgYxC9iNVnU/DaaE3iqUcfnrl8ci/I6qecHeW2Wz1B6Z7IX1uYW0Rv5uzYp/IeD9Jnzt+T49BCUk3wi5d74tnRcsZTyy+KP5wf/pm0xe+rFAd7Gg/k38PTGAnd8EB4IDwQHggPhAeulwciAnD248WX6p2N6fc/+OBvNvT555/73Q31/sgCb9f1myRz/6I4vpO+3ZYcAij1s3tpaOcHqTujUTorYL3uKoD857Zueja1OURbVZsRrdkHsI5WhUlh6c9FsyBoEGWvfYG8IP+eHAUmr/7IfGluP9vpBo5AWvQXMY1A5PPdze0gdSi3g75S5LQcXut1Dpl6rx0QO/aUoo9dxgU9kCvlZPOzmkD12KcBedrLfrSOZh4BjzrIX8UXdkrfdES2SZJgzv+0yJ/2QP7TnCO0+6eaSMBsln4XNFKE3RJlPHguS/LUs/4f/qSUiM6//5M/GfoDw/p/wOzuUNNJO3ZD9XDaDb29uK3wQHggPBAeCA+EB7Z5YPdn5TaNKMMDfHnu9OF77z16PSl8Fb0uNUhNlnO3suVGi/StNm7iCvW+f3PZD6AVba4Wh+kDuo0EdJG/iFcgfy8nQOXhMwKmwKHenL0jnouxjz40CxQuQIAgi5zODiQt6BerBfkzx5+37rfxnYwTYuT0OPoF0gfhj6sEYPK+B3TAAKfXba8cdShIGr5E+/LbMYPKgcxBlMozp8+Oj5zKhx3kGTfGXZ8H+k85evgXPu+rgMKelPuY2Qu4XKX3b8ILuac9FQfpL+x5Ub5iQwBTJEcAOyB/+sn9e5EAVtWQmjG2B9FD/lquOwDSD6WMi5aX+D/+zne+W5KJ+tN7YPvbfHq7YSE8EB4ID4QHwgPhgSvsgZ3o9Qr3+9p07d133nl709mFQZg7J/wkXucd/9KtT9Zp6JYjUg66LhmPmApLkQDdEZBVAl2tersB2xdgZTkBY8sJQG7E+VF8Otoe69R7lNsGyXlyF1bOZPZw6Nx0jf5zP/TXQ/5aDzJT5I8ckQAmQId3j/HGUpeChLulLQdybEu8KwJf3XrsY0d5yr05fUX6XetVPeWf7o9l8SBbHQf8S45AjviIQexhR6p7LAgY5L8ypD6WQ+lY909OALwienhtSMuVz/IS0FtN0u8B/cz3xY6G7BSaDaSLaQ5VSYXDcgaAU52LWc+fC+xCy3X8VL7mvVDn9h++LQaiyPcAP+O+RNSEB8ID4YHwQHggPHDjPBARgNMPKV+iW335yiuv/GzTxLe+9QeAuqZF5vx1Y0C6sxqlD9/xOk36w1PvIf/lUfqmm9zpIsKF7Rk+tZ3DvFUBuhqA9jwKkgWZeXKnLfeQAoh8sH2F1EQCMKD1lAul3TGnzBkSXFbpMZiME1Jmzh8EDEJrp3S3I2ppbjBLOyUFEDpy++rlwI8hTPQ9hA8ip138R/uKyOGp9yh2vOcDvaI9kLKsBmC8uD/m/EH+IHwiA3dHaTzn9rYTCZjbFp4zy+WY2yqCma0qoJ+D6az7c8MqgLy/hBkipyRH7vKqgu2rUbjfwf0QQfRL4yFqmWU1yMOHD5uzAHJFlfdA5ff2WFVzyQ9ed3mGSgXf8UBEADruCCY8EB4ID4QHwgO3wwMdVHo7bvnUd4nP+HjiU/qeWebTvNkR8O7du89syn/wg1d/sqG6XnZqELpFTt0PWEX+q6P0ocs+AN5qAI0EcFbApg+bPy8HINX6/4IkJoaUl4qgUc0QkYLtFAS3vbZfujYEw977Q/WZY+5btBLvPogIWH2eYza1HtKxPf3HrBO3uWLaRR+kRPlpKQjVs9Mib5MA8Ro7VB93gKixi39L44Ge9pNtJWyxhFb3+DzHbTWMA/7NCjJuyyq9vpMqvWfwU8ZfIgDZTuGC8cSPd23c5xYZmllkaFmlyftJlfblUL7QTK/6qUmKOBxOU6SQHQCnFlqcSYhxPU3t3iF0h8VJ0uc+OAUQHjFyAFgFwO+ZytEP9HTun3LGDf7gycFbm+vnv/j8721ovaHqRxta/zU7q9b0oOHqtCqjhNAiAmAO2Yfwn9g+OiEbHggPhAfCA+GB8MA19wBo9ZrfxpXoPl+kW336ycFhcyrg/aee+uymt+z8B/LXOwD5aw6AIn9vNQA5AEQCRnkKLbXEqoChkQB2/puAbK3DRAK0/3yWa7nyIIASckQP5A9foiDTkhxz1FkuI8KEGHvIMgt2L9irHjjCagw9a2DJ6X9ddZ8DmYLclTdNECiGQOgtn648JK7y6OEf6nW8PHtZv3AB8qf/IEr4iWW50w6USEDuj/lFAS791vJet9S/JkB/kKdfIP3DVXri4RX5o6eUSICWe/zUcksWkybAWD0h58TikjP2kfCy/rk/6g35T2zVEDkrXvulcg/pl/Son8/nb2yuOUuF8h2UV22HSFR5HogIgOeZKA8PhAfCA+GB8MAN9sBWtHqD7/fSbu3o4PDNTeNEALyOgDQV+av8bJbm7Ho5ALZjYGX7BhAJqBYpVYHVB17kQdsB+a+BaAOzlkFa5706AGSn/T4xD/IfaCAjT5GnX0smza0+R0bYgs3KQV6jjOD02zxrNhojyzYHiUrzNWC3XBKoCjg8enpfIG5HLRfTH0XMCGi98siB7Mlq99qnHHn0iVhk3i7IsOE9y3P/PUEDlhZRoJ/sXIg42fxMtTOnj11WASA/Wac5eJA85dDFKr2nzO0/WTr8KE2Jz81Bs7n1926KDGAPqnPylA+l3tz/UH0icaUIQZ0D0OybssUuEdYtVVF0Ug/or8xJ7YReeCA8EB4ID4QHwgPXyAMRATj5YDH3lD7RWzt8qTaf4sxlHS4WPzWRr7Wi/auMwGwfgNE6fdGv7Yt/NbdvtnVqZkJ2MTsCsmMgkYB+E01Jm4PQ/QZcGwIF+aM+FeRP9j85AMqjd16RABBqzranQaMr6a9Ut6xkibcVw67WllXekza7GTmKAOPcFncRfrvsuZVorgqIXu3iJ7FSkXtBOXIgasqHUr1Psvrr5SaNCSICUOrhaQd+ubTnW+b+QfpeP8fMcRPJgdKAQxfmV5B7T8zsYo5h0FMk8evIdupc23vJezC2yE3PvhUQGZhbqALek59ZJGnkIH/VY2fP1Sj99M+Y+zde5eHJ+oeHMl7wHi0hf/Re/eGrf8G1UX5PpTjYs/BA99f/LCyGjfBAeCA8EB4ID4QHrrwHIgJwfkPEl2szWf/+e+99vGnq+S98flCL5ADUu6A38sxdjmcJUdlhZBWrAqqj7lAuDcnTGHP/8FAiAawGUOQPcllY9j+RAJA/dpSnfF86MaS1JMvdMQDSqk8v6EiAZPEbyIM5yI5wzTAXrOXwtHPSXAbmhtkWAYSu7VLuzV3TnyxHgdD2/lOFImUQNHJaL+aKrCL/rCDIn3LkQY4lXvsHz31gl3FasQrA3hvqlbZ+TO/T1HveiCiYAZD/VKAT7YP8q3Wao2e1zVgiRTrXD+KfWTa/zv3Tf3ID4JG7T4FRXf8v1T12ZBHFsewHwNw/Cqz7h9fxo7xE8RdyvKe/eOedDykrUEJmjAQR2YJaVB/3AM47XhbX4YHwQHggPBAeCA/ccA90YeMNv9lzuj2+RHf68r33Hr2e2v/qXt1gPwBPiZ0BqW/PCOh+23Ee+sjmDJeCkED2AlQqRf60sy8lklBC0iXkrzsBKg+yUDgAwti33yDlOlawr2qSN2TZamvPxCyTzFJcigy0/ewqgpRBzt3aTQSk+5xo5EjlezynyRniz/WUW+jKm/PP8nbhRQbop3cf2GGHP/geBcKbnzUSQDvoMRwjeV84tVFzAPKcPztCOs8NSF6RP+1SDw/S1/L799PPzvowRRw0F8DL/p91A2f1SCrlaAAAQABJREFUDqXdnQBB/t7cP/1ivOBzpG0iDSDgUPT++Dvf+a4jEsXn4IHu238ODYTJ8EB4IDwQHggPhAeungd2otar193r2yO+bL/+r/4Lu4n0hczcMHPFeodtLkCqYc6tsuzdyubu0GNnQKbGl4rMcusJk2okADvQHBmg4JxoRu4gNKcd3QlQeQ8JO+aKxW6Wf1GzK8D6/soQN0i2RaBd+cyZP9izHsQ59D5B9BNB+tinHt6lPEcge3hXIVXwvIIUcySApBazR1yE7SbY+Y/VAOwHUGiurQa6tyXpSsr7uRipJ/Q3P46mRySAccD8yHYCXA/c4dFD/thTqsifekX+0+nF/qTr+HoRB/rrUSJ0L7/8crNjai2XjkPtL4cht0pN8URpefA7PBARgB3OiarwQHggPBAeCA/cVA9c7OfiTfViui++THf6NGfjGyIjCx/XgEg8ZDiyrGTm1Ctbv7tc0TyWhtGJTcqDwIZpnUKKUAehj1OYOq5aQsREGI7rbLvGzlkhf9pgpz94aG+8BfGTe8D6drLc89SyIU/sQRXZK49cjyqyB/H3BFPBwuqnqgcvCJ99AVZWDuLHPHPwREhA4tRDqUde/ZhzLkD8QHl4DAnl8WTHP+y0ERRKkmJ+rgx/8l5q1j/NkOUPD10s0imBUzutT3nklOqcP/VDkbjO/aNfoqtlSn3qrQowP+MXkH3J3mKe7r8e7yeOrPcDd2E/XU6/rnVxRACu9fBF58MD4YHwQHggPHAyD3Q/Z09m47Zq4Ts+okh7vWcOIRLQbOV39+7dZzblf/a9V3+0oXemaVaTCEBGHpvKY39EAqbr9MW9sHPGWR0wsh3/mINcSLbyMVPN5WrufEgbYlsV9NXeUD5n/+uNSiQABD7U7lnJcVpftmeRltNGAgCe2a5cgGQZZ7L9mfMX8ZYV5D8Y4bcWTnTF09ND/GLNjQwQUZAIAZEAcgQwRzm8UiIAIM5ePckDDIREAIgcoKePZ7+e1z5p0C7IHztQIgGjZUK460k6CwDeQ/xEALCDHDyUCIDO/WsEgB0A0ZtYrkApAqB20VfkT6RG2/UiAPgNe/UZAG9trp//4vO/t6H1DqofbWj9l5Y3VNVBYiseQVZfEQGIHABz0D6E/7z20QnZ8EB4IDwQHggPhAeuuQdAqdf8Nq5U9/lC3erb1eLo501vp/e+tE+vl2MzZ0gG5K82pnlyONUQESghf+yAWE4aCQDpe4ioEsRPu1BFBpRDzytCQFZ3jgRYjgY5F232/m6gAWLMiJ6OC+0hf+pBqF4zhvw9xL+252R0wpyQnQ9v3UceakX4yhMh0HLPviJ/3EG5RgJA/sgpZRyIqEwM2ufxNQXGvdVPCD/rI8e4GM+OleyvwXM/Mzl9f0rIn/aHIn/klSoC1/qz4jUHAKTP+1l6j7Uf8/n8jU0ZZ6ccq+eROVbUXIL8tTz4PTwQEYA9nBWi4YHwQHggPBAeuCke4IP+ptzPlb+Peq7rvU0n790bFgGYMGc6SnOIq2UasqkhmrkhatYfe7kALA9eeN/T5jlFLid1KIjopPqeniILEIcn75WDBJm7zHK2U2LmnfXzuV4uQP5MOUt1ZpGjQBEnyJX6ijl/1plLPz3kr8OtL7zW055XTr2H8KnPyJ8Co+jlYicXQBE+p1RmvcIF/gWZIt5D/ILskUN/Qg4BFUbZsRIExXu3sh3wiKShxpw/vCJ9ypUe2Q5/dwae9qf6JX61TMvtx2NSl0oaqZ4cAN4fdvJTf3vl2kr9u/i2lgV//h7g+T3/lqKF8EB4IDwQHggPhAeujAcUEFyZjl2jjjAXxSoAug6IalYBMLd1uFj8tBFYr76WBIftCAjyn8jcLqd+EQmgcXIByAHI5TbiORJAhAEkhuAJKXP/+0YAFMkr0ve6M1Qur5/HkCFodjKjuERB6iBE5cvIn8cltYQ+7bbZ/5YEoMjfBBXxM+dPOftC8ILzMEJpz6Oqp3IgfMrhQfjQiucLioLzvDHnry8Takp1/NWfPI854qOrXMipcZC+tsfcP+Xt8Nh7bOvji2cSYKBA2ZPfE9Ms/SXty178q4Wt2592PcsqAN4DED3trWw1TOlMAORPSl/94at/IbpDH1VRC3YfD0QEYB9vhWx4IDwQHggPhAduiAf40L8ht3Mlb4Mv2ea4rY8/fL/ZB+D5zz8Y1NlVIWseIzkSYIcAMCdJJKCLO9HqU+YuzyoXoN/C9hKQHJEAKNLUw3u0h/QdwR7SsYhAniPOc+0JOYEgMadIk/Kh1NNnzjTbsX5oJCfX2wXIn/KJrQZYSMSI+hLloUWOH4rFYcpFqe6m9ewZ6ZugRgLQ71EiAk4kgFUSqqe5AdR7/qQ+jysFDi09Z3nu33IHeM/IisfscpI8NlmqJ5PEyu4bhK88dqCrKm2QN66eoqihCwvljadp9cKYQ0A6UlWl+wBI9YlZL3Iw1CA5A6+99tpPBuqw/h/wOvSnbaD52yWGE2/XXcfdhgfCA+GB8EB44JZ7gA/7W+6GM7l9vkx3+pQv3RdffOlMGlUjk3H6IJYk8WplZwaM5fRA1Yc/aSRgSVY1G6ZpBIOt1rTcGlYEppEA+gcdiviR96hGBLKcIXC+lEE8ud4uWgSa5u41FwBEqwiWcnIK1K7yUxvYhc1ZK/JX+bPiM4415I/dhc0tExGoiBDYXDN67ktBJACDRskFoFj3AaD8vOjadt7ktEnNsQD5n1X7RALU3niaPKjIX+VKvJcDUNLTuX+y/0t6Xr2+30S8XnnllZ95OlbOo1QQi+p9PMDv2j46IRseCA+EB8ID4YHwwDX3gPthfs3v68p2ny/db3zjG6mPzqmAegOcGVDZjBc7A+qqAPSYE1wtEiLVXIDevgAOEhs7c7SaI7A2/YnIL0H8dGxPqoiBiICW72n2xOIaKdCIAJEAAiFDczi0Q8yN5nJLNwf55/KLvgDhEwmA0g94ew5O+gOjiF8jJ974s+NfZbkPy1XKes/l9NMoyf953MiZYOdNk9NIi+67IWbrbRtSQLCbc99KlRD/apE8l2mVci+8MwF4z6XbuUFyADgDIFec8IL7m85SLghmvHGh3nt/X3755Q9MJm1MUFVEVFH1qC2X8aqjfJcHIgKwyztRFx4ID4QHwgPhgRvqAWZqb+jtnettqe/4mOKjn621mn0A6p40n/Sz2ey5Ta9+UP9t6NhOBwPhe4CZ+o3O5m9q63NZ95tK+/+SJaw1q7Vlc1tF3hdABR1+qeupRW5kCDDnBDDnrzdIueh7LHP+Y7t/eE+ePfw53308SoBhtdbhMwu685+149q3Co0EeHP6Xm5Au/5/e0vMlVLrrQpg/T+rAODRO2s6qx43JufVpzqmWR2QVwV0aocz7Xp6Xqukq0gTBD9R4CiQGMRPjgy81yNyALRe9wPQeuVn9r5otr/yqgcP8tfVAHoaoHcWABEA7GkkgP0AqCfSRaSilAuAvNe+RrQYv6OjBPi/8IUvfHXTdpwCyAhcDOU/rYtpLVoJD4QHwgPhgfBAeOBKeOCkU3RXovOX3Anmnhwomc+txscNrb+UmwW983mazL/bBTb5lkD8Ooc8Whhyt52+9ItbIwLsFLYyZJTnCkdp7g4kObVs46GRgImdOuhFAjLy545A/nsiftQV6SuPnEsN2bOzGZEBuqV6vW6Sq2GjnevZKU2gJBEBHT9tBx6kSyQAxK/IifFCT6kif+VVPvM6t58rdl+A/BXxswMgD/9uK22tzv1Xo+4LAnJsNdJVD/mbAJEi+gPyn1okaGHLDZBb2/r9kSXbsBqA9ryIAPVKQf5zTunMZwWknw8Qtuopz9kB02l3HwCVK/GK/JHnTIDpjMAlNdspOQCsCuA59eb4saLjV78nP6Yu6MV7ICIAF+/zaDE8EB4ID4QHwgOX7oF9P9AvvcM3pQPrav2W3cuXtt2ThxzXU8u6deamvYgAO4QxJUpuAKsDFuwT0E3qrZaS1d/rK5uh9yqkIENmKXfYwQhf5uxB9NrclP0R1sO+ebOdqiuvdp3u52IvkpMFnAsQFdUl5I+cUnIAipEAsvfVgPDenD9z/YhPRmlud7luNsCkOFONeFAxMmReyY52IMeRZcOvq/SgTkfbN4JjzprniFHMgRqL3xEJWFnFxNb/r9jpTyIQ+879g/yJBDDnP7dIgJYPjQjgrxLVuf+lhfjcSICFSJjT9+yD/Etynj7l86P5u5trzkqhvKbeuv/tA35MMS6He4D3YrhGSIYHwgPhgfBAeCA8cO09EBGACx5CvnTr86/f2zR97969rREA7RZZ/wtBvCqnPBGBUm6ApQBkdRDnxNb3e5GAUi5AtS9kth4wJwuCyx3j/iUCAmJHTvnVQOSPvkulXZVj7l/LTxoJmFuEZWY7EsKrfXhF/FoOf1LKnD/6dw3pHxrS5wfFQ/7okesAn+f+vYwaEwT5o6cU5E85z5EuDqjsAVnYapCprGoZ23KNseUC8D5UNpefc0msIW2XesqJBKys3bvWHuWjp9KNj1kFb3bXtkpotEy5P3OLXMwKfuL+h+4AqKsA0B9KvZwVjWSpvV+8/Yj1/1oV/AV4ICIAF+DkaCI8EB4ID4QHwgNXzQN8sF+1fl3H/jA31U1bbuey2A+gubdfPn78l5uLz3zm01/b0FGea97+aV9C/jrXx5f/xvbmj0hA4upttmynMnIDqmnaeGu5St+EU53bl50CiQismVQ1+V7uAlDciQSA8EFq8PTTpUQCTADzNOfq7Vmh9laMUyESkLPXbU651KwiYuRB/vAlylw/choRgKf+tFSRv2dvYn5bGqImByDL58e++/rsO/ef7TkXRGKoJgcAPkcEWCVgSJ2IADtgkiNAOfpQkL9GAqgH+cNPj7IDKNpJ13fSe4qWvt87lc+hsoT0vSbffPONH0qdN/cvYsGehQciAnAWXgwb4YHwQHggPBAeuGYeiAjAxQ0YX7ZNWvT84HHn/Ou1zS3yRU+3eoilhDxNkYiARgKwq5RIAOv62escOSICeS6U7GhDuGObo2ZzBN0JbyXZ2iB+7GfkL8ieHfz2XreP4Uui7DhIv3NuAPcn4wgiJhKgc6rM/RMRgC/dnkYEkKd834jAvj8Yd6YJYxwtUoCMSAD98OhklF4X5vyhpax/tafvj9YrrxEBRfggf9UD6VNOBACq9chBNSKQs+xNgFwA5KGc+QHv0fw7MEuBSOIsa1v94+mddzmnow5oh7MBAK1EXAeohojnAZzp1Ud5eCA8EB4ID4QHwgM30AP7ftDfQBec+S3xpbrTt3z5vvjiS00HmDsfTzQGkPpHPRuKDe21FwlgP4G8s6AYnNnk99wm13NEwE5Lm1jaNqetVTLXzd7sGglg3TA7stFsLieEQIUhZubiz2uun+ZKNPejkAuQEb8YJDLAfUh1ZnVOdV/knw3ZBUhfkX+Jxw4Ps56KR/3Ingvq4Q8tp+SuGVhaHIxcACIElGMPxM9z5O30hzwIG8Sdy83R+0YC0Id6yJ96KO3TH8qVaj16PTldneD8AJDTQy4AOUOsHqoM+Y/XaQBYpcHzvLQXckJOj3bknHhOR91hnsjpDpGoOqkHIgJwUs+FXnggPBAeCA+EB66xB/iwv8a3cD27zpfvt771B50bABnyZU4lCGYhCCB/4SNolDk/IgBSXXnIHzmQPzxUESPlZL1PxqwmyDXNBQifUuVzuQRA8IdXXyqnvkS1nZJ8zk0wQQ/xq532FEINdahk4pnrJwIApXy7Vr+UcaNmX57eguyxA+KHv2s7LrKaZAJvOM622q/XxiTssV6mAdfnHXslCnJWRF3S27eeXACNBPR4Vg1YA/hH/bZv+1neVuPMLbIyMgpwJxLAfgP8XID8VzbnP7OcnDWRrNzA9gv8i7+3S+1f+vDhw5+bFjsgEEEtGeORLMlF/Q4PRARgh3OiKjwQHggPhAfCAzfVA4K3buptnut9qQ/5qCLRluO1iLY0abiz2ey5Ta9+UP9t6Nh2/ALpe4joTt4rfaPV/nlIn0hAK5muQAqUH9mOaCPLTtccgJFBt7VN1i7t+5s5/oyALRdAjxBAjvb2pSB0zy9D7WEHeexpuSJ85D2a798TkHJyOtayGoBVACKeWUX+J40IZIMDL0D2eU7fkD08CJcs/4VB0mkv2cNeC9lngvX+2h1yAHh+WAUAElVkyvuDf7FHOfxJqSL+k9pBj8hC5lklYgXk3pAaxOoA9t+4a2cKIMd7iz1yApZ2CAg8ETiozv1Tjh3OKMDv9IN6PVuA8dRcFsoXy2VzBsDnHjz4+sZGvUPqR2br0OiBUXIAiAyQ/R8RAHPQaQj/WZ3GRuiGB8ID4YHwQHggPHDNPAAqvWbdvlLd5UtUIwF0ki/Yjq/rL+wnG4H5PH3Q3iVegJZQEAw5ADr3D9LnS3+WN1dPhnpZwWKfyAJ7jWsOAMgftakh/bnMITKnyJwkyBgkhz6IDr5EQeoluaH1RXsgMUHonn0QE/frySkyHVk7C9t73tOjHMQPrxEByktUVwWU5FnHP7X9HvLqD8lSx05G/iaf930wfwLjkPco2f8Lec48efUv740nP7T8rJE/7fbsmn9GFhFEbl/K78CaUw3NwIrnbPCpf+eDERfzeXMWyr73FfJn64HzGd2z7WNYCw+EB8ID4YHwQHjgjD3QQaVnbDvMDfDAweHhzzZid+/d/fKGjjKi8gIKG6k6idqQo0YCFPkn6apCDr2RHY+2sB39FOHrnD928ty/ITLscEoaclAPGWtEAPl9IwPoDaUgf+b84V39PSMBnh1Fpp6c7gDoyYH8iQjAe/JarqsAqGcun+x1kPzC5pArm7vvzfGPZ8mEzO0zd70EwUs97Xp0Yac4MvevcsxJ98qLA6sau3nm6nuIfbdav9aSY9j5kkgZgtjXgODSkPxaVhmgB+V99n49mIPnvUTvpFTtYL9k75NPnryxkeF01GPyREyPFTWXQ4NGqhf8Dg9EBGCHc6IqPBAeCA+EB8IDN9UDEQE4+5HlS1U/4vmybVYB8OV7cHDQfAk/++mnmwhAvR6g6ZEiVBAkc5ogerrPKgByASiHZuS/SOeKU85cPuejM/evEQHkla4rbpPbVonEgxRKc+REBi4qErC9t/3Sdv2+3Y+sxijdV99it4SzAGwxhp2Z15U5zk1tgTfIH56s61K5FzmYGJLPZz5Yo9ivl6s0JZwNAZ/7JnP+RIza+0vYlLnoEmLkeahyBILXKLfYueD9yIULSx6f8pzmmkEXIPJBwluEdBEEZ0MgmusN4bc7RabUouwf+UGY2H4AZP+vLVKFPH4gx4TngnbPm2p79IvyR4/efVv6sHtgRTjYs/FARADOxo9hJTwQHggPhAfCA9fKAxEBuOThOpzPHw3pAl/0yILoiQQo8i9HBBIiGor0abdEQWwlBF+KCGDHa69kX/Wwt68edvA/kZheRMAmc71IgOpjFwpSqwrYnxwB9DIytwJF/hnJ29w78iqHPXZyrFbp+WDnvkNDoHcsi3xlSL+ntzakDSI1iLs0npwARR6MD/agE53LJxIgqzPwL3qZnhD5Z/3zvhDkr83lSIkhfk7dVDneJ/zA8wTiBoEjp/oXxfP8vvXWm+wAeFFNRztbPKDv4RaRKAoPhAfCA+GB8EB44KZ5ICIAFz+izHXd2TQ9P3j8k21dUOCzTWZTRiSAL/6JncrmyVN+UuQPgmPHOhA1yAIEDKKjnnaRg4dSjr7yyEE9+5QjB9V+UA7F30y1Uq4UhEUkoF9vORx5crcrUdJHmjlz+EWpYyYIwkcPHiQIAiMHAB7kz85+/DCw3v9uPuMhIXzW9ZPNXhnyZ86f9ieGcPV+vHFCrzI97PHc5XpDxBVZ9eYf/Jvl7ILxahJw6rI5A66Cwpey/xlmsvlzZMgiJbaIIVu1LfgrykeMq5e2nzXTxcoiOeQASHWPvSrIXzvGaahavoVnB0DA6u5koy0Gosj3AE71JaImPBAeCA+EB8ID4YEb5wE+9G/cjV2BG+LLdaeP+RJ+8cWXmi6zhz6IAqACggHhgPi5Tw/5syPY1CIDdAq986YgPQ+Be0ifSECpfyX76I/sFLS1nYdOuVL8reUANS2HB/mRxU25RxlHxlXPBFA9EDCIWOtLPEgfOeVXVVrHP7UcBCICGdlbTgD6IP+M7G0/TMsAQOzklB3rsGARAViP4k/8ixx8dw0MtXXmhRNB8FYBgPyx0PLJESB86qGUaySA54fn2QsIsOpmuUoS7AswrVJgcTVK42hHBNDsuVEiS/s2wGmoO/SIlO4QiarTeiAiAKf1YOiHB8ID4YHwQHjgGnpgJzq9hvdzFbvMl+xWX/Ml/I1vfKPpO6du6Y6AIBhuEMQPwodSPjeEgDyUncLq7AGKTkQzIrX16CB8D9GXGkEPBMRphZSXIgLote0oFk2xDyIBrVwqp/99O0lybDuwEQlgPECOrT2TL6wKQB47SwnNlPqDfo+y055m6RukJIIAwmeOnzl9zo8H+WOf8YavFKHnCrsAsZfkVA8effg9qY4Lfq4G7gvQ6hPa2I6VPERPd/Ocve2XsbQdOKtVsjdpHd6oWGu9tSAjSsgtEL8yrqNcn6bKV9bB7b2nl2Wad1wcT+iiF6TYaYxVDQ8fPmQVwJEpyBvQM8PcP+33BKJgfw+c9rnYv8XQCA+EB8ID4YHwQHjg0j1woq+4S+/11e4APuXjCih6z7pNJKBJSJ7NZs9tyn9Q/20o63zJ8gWRZgSzEar/NAeAMwBA/t6c8tKSDE66CmDFnu6pGzUQ5MM8FYBcPcROvanvTTy7Vd6RcG+TprAbgGi/iQTQGohRcwD8/qKZKPaRh+9KtRyIjzl4VmW0EruvmLsdVzYrbln85AZQ71oRBJrlFLl7clnBuTA7vciDieeIhaN+XsXkPlSstmFfAqfBpT4oKif+Yt8D3nvEJ9P0szLKc/zpZwW5iU36EzGDTqfp54bxZFUAdpGDTixyBU9EUiMAda5M06GJ2c/27H5oT8sXy+W7m7LPPXjw9Q2td0T9aEPrv8NEqgOjhCh5MfmhiQiAOegsCP9JnYWtsBEeCA+EB8ID4YHwwDXxAGj0mnT3WneTL9qOz+sv7Sebuzo4Wnywofefmn12Q1kNMBmnD16yjDUSkOf8q2SWSMCRfTezU+ARm8xvjNd/3ml/ivDHzD0mtYz4iUBANRIAggDRmvoZEgIryeQ4+ykhpZYHOCR5r7wfQQB4JPsgLZA5qwUAeHlcToh4UcOf7AhIe8y5K9JXPjtYcgFAzBmZ5chN149tvQAtQaq5Hcq5gVxxyguzlyMd2eHpNWJUMzI9ZXMr84faozyfRUA7JeSP+wr+0ecK82vLDWhzgajZTnlOdBjIpWGUVyB8+rXdnFsK8uf9RlAjC1754cEBc/+IBL1ED0QE4BKdH02HB8ID4YHwQHjgsjzQQaOX1Ylot6qODg7f3Pjh/r27TQSg/UDf/Y1G9j8+5EwABpadAkGQyEGJBIDUSWAYZYSYJNvIQMJeIP4WsSY5D9HQ3lBKf3pII2fXJ0stoqfnqXyVV0GAfXaX1zEX61pX3gqHE9vzvhL/eQbIHcCfyIHoenxGqt37RY4sa/jKIgEZMVsoiXHKcs4F/djeWq2kkNOxk4t5sPfVy/tYpPGZ5PHKlgddZEQ/SLrOybHxxH86909W/6SQg5IjK067eTx4fkRubTkauE+qM1uqz4JnfMFz50UCaO7w8KiJAHAaKuU1JUJ6rKi5zK7XiuBP74Hd/7uc3n5YCA+EB8ID4YHwQHjgCnoAoHgFu3btu8SXq0JKvnSbVQB8CR8eHX14/I7Xlm0PEibLF8Q4HifzisAz4jdjyJMrwD4AGXGYnCJtcg4UqNEeiBV6vO/Hr7HLfWi7yII04dGDVwryp1x5yodTvoWZvE08/cYO/e/11yAyOQHIK2U81G9qr4SsQVzYxx7jQ7lS9Lhb7kflev1RAY/ngfGgKPWmr+14/aE5Rf4gepA6ciel2Mv6Ntefkb7M/U/YYXLEc5M0RzkClS01F3k/CZvjJ7WhK9VyIGpWl5T802rud0X2/35a+0s/evTu26LF76EUB3sRHuB34CLaijbCA+GB8EB4IDwQHrgiHogIwOUNBF++zamA73/wwd9suvL8c5//3Q1l5y9FwjnbfCO04y9n/xvSIFeAuVwiApRP12kOnAgB7SoCBsAp0gSB0iWQCgjPs4f8UMrcPohfec+OypV5AjiexVQOgsNPUO4bf+EfyrNVBKxA60HsyGMHXmmpHnlyOoYiAPrFuGLHpYX78vRoR+uXo+7c/9Lm3IkI9JC7GhjIaySBp2Bmq2gysLcIx9qQ/0hW2VS2KqXXrMmNcsRAA4RJAz+P8sZ7PUuDCob+XgwytkOISEUp1+G73/vuj3aYOV5FUg6PKENxXCauT+kBnHtKM6EeHggPhAfCA+GB8MB18kBEAK7IaL333qPXU1d+oyGaA9Ai6CRV+rLPuQCCxLhdkD+IC+RPvUd1ahfEq/JthCB9uNOOyu3Lt8g/2cUvJTvoIefxej/Y13LsUA5l3XXvfmUcqFeEj12liuxpD7lSP1UOezkSoAOLQoFyHwWxXF26X5BkVrAL2llYbgzdJRKAPBEBeI/2kL6trmCnP/ZNQJ/yegvOpigjdIuwQdr1+6bprAbJEQNCcjQklOx/Mgy2xws2O4MWDJld/MvzApVmT8x6O0kSGXj3nXc0B+DEbYXi6T0QEYDT+zAshAfCA+GB8EB44Np5ICIA5z9kzGXt9DVfxgubVKzPZTeocboOEikg+xxrICr4k9IyguAbc/cUHghmaL+0XZ3T1/sp1SOPXRA2lHoo2f7Ug/yp71EQmkQCQGTIlxAyclD6Cw+lnP5RDk+9Fwlw+yH9xy52iPzAU++VUw9d2amL7Myo/kFuKNJHXmnOGSCr39bf2zYT1cpO6wPxo1/KlsdvRAIqZ10/9qA8//BKGS/Kkddy6k9KT2sPpK/tExng9FOtP8aTG3WsKC7PywP8Op+X/bAbHggPhAfCA+GB8MAV9MBOVHoF+3udu8SX7Vaf82X8+9/8duceh36RK+LqGDnGeAgbZHhMtLmkfa9e5ZWnX167Kg+yGSqPvs7pUw4t1SMH5b7he/dvc7vaT+XRL63rRw7EC5KkHAS9XPIYpZpev0zBK8ee1mfkK6c9Ig9lPOGVevVeeV+fOfbdgbOFnXPP8zI0IsDcf57Ttw5w//Sn5Zl9p6ZAQfxOpARt+u0+LwgOpDwfiGM/8+0NUbSTcgrgTqE9KomQPXz4kLMAjkydge4+2K1tQod7DkRrIK58D0QEwPdN1IQHwgPhgfBAeODGemBY6uiNvf1zvTF8y0cWCbz3rFUiAc2OgHfv3n1mU/4f/sOf/2RD796dbUieggSR8mXP3H4jNOCfUg6AIkLPJP2g3tMDAIH8QDqePPZKFDsludPWe/3kfPeVIfE1e+0LPin1kzlR7SfIf2JPx3qZHiPK8afXP7U3lNdxLekNbX9fu9ouyJbIiNbDT0cJKC5tZ77JOgFK+JmdaqnIH30FyCPbd2Bt+2OwKqfUjzxOLAuggQLlvVaxsT0IE9twAn8izw6hlEMnk/Szg/+Ym6f/yEHJbYAnAkDEZDzh5yv1cDLl5yvx2KUd7oPygycHb23Knv/i87+3ofUOqB9taP13mEh1YJRIAJGBiACYY86D8J/TedgOm+GB8EB4IDwQHggPXFEPdD/jrmgnb1i3+MLd6vvlevWB3W9zKiDI46x8UEKmtAOChyeJHV4p8iBU6mlvKGJE76wo7YJs1K7Ww6sc96Gn/LEaAPksR4FSHGXlIEYQP2eiMdXPToNq5qx57hs/wZ+2naF2aFfbA0FqOTxIuLJs/oxTiQSYIPsd5JCalaMPBfG39pNFwnmUK2Ucc/a/CsCzL4DlCrSPQwoh0Q/E2+ct31mu2nbR098mdAFlOm7z+fyNTbOcfXIBXYgmBnggIgADnBQi4YHwQHggPBAeuGke2IpCb9pNXqf7OTo4fHPT3/v37jYRAPoOsighEeQ9CkLwkGqLSLZb8BCdh/zVCkjPs6Pyp+VpT+1o+8ojr35CTsuRV6r+9vRA/K1+mgJtIwzbESD3R79a/ZNdnZWdoa3T/6Hy+FPlyc1grjzXO6ErtQOCr2wVhI4TiLaVyy00F7yfuZSdBSU3ROvhmauHV6r9ob6kh9xZ0X3Hi3brHABvB0AioohCmfuHD3oOHogIwDk4NUyGB8ID4YHwQHjgqnsgIgDnP0J8ySqE48u3WQXA3NjhYvFT69LXzrJrrBrQ1QDahgImIgJQlVfeQyrInTfCBNmV+qFIRvul+i0ST3dS8kevH45Cnvs3By0XCflPpulxga8qkqK7MSD6rfej/qYeeerPii6s31Prt/JeO/SH/nlyxXJ9cOHxO7wYYpwZL6mu9yXsrsKgnnL4TAX5l+xnPe/CcgW8/qka7zmRAc3KV/nz5mn/cH74Z9IWv39SHOxFeiAiABfp7WgrPBAeCA+EB8IDV8QDEQG4/IHgS/jOpivvv/fexxv6/OcfbEj+G+U90rsIMAtc8MXQOX/tFkgP5Kf1Hg+S0nqQEfVQ5GhnaLuqj502GzuXNBdk6RMh0P7kHQDHKRDE6GVkz+h3zVZHiySvYaP2HPmkMLEQgo6HmKv3ticQlWrUH8qrfokH+SOnPOVQ2oNXCoLV8sH8QOSPvTzu7ORHhVG3nqx+k+M5yPKqbzzPibEu4flCwNMr+RP9s6bkRnh22e/i3//Jn/Doe6KUE+oCnHYfXKSCnokHcPKZGAsj4YHwQHggPBAeCA9cDw9EBODixokv250+f++9R69vurRYf6XJH56O0iQkc458Rq9sL/Sx7YDGbYCcSogQeSgIQpEi9WdFT2pfkQ8IC0r/sM/9aDk8VPUpx47yyDOlvGZP+nEa3rUd4qjIHztK+3P9SWKSIz6JX9pcNOXc39BxRp77gtIf5Sk/KfVyAOjHvnbz+ON4DID0jeIn5MdWvjL/KaLGDLToB0H86EF5PuBPS4koTHjxzaD6kX6zA6C2C1JXPeS8cupPSskB+OPvfOe7J7UReufngYgAnJ9vw3J4IDwQHggPhAeurAd2otEr2+sb3DG+lH/7d76+112C/IcqgZBKiAXABdAaat+TA2mAWJT39Cgv9Rc57MNrO54dEBd6aodyaPYPBUbHdehm8ydT71Zbn3ZPlv8ybYG+rJoUkGoyskPS1uksiGo0b3Qm8GaBXID1WqBhbiFdcN9SXFHO/Smv8vvypRwAtbfv85v1GYBckC4YX8JulUVUlrYun+df1OrATRKgHh65UjlyJYpd7HnypYiFp1cqZ7w5A6Akf9J6cgBefvlldjjVUwAx7WTDUB30PDwQEYDz8GrYDA+EB8ID4YHwwBX3QEQALn6A+NI9ne/tlDL2NmcuGCQF5faoZ51waT8A9KAALY0EgGSQUwrCpLzEI6d033ZAOLQH9exk5M86bg+6a8eMH1UpX39tp845Yrk4rwIwAJ+RPxKG/GGZ2yYHgPKhkQDk8Qs8FP/AI6fl1J8V1ef0vBEp/faeg1K9p+eVY++6UE4B3Le/zPWr3tFRAvz18/RE64zn91Cryf739lJU+eBP4IGIAJzAaaESHggPhAfCA+GB6+6B06HQ637359t/vly9SVq+fBmD5lP54cOHP99063/8n/+XpE/6rvbVziuvZ5m1ZhBPJGDJpKijpYhfxZjDLCEgRZTKq134kl3koJ5dz04P4a6YNd4+bEPvl/4oXdqcvyWla3W1XKdcgFH1uKlbV59qKHx9SkRP5zQF+KtkAzn8pbzqaz08cor8vccc+ZtCeX64H/yg5eov5C+cjiel37GdXaqflx/vFIjKS/VARAAu1f3ReHggPBAeCA+EBy7HA6DPy2k9WnU98OTxx3+3qbzz7Ke/fFyIuWqyg0EQyLT7BaQPd3jqoeQAgDw8hOzN/WPH06MeCnL0eMqh52UX+9ofyqux7b23Zw4A+qMxgImSLp1U6ZVbVgSApN5WATDObW6ArQqo0qqAylkVUK8v6Bo0jhyQrZXHCvELCBSKSIlHDqrylENB/jyHlJPbkvkTXmB36PN0wmbq7qaIkbbjldMO7xd8ps6OhLneLtDHz7zXk+2PgarvzXtz/Z6h+dH83U0dZ50ck9v+Apw0pHnMcFwO90BEAIb7KiTDA+GB8EB4IDxwYzwQEYArNpR8KdfrZ9+3rnUiACB/7bYi/fylDpI1RAESJAcAxKD2hvIewgFJYqdFKLtzFhRBoX9Sqva0X2p3zCoArSjxpmcbN1brkUQClh7g6RperRPCH48S4ld+uUiv7GS6PRLQtVavfnd2rsMPOi7w2CnJ7VuPXSjPD/y+yB99HWfseeXUl2jJPvr7tqORO+wopX0dF8pLOTrYU33KPTq234v692Z7MoyjSESH6oMnB29zHfTqeSAiAFdvTKJH4YHwQHggPBAeOHcPRATg3F2cGwD66uwc0PBulqwvfvn48V9u+M985tNfS+VJjblhIgGK/JPs5f8L4gAhQrVn+yIn9D171Ge77JdgFewNj5xH6X9lqwLYSx75FoEZ0mfO1kHc6Hlz/yB95JSnfDK1x0VyAKgfSvP9mYLy+FfL4b16bb90WiHjhD9Vv8SrPnxJb2h9yZ72W+Xh+3Lp52BoJGBof89LjnEfap8I5Ks/fPUvRIffOykO9jI8EBGAy/B6tBkeCA+EB8ID4YFL9kBEAC55AI41z5dxsxB8fvD4J8fq8iXIn4KR7XFOJIC5/Vw/Tt947MlNuUf50gfh6RyjN6es9tDXcniQEfzI2c9gXaX+q71RxXp9LCQ6WiZktSQ9mmpCJ8av7MZ0zhJxzQUYy+l8yK3E/5QPpR7SV33k1sunmio/yxu/aKBJLe7m9TmARwuecYGHIsfzAtKFp55xmOh4mYAiZ/Q8irw+X558qRx7JTnqS/L4oSSv7zny14Xye/Paa69t/R3bch88uIBSIqZbRKPorDyAs8/KXtgJD4QHwgPhgfBAeOAaeCAiABc/SHzp7vQ9X84vvvjSzh6C/HcK1ZXMya0NISNPxIDFAoqcAGYaCUBf5Skv0YyUZI5e9darlO2uqcggfZWHn0iHcTr1HvIHkSKn1KsnErPS7H81cEo+5wBgp5ALAOJU5I16iSqiLyF+bc/jadcbh/x8ILgn9fT1efXktDnug3LPnyqHvFLeKy1Xf3v1tIMdfgdGtkNo5tXAGfHeuGGe35tXXnnlZ5Q5lMinUx3F5+mBiACcp3fDdnggPBAeCA+EB66oB3ai0Cva51vRLb6cf/+b327SzKcrO/jdJgd1jhAkX3IOyMFDMCV9rQdBKbJSuR4vyH9ECEIEJ7YMebVUDC+CwvbkbYe/kWX1r9nxT/RANioH8qceNcq5/7xsWiIQyJ+WtvsAbAdOa3tMQIa0x7jDcx/0n3LvuUB/MkmYATnK0YdqufL7nvqn+rRDP+BLdGzjjp5nt2RH9dTfDD/lvCee3Yz8bTUJ8pTDq35brzWJZ5ypRX5f/6OvFKSv5eQAcLZJXZ+OBayc5B01UG9h0S+KkrP2QEQAztqjYS88EB4ID4QHwgPXwAM6tXoNunztuqg+5qOLNO17dkdEY5r9AGaz2XOb8ldeff37G3pn2sX8IBmQP3Qju+uPL3MQELLsCAiSZa6XehBNy6ckXeQph6p+JYgfOQ/5V5arQJY98h7tIX4THFu6vFfv2VsaEpvYun545NfO/awWXWS+ZNhsJ0DdB4DsfuyWKDsE6lkA6LURAB4zahIFESry70rtz2HX0/QQrLfzH/KKtD37py0HqWOH9uHPm44n6fXncaH9jNjtOdZy6imfIGerfxgX5KAaAaB8bBGevBOg2aMexI9dePxD+WK5bM4A+NyDB1/f1NU7nH5kModGD4zywhDiI/s/IgDmoPMk238lzrPFsB0eCA+EB8ID4YHwwKV7ANR56R25wR3gS1YjAdwyX8Cdsai/uJ80AstFczD8ajx7esNPpx0xbAym+YvdvrM1EgCS8Awi7yH/np4g5R7il1UJQxE/7YDsZzZlOLecgcxb7kDmtd7Z6hzkTzvwK4sIrA0ysrPgCt4iB8hNbP8B4A32Losq8gexlfpTei5K+h7SVz2vHUXo6Kk8z6WWI1+klkU/dlZztKk4vNZdi73IV7e6x4GsPeR/4vuQlugX7Un1qVl9jhbz+XunNhoGzt0DEQE4dxdHA+GB8EB4IDwQHrh6HjgdnLx693PjevT4kycfbG7q2WdTBGDfG+TLXJGf2iGHgFwArR/KgzR0zj8jf0H8andM1r+TBDyRU/VIpMAOSN/jKSdSAK8UBM9cqNaD/HO53Bd62EFuUqVXTnMBqPdonvv3BKrkCZAy4+6JF5ElhjwDe5YX2xN73tx/yU6pviog/DZSsT1gB1Kvd9ZIPZazHxRh5/fB7i/Xc3aE3PeJWbuvffVzf/ZVLMh/8smTNzYinG56TJyI57Gi5pK5fy0P/hw9EBGAc3RumA4PhAfCA+GB8MBV9UBEAC5+ZPjSVfDKl3GzCoAv54ODg+ZL+tlnP/3lTVdXto6dVQDa/RLyU3mPBxmAYACEuhrA06d8KPLP8su08586h3ooCB7EvzIEThYzPPKUTywnAJ565JdkPQtCUySPHrQUQSGHAPmlAUiQvbcagHr0vOx/HXdFwsyNZzsMaC7oXqg+tR4yJzcEuaHUs4c+3fT6k5/TvE3G9rl57MmREJstMpuqFtkjOYyu7KwKT9qS8b3qqtQu98f9q7+G7viHHa8jvA9Erlg9g17OHfIMSPmjR+++LUX8vklxsJfpgYgAXKb3o+3wQHggPBAeCA9ckgciAnBJjh/a7OF8/ui4rIf8kWGuXxEh9dD8Re+sBkBuKCVSwNx/Rv4DDYwM+Q8Ur0D+Kg+SB9HAQ5FXnnIiBPBQIhJECDQi4OVOgKgI+2Dvzlq/vZvAT3U06kqWcwboGZa3UxCk1nrlyGWEKhER1Vvb/XAmAsgUO1AvglRC+ugzh9/yKZRCP0vPfev17XP82e7AC9pVcSINumoARK3y+JN6eJWDb+UouRqU35W33nrz51ejR9GLXR5o34ddUlEXHggPhAfCA+GB8MCN8kBEAK7ecDJXdmfTtfnB46HnaZ/rnYDcenPJp2xVkf/SdkTTbH+vGUXyIH/k4ZFTHjmlc4F2MyAdggtb2T9NCJwcgIpy5IzW+zikKzvdUKoz8u9HBiwiMLJX1danL/Ocd0KyJcSY2zOojTxIspJsdubGkcv6zkUrZ/1x5ZyKQnHuJ6tDTjl3X2iuXgywPUKw7/Of9xOQCEqp/ataT4QFpK/9ZKdRTjPV+i08W2QARrshsC0KUXR2HsDpZ2cxLIUHwgPhgfBAeCA8cOU9EBGAyxsivnx3jgFf0i++9JWmp6VVAN7t8OVOjoArZ8hXD+cDEIGMQEKjfLjXdsTEnv5ee1ruIX8QvMrDg+zhPVqyg14P8VvFap5WKVSG/D3Ejx3o1JLTCe9QDu0j/1SjZxAgPyYiQAEDBC+UccuBDUOkzNlXzumIYubC2Rb5p6aV9zrE/Xr1514uEZXs91M27A3zOOeOXA1Mx2mmO27XexV2qETVWXvgajwtZ31XYS88EB4ID4QHwgPhgZ0e2Ik+d2pG5Vl5gC/hrWPBl/S3vvUH1t6wrO+hnctzeTbzpuu5QVw5y18Mr20HumptyFjqPVbn/nVdv6cH0gfJwyNPObxS5EtyqjeXkAgRgv3uWq2W+bx/ADkEWSUN2MKy7ydOAGZlO9aBQDMyzlCyq8h4V7bfBPq52cu62HMOPUeo8n1eUsf37LfXy9L6f+rz++wZsvI8zgW5UjWRReSIMD58+JBVAEdWR8QTUaXM/e/eyEG1gj+VByICcCr3hXJ4IDwQHggPhAeupwe6n//X8x6uW6/xOR9fQPp7diNEApqF4Xfv3n1mU/7n33/txxt6Z5qwHPsBkH0O3chs/vTLPJXWOwmSzk+BUbJ3iQCwrh0kRQQAquXu+n/ZI59mF7anv7eeHzmlitxB9CU5rd+XJwIws63d4Pe1s7BIyXSUVgXAu3ZA/qwegFeFcXpsQICMD2Ig//xc2Jz/2OaqVwWkyrhj77xpD6FK/4ho0A/uD75Esc99lXjsqV8ph+bFIpIDwKoKr9+0r/cxsZ0pM8K3X4+Rjd/UQj+53p5Pxhm70An10En6GWK/Cu7D2wkQuzxn8Ogtlst3N9efe/Dg6xta72j60YbWf4eJVAdGiXwSGYgIgDnmIgn/CV1km9FWeCA8EB4ID4QHwgOX7AHQ5iV3I5qvPcAX8fYxWS4eN16a3nn6NN7ii92LBJzG9i7dFeu3TQiEUcnpfuQCsCMfCF+RP2155dSXaMk++ueF/EuRgLx6oID86Sc0I0lDziBA6qEl5I/c2MZv35wA2gVpY69EkUd/nFebmKbNFGvka2U5EWTFw9MefsE+5SUeOaUu4kfQcik4MiDL27iQu0H73K+H/KnX1AZ2XpxoBf0YSPN7afIe0vfMHR4cMPfviUT5FfJARACu0GBEV8ID4YHwQHggPHBRHtiONi+q9WhnsAc+/vjxTzfCD+7e+dpxJebqFQkdlzl+fVrkz1zjes3UnVm388jZ4j6fBWBzjJWdwne8L8evQf7HyzbX+yL8oYhe5ZjTB+lrP7QeXuU8HqRP/dOrNBV6MLEdAq0COc0NyJEAmwNmzpaJU0VqRHpoDzl4pW4uwClXA4Bstb3BvLW/tkyZ0YoUmmShf1/2XBoSJhKQV6tYw8zlExEAWXv9RZ5+ZyRPDoWsEkGOXB14qOYC0D71RWrvG4B/JAb3ticNevo8ZyKe2cPDoyYCwGmmuaKNcB4rai77Q6gSwZ+bByICcG6uDcPhgfBAeCA8EB64uh6ICMDljQ1fvqwCoCfkAjSrAPiSPjw6+hCB4xTkr5EAkL4iweO6u66xu1x2l+WySkB1QUgAIXaYGzmrAFYWEfDmlBWha3se70UM1J7KKfIH4ZfKkfP6QzmIHoR/MGkWd1RarnzWF+RPuSVzu3vXZznNSqdCaI4E2GoRb3xE7cxYcg3U4MrOWBiBfFfpNRmvtmOYFTsbGjIej/9/9t482pLrOu+rO72h5/kBTTRBzABBEhRFQpxAUiJNkdbASbJjr9iSY3s5y3GcZGVF8Vqxl+JYWlGkeFm2ZDqSsxI5lr0sS7QpQTItmSZFiWOTBAlCJEGQFKYGGt2NbvTcb7hDqm6dX93a373n1b1vft37/dGn9jn77HPqq6rb9e29zynrseoRlA8Djc38ucFlggVjLmL+9rEuvgkg/VTEIzEocw3sN4JHbcD88/OPPedFP24UHXCN5dOnz7wgJvk9k2oXtwICo5+erTAzn4Mj4Ag4Ao6AI+AIrBsC7gFYN2hXbZg356nM0rmXXnoqK+fmjrwtK3mhV+aftWV/UUYQqAMeglx7+F/sDrfYGhhGtzN6T7wejCXiCbDWkoR9AcgJsDxKteNyFeOP98xblPkjw/gpq+zQTsz/cnjlxhNAiZ7K1BPzR07ANTBi6vW6k+VfMHtWBRC7jsjdwLAL/0/MgxD6w1iZx7glniP0WS1CfcGcw/1Tq+cz6kKBI/OqB/8asf/ifi5i5blHgHbG11Jj/dpeyIH5F/gLvolcJ/rx/KhMrg3tA+af30CxWDz6lNilpJ7ng/q1Kk+ceGbcVQC4ZCCheETXaipuZwwEAH8MVVdxBBwBR8ARcAQcgesFAfcAbJMrefbs6T/Jp3pPXpCFH2EWyvALZrLG5zv4GmBuWGUdrkgpCDHaGjHToAjzH+oXdkRjfwBtV1lj/NoOg1dmr7L2m1Qmpk/MXx842mPMn68O8qbeCzv+6Txi1xfmj35M5n4pmH+xY2Rg3IUrAEt5SU4+jB1ijlbUM1AwdyzkPbCTML7E3NOd5jDdL6en8q3mry32HWUpXLnPCD0931rqY+r/4QkIz1G3F+aBR4RpFfM0w2bLU/oV5EhwfcAxdj2worggD8pcE7noh+svVDAOzB49Snb+Q46Vuv4/pldVz9dLq/S8fWsgwH27NWbjs3AEHAFHwBFwBByBDUFACcmGDOqDGASgNMteizOnTpnsWmKTEBljsSTAEEpVqzpk57J2hBGy7rkLQ2KiUEOYXWQW3cC4iHkvhRNsBeanzF3NrLQd5o89ZLUXk+k308m3Pi8Yf9jzn3Zl/Mr8u4HhsQqDWDfZ79ih1OsLA421U0+JPqUy8MJjQ4ehUkO3llMU9ob65RSb2yKBaXN/hBwEtZ7wTYRg79o8VD3PQSFxZqDXX0yT5szker2Qq9Lr5Y8bDjTutyTsONjV/QbEE5GErywOnZZUFDkMoX6I0QcAYPBaYo6cAGS97kV9uH/GZv7B40H/1ZZ8vTS1w1cA1WRxibTB5Y1HwD6tGz++j+gIOAKOgCPgCDgCm4DAsqxzE+ZzIw/Jm/HIa8Kb9Qc+8MGA0Xj58TC71QJbCwytmhHakWBeRRZ2aGZenAXMn94qF54AWb+NvjLzWD3MPtaOHdrR13qV0Yf5I1cxfvQoiWF3yK6nIZQx5jfwyMCI8w44YDDDOnvwp75g6oGBF9cZRo4iZWCu9IPZxvaJGHTLOQd6mGde6K20nCUnIHgGuh0+QieeAHJoOvkd2EvE1xByVHR/AM5X54enpqgvcgny8wUf2mH6A5knIa9hb388P4VMh1CqHWkuxHH1ig4THnSCh+7hhx/WVQB4OPl9U8sAH/EpqrrLa4mAewDWEk235Qg4Ao6AI+AIbBMELF3YJpO+TqYJ9ryEQQFmwvnhCehTl+np6f7WcV/6ytcez9qnmnlQk5g7TJtSMYI5KvNDrxdinDAzGDuMpxe+2gczpJ6s/46s8ycHAPvYSwIlZR76TYE6lJCOVWXYIU6ZOrJ2h7lru9Yja/+YrEw/pjdU38wv+yAGnWvgAUCf64fM+n5lqEUsHcWKkutI1j3XF7miexoK5zbONZXpVvYPCuoBKJLdw31VeIQkB2B2JieOg1yA5UesN3JPQKEl+ymQ1V+0Rw4K3EI7561MG1nbVeY5Hqz3z3EtnueQrIDM/YB9SqarOQC06/p/7jtdBYA+XyXELvsPMD7189fmT2THczfNvTMr0x1M82SY9DCT07/84xeDbwLgGXAPQI7PpvzLfz6bMrgP6gg4Ao6AI+AIOAKbgwAsc3NG91FHIUCsbOS1aS8uPJd1mmrO3lLuzN77sexkGHe5z2qOC+Yv2dJqs2D+oaFqHsRSqzwBreBJWAoMeil4AnR8mDyMn7KqXu1UyZrNX6Vfb9mvAPbYu56OwRMC0wK3gqEWO/VZBj42c5dxlPnDcGGqqCvjL+orDjSLHXU8ToXdcN4k3Q8xlHrALXgCxmX+jIcnAeY7qM89CQSih847KIIL/Qo9lhMkuQUYNO1VsjhSMF+UMP+iYswDxlV1PX/a0YfpU68y9ZRLS0tPZsd8u4R6L7c2AkPP19aers/OEXAEHAFHwBFwBNYCgZEscy0Mu431QSCNtZ3NLO/YYT0AhOCJpcIYYJA6Gxil1qtcxJhZL8x6bVUMssb+UdMsa439w/jQr2KyBBb1DVYZfmEvHCjzp51VBkUyePAsJBHPAv0mLauYP9eF61Zk9wdmDEMd4JPXDL5ulyNSK04kzDCSW6HMn/OBuSozp52y0KNCyhjzRw3GiZxusTc4TI+6xL5DNQw+EU+A6VQSFmt5x6leuFOC56Bb9A/1NZDNyy76JVvlwyrGji7nF8Mphg/Pb6wd+1pq7J92jf0P6kk9omZlZfq7ZPYpKVnBo1mq6h/aC62tLm8IAvr7uSGD+iCOgCPgCDgCjoAjsLkIuAdgc/HPRudNWF/FeXPupy0TW1tot78bpvxAeeohmTlEIANEJOYAAEAASURBVActyigHLfaIGF8PV0JohsEUngDbLSpp7D+mSCowkWyI11KI8TeSAAvff8cTEQwyTj2SC6AeAZWLeYWrULSPyfzHzf7HLuc7FPMPE4H5c91g+sSeWQ8+TOjzE0CvOK/YwbABq6kUN8jKZMdlqDBaO8hAKuZd7H8AI891WPUAYyk8AQMTI49g/ngCGkn4yWMfAHb06+b32eD8cjxj54fHZaid+St+YXY8T4NxRk5721UuLC0cl0nz+yXVLm4lBHiettKcfC6OgCPgCDgCjoAjsM4IuAdgnQFeA/O8Sfc/d3bu7NnLmc25w4f6plkvDXFvNuHSk43MPgDRXo0wje5474wwPhg6dkmW7raxE5hrEDWHoBP2Zsc9AvPTLGbGwSrjEfOPyTBz9CjRryors//xTARDOj+1D9PVr/Kxo5/mTmj/FcsRxqpMdYjxyoCY0X6iNiSqPg4KVrfgoSpyRchFCbH8TmD0DXasDEy8k/D4DA3Zr+h1eF6CCyg8UMyHVQrFeYsHSq1WfZtD9WNycT/XgycisgNmrP9G13/yU58CyKqhcYLxKATgq7p5+3ogwEVYD9tu0xFwBBwBR8ARcAS2KALuAdg6F4Y342Wvydmzp/8kn/I9/QLGzE5iA+Yw7gv5aABg8J0iTXy0XtLIv8KWFIw+12MeQ9n/4SyHstSD+cF5AEfe0FFqFaggngA8A2xL0Awh5OLrelBHye6vYvxD/SMwxKrBQff41zfvgvmHHReJiQ+Yv42JkxtQZOnHJhCrh6qHdhivqhfMFz1SVoQJF4xd7Kq9cWXmE/NMcT80ACgY1h0UixyAkEti76qsU8CVfRiC2Aj1XJemnK+eB88L807IXcG+dhhTBn/uI8YZs3uh1g0uwthqgEJxhQe//7GPfXGFXb3bJiKgv0ObOBUf2hFwBBwBR8ARcAQ2CoFl2eZGTcLHGR8B3rTf+paHxu80QlOzzeuNPBQXCGjRo1uzn/Wut3K9rq0u9KsOyAFoLx+arTKTwAATmL30iDH3gkkF/aheiAXXI/ZluEoRD0USmGSR5c882NkvUGkhtsP214hpDxvOa2CehacmzJuv02m/tZoOng88CpSMV8hhuUgnxMhDqghqSaeXe8A6YR+ApJdnkfRCPYqxecP80dOSbH4YuTqo6nXrsUFf7VTJ5PaQ6xPT11U67AdA/UrHj41HPbkZv/3bv/1SqOOXQZ0tq3ziGdHLtUTAPQBriabbcgQcAUfAEXAEtgkC7gHYeheKN2W9NrxZ5zMmC7qINa7sRPAEdDq8C9qk3FYjZ05L4XvfjNKpMU1qQi5AyFaGGcFg0KoqyWmo0iP2iycAmZwA+sPwkbWE4aue1iO3Q2oFOQZqL9Y+8DzkuHWCKwTGix1l/tqOXqwcxKBzjVh/1cMezB+Z9e7cJ9SvV1kw/DAA8ynui6BQnFfI4u8Ks2d+vW5/8UzqKOK+zpl5jBHDaOmvJf24v2kf4Ml6lXycQT2aKytZDcG3PgoPUjgv5jU4z3wcPAE6auGR6jX6E009GCZpKHa9tX5hPv/IXzr+NR0jyPpDgZq9INR6uaEI8Ku/oYP6YI6AI+AIOAKOgCOwuQiYt77NncoNOzrXgJcxKAQegJmATH9HwOnp6d2ZfPzLj34zK2em8j0AyZ6HmVBmOtmfvrnntYN/e0nO4IscgFouK/Onx9KSdUh0FwOzmspPZ2k+9uKfW4h9vQ/7BeOjYswy5gkYs3sCM4Lhw+jpP2n9kEdCssn5ul/BaBmoolwrZsm42INxx4bXnICoXiS4znjK9NUO6++ppx+rHwo5KHR7+f2mMX4YMQwZe5TF+YZVAOoBaLI6IHTADs8XsX/wI/ufHADq6ce4PK/IwFUL42GX9kbwxCHrPHm+GYcSDwCyrgKoN1rGA4Ae9rGrMvOYn59/PDuem5t7X1amO5Zeysr0j8915C6CpNiQgdwA9wDkOG3qv/yns6mT8MEdAUfAEXAEHAFHYGMRgGVu7Kg+2qoRuHbl8nOZkZmpvcdGGStizkolRimPquvl311vBX9EzBNA13pg/ngCqJ+0nJj547Jo5LcyOQG6OkCZOPOC8SNTwvy1X5t18EGx0iMQ9NVOh2A//h8GjpQwyUhzuh1A/i6vzBn9WLvajfXHTu7nSaWQg4Jd2imVoVM/aal2VCYWrsyfcWC07A+RhKSUXivPDWgWuQH0yMsmlNxWV0ow/5gieIOz4kesn5wGHt8OOTghd6QR7iu+DcFGgeMyOr0fY/Otql9aXDqT6fCtkpJ+zAUI8y+p+uFmITDu/bJZ8/NxHQFHwBFwBBwBR2AdEHAPwDqAup4medNOY29PZuN0u3v6HgBdJ0yMctK5BCKddDpk9Y+20AoMSnMB0G6FbxIsFR+0p2X5kthopSdAmH8iMp4AcgJiTJ/ZwIjQi8noU+IpQKacD5SsGZjaYuA9zWL1RHCtEAyHcYoMY8SuMsZ6+I49zFzbYeqxdpgo9ict6T80boUhThc1Tht71GsJHp0Ic1d9zQHohs9mwvzb4cHZ7B9Czp/5ch49ebBrYcODTvh6YaPYb2BMVxKGI2Us1h9RT06+cJr1/zEVr9/CCLgHYAtfHJ+aI+AIOAKOgCOwXghs9ovvep3XdrRLbIxVAJwDsbT+KgAqF5aWTnOclQPGbLtXeQLI/scWRNquCqZ1UMaYf6ebJ/nG1v9XZf8PRqg4wlWBmsrhRDrUh68YNnTLuNB/wNDziqViXTlMPT+vdtiBjmGb7DRHRSip74R9Gpq1gEuQNZYd428wa7LvawSFi/HynrH+SYgZazs5IthX5q3zYzgYODKl9le7yAN9jkaXsfFj9WqlYNKB8bNmZTp4ZgrmjydBsv3Vnsrgly6v6TfV+XYA9kKHIgch1CMP7PHY5zV4AsCrEx4kPCa6SoAcgAYKA8NjHaXT75/A8LzG6p4888yTj4kmv1dS7eJWRMA9AFvxqvicHAFHwBFwBByBdUbAPQDrDPAamufNup++vDR/5VtraLswBWEm6bhoGPOgNZPfUrF9AGam8mh0p5vrddsw43wAPBlj5wLE5sWJhHZl/p1aPn4jrB9Xxo7ZRmB4xFz5ehz66hGAIet42KtksBEmh13srLbEM9QLHhtyBLC72vHUI6AyDJfxaB/CB0qMopT0oxrmT5Z/LexXMR3uB5h/LXx7oV3P74P8X6ykV5lVDlTxDYfA5JuNnPlD+Ac8PkeyyM0I8wdPzg85xrx7vfy5QE/xYlqbVZIr8LWvfW3c36H8hNItScKcB5Bt1kn4uMXFcCgcAUfAEXAEHAFH4AZCQF98b6BT37KnypvysteGN+877rizfyLdEOMmabiIUYbTrAfGwlmzfloJ57g5AFWrABr13GEB00eeX8yZUyucHSfLvGD+yGtVsiogIRcgMH/sE6tHpmS9PsytqC++wZAzPjwC2o4nYGDfcm0YHv20hImmC/21ycgxJmmUUgGGTH0RUw5MVQl3MT4dpFRcpDmd9uh5V9lVO4zDfavtrPMHh25g/ngCcJ81JDekGUl26YT64iHk2xviCSDHgvn0gmeB+7rAO+RiNPOt9wtcOC/6gxfnoe3obVQJ09fxesH18cgjjzyrbSIDvVS7uBUQwB2zFebic3AEHAFHwBFwBByBDUKgeMHdoPF8mDVCgDfvH3n/h/qUMl1236daGkNnuBhz6vXyW6AW1qcTOl9pDgDj1afyTxh05JsArWaeVY9HIGnm42suAHYo8Qzo+SGjV1mG78fD8Hrha3IRolqYg5nFGNlwLkDedVBvmT+GsYd96imLpH/5hkDRjsuHCkr0Ya6hHmZZMFP0paxi6Mxbug2J4+qN6Nivor/ev8xvEBvPQ8qcF8xf1/vjKVDmr0y3EbL6+fpeMb+AJxs5JroKRC4z80evHaKug8uWz5vrUowTDmL3RVjckAyvCsk78g0AtTeuDN58BVPxwc7DDz/8fDhmoQXOD1RipSAVU/P69UTAPQDria7bdgQcAUfAEXAEtigCowN0W3Sy1+m09BrwUsaCfr4GiLemvx9Aq9U6kuHxyKN/8pWsbIbPdpHdDWMeyPkwfN2LN3yYf2aj/BfbCVC/CRDbDyAJ2f3dbvjKYMj6h/mTC8BJlsfOjpVGxJg+5xlrV7tDcsgJSIJngByBIb0xK1phZz72ESgYYEX/IaYXXBKcH9cRMzHGSHtKDfND8QDQDlNGZsdA9qCHYRftcjDueUm3ajEkIYy701+HPfFDNj8xfph+jyz/ELNmFYB6AHRikjJTxOwHHgftEZHDdWBVQHHdivq8Xy2sSuFbAnz9j/uCcevhq4B40mDmPNfYbwXPGrOinq8BstMl9mhXe/TX+nan0/8GwMEDB96a6fhXAEFqe5X8Z7O9Zu2zdQQcAUfAEXAEHIFVIQCrXJUR77wqBIiFqScAo2TRmmuVvrFf6yt02lf6Zb21iw7LlTB/GAODay5A+p3BYCb/nLcyf8aIrgYIsf5kMfcAwPxZFTAzlZ/WUlgVUAtZ0qx/xjOAJwAmzLjK+LUdPS21X8H8VVFkGC9MqGiWtPluYHYwyHYHhIse/QOYna1NpcD8aa/KTYDpkytQMPsI8x8aj4rCY5DPF8ZZ5Qmg+6pLwTEJO/PVi5i7xRHmz1b43CfFvEOQHY9AOyS3kBOAfZ03103ruf7cn1wf9Ipxw3yRYf7o4WHhelFPGbPLfRDrR3+YPPJqS+53fi+wtzA/T+yfKi+3IQLuAdiGF82n7Ag4Ao6AI+AIrBYBwypXa8z7bzwCV65e63+Na++eRt8DwH4Agyh6zqU1hsxMa0krP6zZr/81GjmnYjVAK8Qe6YdHoCoHoF7P7ce+CljHUxAMy/L8YrU9DI/xY4y/F4K/tUANYfzoU1Kf9PA1BMsRPwzMqtjzPawThxkyLyX8yuhUn35awiC1v+oVsXv29CqY/PputMa8OB+VdZ60az398XykW/AZFTxWVBYeiXCd+NYDzJ4YPzsAdsJXK4c8ARjcoJL7h9UDMHlwIfavqw4K5j9YNjDRjBl3ok5jKC8sLPY9AHydtNQFj2Wpqn+4vjekjubyWAi4B2AsmFzJEXAEHAFHwBG4vhBwD8DWu568KQs1TXiz7q8C4M17fn7+yewU9u7ZdWy5U2FnwJgnYDgHILfWaOQMnlUBMH/GqswBCKsBWulGBeW/pfboW09zAXqB2daEGfZCtn3ZZnYM86e+ljBOzizxEOAJQC70CSqHilr4KmAxHlen6GDPi+qihMKF+cP4aIcBE2ulnrJox07IlYBJogdzjl3fQi9ywDg0F0ybikip56NypFtldRH7F02y+xvhvuChICegEb6eB/MvPAPkAER2/ovF/mX4YjUA9XhqVC6ug1yvop4OkVJx5Kt/3G0qT8z062FLQnU5ROaj1adPn3lB6rgUUu3iVkbAPQBb+er43BwBR8ARcAQcgXVCAHq0Tubd7DogwJt2/6uA51566alsjIOHDz+UlewImB2X/2qysxmxVbJ72Q9APQH6bQByAdQT0Ag7CnbCjoLlsSc5DoQpTWHIHSAwHog3TFWZOTJjwdjVI0B7VYm9gmkHT8AAx9wCzLEbmCU5AtgvcgJg8DSEUpmeyjg+YHzE/DGjzG/g6Vn+3Z6QMqsGBuMSgx/df1zPAPObtGSHxkFOgM3+aIScEuzyA4ZWLCegat0/9sYtlfkX/UIOBvdNk1yUcP9o7D8JWS7cHno9C7scFA9IXsHzS/NKy5gHKmbvxIlnxl0FwKXhhuJRjpn2+g1EgIuygUP6UI6AI+AIOAKOgCOw2QjwAr3Z8/DxV4jA2bOn/yTreldy97IWQmg0jZzmDE9jkawGaIRNxjvhq2bFtwHadrWAegLqU/m7ZIfFBCH2n4Qs/6XwTQByAShjqwOE6CT1sGc/9AFGjIdgOLQL8chhgdEngZHVYF6RV+BeLe9fHzKcjzjoFuRA1QvGL1cDho0Hg2bqYZSTMmwYvDLHgSegQCgfsmJ/AOaj85x0XpxfrFT76OFRKXAM+wEkXXs90W/j2Qo7/lE/mO/gStFWLhmPutj50x4tA/PXdf+E2PV5w04s+5/2tSp1B0Ds6n0zrkeBr5Fix8vticDyT8f2PCeftSPgCDgCjoAj4AhUIOAegAqANrEZyrPsNTpz6pRm4/anPGCAk50BjFJ7FZ4AZqUKQS5yAWTWMH666X4FjcD0OhGmh0cAT0BhB4YemO3AMyCLKAIRFj6MmaESDwHmhxS0IhhuFDvXqUIuwzBh/Kql9d0Q8+528o+tNWHE0lGvG8yO+2Cgnk90mKkONMpHAyZdrk3SRQfkCtj6tZJg5kOeABlgKLYf7p9i578xLyDXRcwPiXp92IlRFQv8K2L/KZLatS8zDnaKHIHw9cF68HgUuTwjrcQr01yEZR+FqpwAvkYaH6FYtbSMijdtNgLuAdjsK+DjOwKOgCPgCDgCm4CA8LRNmIEPWYUAWf8jrxVv4j/y/g+NtMOOd6x7H6lUqoRxJEl/u4Gk010otaaHPZsLYBuTZCgXQBWCXA9f3yM22g3jNOqMm7saamG9dy989Q1PAGbrUMRAkcjGX2uGWmT/s9MeE6Akth4YOv6HYQZOBykjdpX5dwMAWi/WVi1yXXq9nCNA+PEIjMuYqyay0uukDLUXvvbHHv/6sKCPHh4GfS44P5330PnK9WoGg92AF6sYyPqnVLtVMp6dWrivlLGNG7OvGmfc9k7YGvThhx9mFUDumhpsPRozNUjfiWl4/YYjoPfThk/AB3QEHAFHwBFwBByBjUdg2TjQxk/HR0wR4JrwcgaZ5PN8kJs+VZ6ent6doXb8y49+MyunmqzwzqSUkQfqAdMZyMSCGQ79fFiYR16bRSpJ789r2BmQdt0XoLuYv/AP7QvA6oDQkVwAYv/kAsSYc+EJCJ4Bxqe+kCO5CsRM0StKYXRF/RodwHTZ2VDN4nlph5ixtiPD/OuN/jYQ6aKG/ETbIfat9dilPyX3ATL7Cwxd90D98QCgr0yZ86NdyyEGHRSq+uHgGbJnb3NtjsrsMMjzoIp4zLQefWLxxfmoSyp0BF+Yf6FfGM4fa1YBNMK3NtAjB6AYL3gAWmFVDR4NPABcZ8pGwAdZVwHUw3i0qz1kpss489fmT2R1czfNvTMr0x1JL2Vl+oerMP98aFLkAPAkugcgx2lL/ct/MltqUj4ZR8ARcAQcAUfAEVhfBGCT6zuKW18LBJbNBWgvLVzIBplqzu4vDwaThjANZMv8y32y41bwO3RCTJN9BAo9yQVgX4DeUmCkYV+AZDG/xQpPAF//C54AcgHYEQ0iUQ+5AIzHvMkJgGFpPfpJA8JR1PQPGsHBEohzqTHPxobQ9fQrgSXN5Q6VQaNbC1nbxKCph4Eht8IEeoFAqUcAho8+OQEwOupjJbjF2qvqJ2X+2IPpw3CRaY+VxOpp707oqYHxMy6xeexVlUW/oIjMdWPHP+xU4Us/sv+xR39KmD/6yLRvVAnzZ7zUU3c2O+ZbJNR7uT0RcA/A9rxuPmtHwBFwBBwBR2BVCLgHYFXwbZ3Oi/MLz2Sz2TFrPQDMkNgmsUyYc10pVugQiHyaQ5BXsFMguQDFvgCyQ2CtcB0Q+sv7F/sDRL4VMPAEhFUGIeufHIEYs9J6zgtPgeYGgAdMH1lL1sl3K2Lyug6cWGsRU2cdOMFgyV1g3Fr46mIhJzkOrRAEXxp2WfRVNSeA/loqTtrOeXC98WTAUGHsxKbVEzBkL1TQX9tj9aqnciOybl71kMmeR64qeT44P853gE/uOdP7QvHlcnOeMPmq8bV9sK+FtmyOfPXy1SciI+Oh1ObRrjjVcnlTEHAPwKbA7oM6Ao6AI+AIOAKbi4B7ADYX/+VG582ZVQDo8qbdXwVALO7ilSvfzhT27d/3AIpZqcyk3JYdkxWvsT5lLMjdbs5MY56ATkgWaIU7K3wkMFls56fT4qxCVvNSZOc/nee4MueLJyDWr0sOAjkJQRHPAf04b+SxyxCr5g2bWDHzw06vVvEIBg9NK8n1ltpc/tyC5gRgNzZvmCl6lMwPOVbCjGPt1MN8kavKgmlXKY7bXpErUAseptjqBp0PeCpOXE/Fter8accup6XjUr9eJdn+PP/IsfEefexR9QDYGzLW0eu3JAL8Pm3JyfmkHAFHwBFwBBwBR2B9EKigH+szqFtdEwR48+4vCH/m6T89nll9+S0v+7GydZgwTKXctpJjGEvME5CEr+ip7UbIgmc1Qb2XT78VPAHoq0dgkBuQa5ATgL6WA/383Zbx0GvUA2zN8W79eth7vRe+HkgsHHuxEoZXtLOevorxhw4DnHGZ5A0t5h3JDWjWWM2Qe2pqAeeGUtQwzrjnw3nAUPEE6HlW2aM/9irLwORh7IynMfhKO0FhkNsB97H4DtkRT4I+Rwor82NVC+v8sTtopyYvtZ7rX3wpoCppxZqLS/UGJpdfBhSxMMFXAEkCGhPoyIBeva4IcHHWdRA37gg4Ao6AI+AIOAJbC4HxaNDWmvONNhvepJe9Vv/mN36jnwPw1rc8tKb4wETICm8ERgxv0tUB+q0AVgX0wh7iU938nbMbVg90e3aHwXE9AuoJGDB/Tj/fmKwRe8Vdyj0BjfzTA3RKv32Qw6y5ACgoU6M+VrbDfgK1YmO0mKatB29q9TokkhuAnpZ4XqhXRso49ZBdrzFu+rEOnZg5TB5PwEBveWKJPv3pR8k4A5yFsAYmrMx7wOxHj895FuOQAwDDD99yKOYV6tnjn/0DdNzBPHPLXCfW+RfjcWKhYqCHRl6Kmm3cAhLfHllmKngml1Hxpq2CQOzncavMz+fhCDgCjoAj4Ag4AuuAwOjX5XUYyE2OjYBeE17S4Kp4ApB3ZJZbrda+rPzaY1//albyNxSz5KtigcrQThawMhOVlUkxjn41sAMPqOUMnx0C0adsL/ExMWryUnMBbGu11O1az0JVj95iDnNtCt8G8Nqe5ALY2lGSLt7IddjJLobjKEuT1BHzZ3XBVAj5KlMlVs88VKZex0YPT4C2Tyor49V5JquMfeMZYF5DuQNhfwXqi1yDkLMyeD5yC0PzC4b1OWE89Kva8TyAB/rsA8D+BMV8eH5DB/Qp9VsAzZA7wo6R6JH1z/OPzPypZ/XJoYMH78naRnwDgCeebwHgucSUytR7uYkI8J/LJk7Bh3YEHAFHwBFwBByBjUYANrnR4/p4cQQk6Fko8obNNeONuk+h0zf6a5lme2npclY2W61dWVn1V6wSkB0BYQjaX+thiuQCoN9o5gy8E2L9tbDBn3oCmq38q3bddn7a5ARU5QIwjpZVzL8Tvlmmsf8B888tklOguQbkFOjqgsE8lmf+6MVwpH2lJcwf+7WQs7FSe7F+MNVYe1U9zFj1mDeMXBl8oU/svqgYfdDFoSPN2G13rcONbzagTsx/MF+ub/74MV/0B3p5jbarXiXzD9PjvqsxIQxVlMXOlBV62gzzp77Tbj/OcaTk90mbI1dA1VzeDATcA7AZqPuYjoAj4Ag4Ao7AJiMAm9zkafjwEyDAm/bIa3f58pXvZrYOHNxvdgSM2SemGGuvqofhsDqAbwjQj28G1JZy5sSnA9QTUG/mVIfQfT24DNQjsNrcgFaj7yhJc8BnmaIpa53gqgivxlWegF7Y2x9PiDE2gQCOk9oh5s86/27ArRn2Y+h0clw1+3+CqRlVZbimcRUC54+JwX0Z5h+YMKsUYkSY9mE7eQ0er8LDEDFE9j/nO5gfDjrLndAbjLt8+7jMvyoHgnlRauyf+ay2vHTh0mOZDXYeHcOeM/8xQNpsFXuXbvZsfHxHwBFwBBwBR8AR2BAERrLIDRnZBxkXAd6kCT6O7Meb+clTpx7NFPgmwIBJ5d1UHmlsRCXMQps6Icg6iD3n02WnQHb867Tyd82QGpDEPAHkBBTj6H4B7N0f9vIv9MY86DZz5j8VvkHQbs70ezbbefLyYkBZcxqqzMPAWP/N+v9WPcejEdadL4V9EGL2BnZyjSqPQK2R51Dw9cF62FehG/YfSJI8Vk0sHPtVdmPzW3V9sY6fnI9A7fnKn8T2laDj6WA1gs5H9WnHM6D3Px4B6ukPowcv7GhZpUc7/ZT5U19k+wMHDWtUkv2POc32Vxk9yv/48T/4IsehxBNJKc0ubgcE3AOwHa6Sz9ERcAQcAUfAEVhjBNwDsMaAbqA53rzxDPSp4OPfeOzj2Rzuu/eev5yVrB/uVn11r94MnoacovZq+QLydG95w0l6YS/xWjcPLsc8A9nY+V/OUBudfL0/noAk7AzIToFhu4CUJ7O4Ie9NbgAug1aRJJCftuYE1Ot5DJ/VADD9RfnmQCEHe3gCeCOmP/Y4G3AEV9aNkwuAHjH4JMnhS2PzfXwb4Wp1Oh2GoospYehVDJRx2uFbBXVC1MbaQMDuoEaOAgOvLzs76TORaCcI8wYnTMHEkYeZtLktUStKPAT0Y6fDQiEc1MPqF/Qm3cM/dn2wB+NnXF09QX+LSqot+x/gIVj+rBklfe7DsoG6elTkwmq2/8CCPfr9j31MPQBWYSDxAHMHhd+VgYIfbR0EuEhbZ0Y+E0fAEXAEHAFHwBFYdwTGfaFc94n4AEMI6LXhZY0t6vDeII/cERCGBWNlFOqbMIXADGAkMWZPO3YolVmSG0CWOnrpeuL+YYev1i2NJggpQ+7rqUdgsZ3rk1uAXUr1CFCvjJ6Yf21qZ1+lt3ilX+IJoF+8DKsFVEGYmzbjCdD6Ko+A6iPjAYBxwnz1eqCvJfparzIxdK2PymFv/UQYqOpXMX30ue84L2TaqUeGyZMDMai3R2qHVvBEjunRrvowf2X86GMPZq844AFg/X+hFxRh7tih5GuR6gEgBwA9Yv7YQS7mFybOc3jgwAF2AAw7aSRXgy4yHklkTPGADzk5UPBy8xDgP5XNm4GP7Ag4Ao6AI+AIOAIbjgAscsMH9gFXjABv2iOvXfqG31/ofvXatZeyEXbt3LF/uZE6vZCmHtaz12psCZjH/on5NxKbCwDDWM523pbnAOARYF+AJHgCkpDVrx6BXiDYtRDbZt+AqSbvrLld9QjEdhDUWD5MHx5fyCFXIOZJSL+6MPqUK5g/zKue5LH/grE2pgIzasCUjP1xPQOsLsAjYIwsIyhzRVU9A0MMFcVYWcH8tVtsHqoHjtX1EE7uF9ujarzYOFjR/jHGr3Zg8sXs8PPJ/RNj/oyvZdRjR9JJ6KBMX+2ovLS0pDsA8vEOYv104XcJmZJTRfZyCyEw+unYQhP0qTgCjoAj4Ag4Ao7A2iMwkkWu/TBucQUI8OYMR1ATvHFzDXkz7+cEXLp85ZNZh9QD8MFyR2L/6Z7nfcbZkBwAdGH+yJS1Rp7N3u0s9V8e8QRQdsMm+cp86N/p5tPFE0BOQAMHQ/AINAKDrIUcAfYNwI56BNqRj/+pR4BYP7F/7KmeyvnZZtpC1Nv5O3Qjkn6vXw+EOTabuSchfAIhXa0RixbnngHa03Xr/fsiXe8fXt7z26MV9vyv+kpf7LoUHokACPMEn40qY/Nb7fh6PpOOo/1h/MyLq6d2YfzoDXlSAvOH8aOn/ainZBxK6imrsv/RC7dT8LNRm97l4UZ66qlnPjeoHXnE75A2yoOizS5vBQTcA7AVroLPwRFwBBwBR8AR2GAEYI8bPKwPt4YI8AbeZ/7sCPjtJ775+WyMucOH+h4AWf47NDwMsCExQ2L/MH868n1x5KJkV4KiInaQx/C1VT0Cum8A+uwfgCdgaAfBoNhewjGSV9R2h5SIBVuPXUr4de7vSCP/gc9Qj14Stk/oBM9AozfZI1XssxA8Hm2oVxgA5l+MFw5YTTDwBFiNGDNUJksvve6aA4DeZpU673HnR78YHpwPegN5NDeC6aOndpW5DzF+OgbmXy++1jh6PDx2dFNHEbF/5oEnDv1JS7X/H/7D7/Y9ifyulOzxu1Oq6h8681dEtrA8+q7bwhP2qTkCjoAj4Ag4Ao7A6hGYjK6sfjy3sHIEeLOOcWzeyPvUmp273vqWh/ojDnawCzH4GpvS56sAUgZocg1g/jCK9Pvp/ZfFTq+gLPmZhJ0BOa060cSwUyD1VCPjcaiHveyHVgmgGFYLNOphNUHYP6DTyYP+eAJQxyOAPOQZCCgCFu0hlJ/UF/PUi+m8SNphPPYdgOEjd2vLP0Kt4BlIcyr6U+oWqy3yGWrMvhkomHoCOJ/CI9BZ7F8vmB/t4IoMU4bhVunTD33kqlLHqdJfbTseCz1ftavnq+cVi+WzsyM7PWIXezD9cJvQnBSMP5LVH1JkkkHMP+dg2MNQjPkzPiX6a1WKAyr56Z/+6a+JbR4dqXZxOyLgHoDteNV8zo6AI+AIOAKOwCoRMKxvlba8+/ogoNeIl7Z+zD8dEgqK3N8RcDr9y6bz+S988T9n5Y4ds7dkJbF7dgBMitUAuQeAVQF4AIj9N4IHIN1kXElPZrb4SxmQmW+6g1yuHzwC3bBDHKsF6KhMrhu+HUA7JTkC7BtQ1AePAHJVqZ6CKn08BHwxARmPQXcqnHbILcCz0A1p/ngAGKcn6+RhnLRrGfUI9NoWbz77JwZgjMqARS2ByWs9sl4n6rVkPK1fa5n5xMbjfJXps0NhjOHrPGHoBcNXBZUlu5/+qoasjL+ol6QDzpMylgNQb7T6zx3PI/q6DwCeFB1vfn6+v/5/bm7ufVlbmgNwKejgAWDHv/wzmsNbLuKxXPb3gnG93BwE+M9kc0b3UR0BR8ARcAQcAUdgUxCAPW7K4D7oqhDgTZxryM5cpLf3PQBPPvX0J7JR+Dog3z/v1PPYfyuyPADmX8xQmD/MomiXAxhH0u3mDJXVBWGW9bCjHx4Bcg0GngG7SgCPQKMZTpedBBm3ka+rL/YToD6Ui11gySs0d0DUh8TmEvDmTXgCkmZ+euynmLTsvIuvGYZcgA6pF2GEAqcgEyPWCZAbUNQXe+3nuQW9IKs9ZcBCKAtzHGhOwqA+J3QwxipPAf2qSuYX02Mc9JDRH/d88bDA+KGlsf6Mw7ipayQMaRwuTKP4et8gqx8CnHOsWnCEKQMfGLBHZOPr/JBh/rZX+hXA4FmKPZ+94CGKzYMcgE9/+tMfUdtB5kHg90fVOHEA03aXtxAC7gHYQhfDp+IIOAKOgCPgCGwUArDHjRrPx5kcAd6kI9Qj4U3cXEvW7f7TD//Sv8+G/MVf/KW/lJWpUsxO1jz0V8T+k5zCKLOAkQx1DBUwdmL3fJWs0IdPUBHWOMQ8AkOeAMahfygZj+qpsIoAmRwC9RhQzyoD9GMeg3FzCVgdMXC45JeVi8c4sVgwnhv0iGEjc1GV4auMPteNGDr1MX1i6BDhmB521qrUcZDxeOg4zJNcFmLvBT5hFUZRH1ZnFHZ6+Q1Jf+q7vfzGHDB8WvKSeaUc3DYESRk3DB/G3cRDNrJ3anVw4xgN6nleaCT3Rp9XnQf6Wv7ar/3ax7M6fkdK7XrLlpr8cLshMPpu3W5n4fN1BBwBR8ARcAQcgYkQ4MV4ok6uvCkI6LXis3Qwf0qzGqDVau3LZvv541/6ZFbydcDW1Ew/Vtdq5d2azXwVQLPR7FPTgvmH2L8yCZhHZjP7izELZTrEIPNe6Z7jkrXe7ViXADkCg9yAvOdwPxvjx36sVA+B6uEJ0Hrk4hsFdTvfWvgmAV8zHOhzNLrs1Ll8eXvK4Mb6I7aseNB56DoNqCoqI0ti4CMb00rGw36VHLMzaT3jxPrVAqNP79f+HzjWI0w/kR35uvl2F4V5GD+ehaJBDmL3P2o8B8hagiP1KlNP7J92Zf7kALBah+cWfebJfFSevzZ/Ihtr7qa5d2ZlyQNwNZPTP7L/8QRQ6tc4xryDc6P+7+Yg4B6AzcHdR3UEHAFHwBFwBDYVAUs7NnUqPviECEA9l72G6Zv/tczu008/83BWshqg12Ule57P3gnp5+FjfGnQcfR6f2USmc3sD0ZBTBM5bx20JyHWWeihEErsw/SInXdr+eniEYD54Bmo12eMpfZSvjyZnQbJHUCJ3ATkoVJXGahCAKoJ/wnt7VY+z2Y3JDPQL4jt4DHQ9l5g5s2Qzd8OMV+N/cNoMauxbJgv7UUpO9MV9XIA462OeVuHFKsDMKcy9astYfhqpzhvOc+iXjrUw+co8aCkd2hfw+6HmVXl9TBlMRMV9f5HkfsbmTLdIbLvkUsXxxhSBuNv9/ItKYudIEPH4v5na8GIx45xYiXP46c/8+l/ITq41vi9oZk7n3rmzSoA9Lzcwghw0bbwFH1qjoAj4Ag4Ao6AI7DWCNjX+LW27vbWEgG9Vry8QTXxBECF+7kA6YaAu7NJvPXtb39lVqarAf5dVs5Mtfr2pqan+2/sKWPry9NTU3kOQCPnTum//XqYizKhGNPJxsj+0EcPpqG5ALl2up2Y5ABQj0egkIMeHgHqYyVMCTucDzL91FNAfazUXAJyB3R1Af27bQgTNXnZlp0BNeasWe/EqmHsqo/1wkMQ1qEj0z5gwNSsrIztX7Aya8v0Cln6QxrK/CPUvxZ2vqR/Len0n6Ne0hjJXGnn/qVftMR+8LBxnw3pB716YjeG6NWs501XI2CHnTrxhLHzH+363FKvnhmeT9r37N795uy43W4/H+rwABD7Z+c/9QCAn8f+AXMblPwnsg2m6lN0BBwBR8ARcAQcgbVCANa4VvbczvohwJs1ngDeuPEAMDJv5v16sng//alPfSNTuHzhQv/rXlMH9z/Q70AuQMMyD4yNWyqTiDH8gklJLgDj6MlQHyvJEaA9topgwJSq3nntTn5VHoGhXIKK3IF6kWTBjPMyXXVhKhosuA+17eKTD3kFZ1EX5osRPC3cLGSHU4/ehNtCDLrJUdjYMV0dYM9D1FYsDjwV9g7B89BkZ0kZXxk/E2iEr2F2ejnzV6YNM+8mLZ6zvCvPC0w/GEQ/XR+RXxpdty+MH6PK+Iv5EdOnIpSs0knFPtDpfd2/xGkOT19Oz4NLLj2tyPPKc/rMs889kmmkHoKzWZl6ALIi++OAMq8d/MupDGr8aNsgwO/ItpmwT9QRcAQcAUfAEXAEVo/AWG+Lqx/GLawhAnrNeIlj/T9eHSOTC/BDP/Ij78rm8rP/4Gf+aVamOQBZkUzPzMIgcnkqZ8IaS4Q5FEy+rz2I9QdxaFWA1mMHBkK7ljBW1dNcAWL5xFyRsRfzDNCuJTkDQ/Vh34Iqz4D2QyZnAM8BMu2U7SRnus3E5gx0a/nl5TwpySJPYKgYipTgQ3/UqEeuKrk+VXrr3c79yHxiMX1l+sW8wE2YPe0wfHAucAv9qEefstALFem3BfT5RdWU0XkGrZInoF+jzH/S5/Yv/6W/9P2ZoYcffrgf+089h7Gv/+EJICcgzCh87CJ4Jqj0cmsjwH8eW3uWPjtHwBFwBBwBR8ARWFMExnobXdMR3dhqEdBrxkscwVE8AGY1QDpon9KzM+Aff/pz/X0BDh48cCybEKsBpsLqABhGq5kHM2EyMHcYF7KeFO1ajxzrp0wffZgdsurRrvXox5itegYK/bAev8oTUOh3SJamZmVl4RFo5B4Y5g3+WEWOxZDRo6z1Onrf9Juwj95aldhlnjF53PG4vtH7KsLcY/Z73U6fwab7CvRdYOlz0We8S0tL/VUzsX54ApLIeLSrR4D62PWqYvzMh+cSWZk/9c1ILoCuApifn38063PTTTf9RFam538+K9M/bmiYflX2f97LPQDgsC1K/vPYFpP1SToCjoAj4Ag4Ao7A2iAwkhWsjWm3ss4I6LUb69sAaS5An/GQC/D3/7ef/eVsnjt37OjnAOABmJnK9wVgfTGMAuYOE1M5ds7owdCRW83cYbE0yDoeaYJ+NMIIkbWdekrNGaAeZlrIsX0IgkcAPUr1EKg99CgnzR3o6N70IbscJtms9fr3ATvFwSRr3ZzxU696yMwLPTwF2I/J9KOsOm/0YmXBmDUWj0zHCPOmWUtl+tq+VjKejip7VTkAXL+YnXE9APRPv/XRvz94PnjuaH//Bz/4geyYVUKsGkqrfO9/QLqOS/cAXMcX10/NEXAEHAFHwBGIIaAsMqbn9VsPAb12vMyRC0AOADkBfebPaoCUsc1mp/Rvf+uj/zgrX3nf3W/Pyh07d/XX9fJ1wHRnwKw6UQ+AxhLxCPSVR/wD84CJoEL9uJ4A+qkd6vEMrLhdvk5Y2BXPQGwHQvUIFP2jdvNQK98s6ET0sENZb7b6HhvktSrxHGAPzwByrMRTEGunvmD6VMTKSHY9sfSx7cTsr1E9zD/mAaFdh4t5AmIeAGX+2IvlANDOc4vM83bixPP9df+vefX9/1XWls6z/82QUvY/sX+y/skBsMtS0o0Pgu11uR+Zt5frgwD/aayPdbfqCDgCjoAj4Ag4AlsSAWWRW3KSPqllEeAa8jKHBwDmT5kv+E9JfmaNXIA0Nn4wk7/wxS//Xlbu27tnf1bu2Lmz/0YP89DVAHgAYBRZn+wPTwD1MHHkXCv+L54ANMgNoB6ZdrWPjCcAPcpYO/XoxRhd0S4egaJ+zFwB9CmrxkOPshf5WiPt61Wqh4BxxvUUoE/ZS/+y4xizhymjR79J8aIf2f7z8wv9bP+ZmWmT/R/LGYjVY1fLGPNXPWTOEznmCaCd5xJ5Uk/AB37sx1Ya+8cD4Mwf8LdxyX8a2/gUfOqOgCPgCDgCjoAjMCkCsMNJ+7n+1kOAN3JmpteWN3fW9/Y9Ainz6Gf7/qtf/9f/POv41//6X/2prGy2lvKY3lT+NcBGI/9KYPrG2Pc4wLBxN2R9yn8w6nGZP32V4VfVq33kusTSmS/tzJt5dmXv9tibMcyzXnzLIHxmL2Tds+c+8y5KBgwVmiswLmPsJPajATDBdqeNJ6gYcj0OYp4HTg8PAXrpHvX9abDqQD0FMN90577+/ZaeRP88YPyU4B47J8aBOSPXaz2z3h/mjx7ywG4t95TxkYGiIa/nPLFPM/aQq+ar15vzBA/sq13sj1uSA8B8/tmv/N+/mvX9o09+8smsLM2D3wV+J7Lm7I8cAK3PW/3fbY1A7HduW5+UT94RcAQcAUfAEXAElkdgQ1jD8lPw1lUioNeQlzrICp4AypG5AClDWHZVwEz4ZgC5AOxlrzF/lWHYMG+Vxz33WD/qsQPTR64qtT/6aieqJ56GQX/rGaCesmoVAV8vVE8B/WsN+5W6jfIAMH6shLlqOwxX61WGqWq9yrFxVA8GPa6+9l8rmXmovRID7zfhAVC9WH88QOinevp70G96+ulnnssOHnzD9/5wVqbjkvVPtn/Vun88AOppzD2FmVH/23YI8J/Ftpu4T9gRcAQcAUfAEXAEVo7AyLfFlZvznpuIgF5LXu7wBIzcFyCdb3+hv64K+PRnPv8b2bkcPHzoaFbOzsz07e2Yzc0QW6xaDZD1Lf/hCaAOZq31tGuJvtYrY9d25HH7o8e8kLFDCWOFySHTTpl+rd0ypZAzMGhXYkVLXsa+brdZzH9SRq0egBhO9qyTZNJxtD85AEudbv8+b9TzHS473U7/eqhM/1aj3o+Jd3t57F/1Y3bpHytjTB597iNk9Qho/yEPQM1uHXnx8pU+07/zjtvfmdlMn9ezWVna8Y/YP54AYv6+7j8D6jr/4z+J6/w0/fQcAUfAEXAEHAFHoIwAceFynR9fnwjwZs/Z4RlANqsC3vqWN/75rIH9AZr1pL8/wEKjnjPZqXxVQKrS9zxgDMaMUcpx62HaMX3sjVtiT/Un9RjAWGHyfG8du+1O2jLiDz1K+ies42f1QCPf2S9lvDkzDUyu06sZ10At6fRf2pc6+fr5EUOOVQWzhlHG5LGMjVDaKMavzByZKaWB63wry1BBOwx/qdMx7fTDY5DK5rrSP2aX/loWngZZXQD+6HOf4QkARzwBXCf00yR96/lrNPv3y8LiYr/+r/71v/YXM920PzF+usL8+V3QEj1Kcx9S6eX2RsA9ANv7+vnsHQFHwBFwBByBFSFg3x5XZMI7bTEE9JrykgdJx+tDueyqgLd9//fflp3fz/8fv/D/ZKXuFMi3AvjqmK4CQM76lv9g+DB05LJOdqztyKqHrMxe9WlnXsjoMQ9kZWQwfWX0KjOfiUvJDVAPAPYmjf3DHGGcyNirKmGiVXraDn5ajzzuPGDe9Furst3Ok9vTb1+MNKntKo/sNEYlHoGYKteJdjwCeAKoV710fn2PxU/+lZ/4UKZTWu+/0qx/hsIDYDwiNHq5PRHgP4ftOXuftSPgCDgCjoAj4AisCAFliysy4p22FAKxa9oKs4T5a4knwKwKSBlcf3+AlAH2vyHA6oDDhw8ey+zxzYAqTwAIwbyRYdzIlDBwbacePUqYPHJMj/aYPuNpf5g/qx+QsRcrU4a7JoxJGT/Z31rPPGDWMERk2rVcL4av41TNA/1JGb8yc2TswfC1nvaVlmo3JlfZX6lHgG8oLLU7fYZPzH8Z5k/sX7P+NQdgKTLnNbmfI7a9eoMRcA/ABgPuwzkCjoAj4Ag4AlsBgRhb3Apz8zmsDgG9trzsEexUDwDyWJ6A3/zIb//v2fRedf99b8/K2R07+sxgtfsEZLZW86fMHlvpVw85XLaMMf9xY/ww/pR59/FHXnbQtBEmX8Xsq+xo+7iMO+YBqIrhM96446BPOSnTp58yeWXe6G3VkvnG5ldPev3ntFbPs/pVr1Hr9p/ncy9dOJG1veH1r/tzWUm2f5ozoDF/mD9MP+YB0AfFY/8ZsNfpH/8pXKen56flCDgCjoAj4Ag4AqMQUJY4SsfrtjcCeo156Yt5AvAA4BFYNifgH/zsz/1kBs+f/aEf/mtZeWD/3qxIJv12QL9T6Z9YLF7rYfzkFiBjShk99ZTj6sOEi3X8GIiU4zJ/7b5ST0CMgceYvY7L+Wn9uDLjx3IOYPrEupGr7CvTV33mTZY87VX16FFqf+rVzmpl7GrZrNf6z2O3mxPwep3H02rS/vVvPP7/ZS1/7sc+8H9lpTP/DAX/mxQB/jOYtJ/rOwKOgCPgCDgCjsA2RkDZ4TY+FZ96BQJ6rXn5g2rA+Ckn8gS8/Qd+YC4b/2f+/s/8r1l5aO7Iq7Jy184d/XFYJUAW/aTfEMhsjfqDwcc8APTBE4A+9VqiR71m+5ML0Gy1zmQ6i4tLh7JyUsa/UqafjZX9wbhzKdvjPb+c1MeYPwyWfuOW2B1bP+y1P65+FdPHzkrnT//NKtN587wtO4XUE9GPuZMD0A437OLC/Pms48/+7D/4u1n5Ox/96Feych2Zf2Y++/Os/xyH6/LfsW7K6/LM/aQcAUfAEXAEHIEbGAFlhTcwFNf9qceuNS+Bq/IEgF7KPPv7BZAb8O53/5kfz9oOHTrUTw6Ymmr154FHQHcQxA4lMX/kGNNX5o5+rIx5AtQOjJMYsXoE1H6VJyDG/CfN/ldGHmP8Oj/OR+uxp54E1YvJ48b0tf+4zJ9+sfnTPm6Zjtu/D9Ns/D7DRR63/1rp1cMNvbRk9/TvLOVJ+l9+5Csfzcb6W3/zb/x8VsL4s+PsL70vV5rtnxtIEl/vDxI3YMmP/w146n7KjoAj4Ag4Ao7AjYtAjBXeuIhc/2ceu+a8DMY8AZobYFYHAFvK0MzOgdT/8od/5aey4ze9+S0/mpW6WoB18+QIwPxh/MgwdOTMVvmP9nLdqGP1ANAPuykj6zND5jMp85+dne5/d/3atYWD5fFjHoCyTvkYZp4yvYtZfYrvnnI7x+oBGJcpYx87VWW91utT09LX8qq69NvHZfrjzlsHTfv1799uuLAbzezTfSZ4fnRqfZnzwpOEvNTOl+V3O/ky/dOnXvxU1uGX/+k//sWs/NQnPnEqK9eB+ft6/wzYG/xv2Zv2BsfGT98RcAQcAUfAEbhuEYixwev2hP3ECgRi156XwipPgLYbj0DKcPqeAEYjN4DVAv/N3/xvP5C13XXH7R/Myj379vVzBzRHAAYOM890sz88A7k0/r/K/LUnngCtr/IAoJ8yapM1PSnjL9npH8Zi8itl/L1up8/gu70aqzwY0pQwffTWO8YPIzaTKAlpO/dlqTbziPT6eBNLRzZKKxBijL5qnkND5R/nS5Z6OeGG6c+38xj/xbOXJmL82F9I/8IxO/whs9OflnR15g8SXiYjHyrHxRFwBBwBR8ARcASubwRiLPD6Pms/uzICsXuAl0Nl+uQCYAMmSb3xBKAU8wjQzqqB9/zgu/9sVrd33/6jWVnyCPSZnuYKZDrZn3oI8trqf2OMn54x5p/O68VMZ9x9APAEYHf3rp39/ucvXOrvIwDTT/lsP9ZfqyX9WD9yykh307dcVjHScWP8MP71iu0z56r5ko0Pox/0y5k+8rhlvde5mul2a42+hwlm36onl7P6pW6yq9/eZcv7TIr/EbNv5Rv3Fcxee8D0l8LOfouX+8Mlf/rUs/2s/t/8rd/4F1kfYvz0X0GsH0avjB+Zdoag5ISNx4pGL28MBPiRvzHO1s/SEXAEHAFHwBFwBPoIxNifw3PjIRC7F3hJVE8ACMH8KVVvRR6Bv/3f/Y/vzwZ46G1v+76svPueu9+albMzM/15pln2/ZL183gGMp3sjx37yLrOa4f/xXMwadY/ljTmTz2Mn/lpPbKWytg11o9+jElrf/S13OyYPvNJz4P7q1+10hg+TB9Gj33KGF60F8y+md/GyLRrCcPHg9QOG+Z1ujnxPnfqpcezPp/77Gd/Jyv/yT/+h33mnx2X/8Zg/KgT64fZa8wfPdqd+YOIl1EEzMMX1fIGR8ARcAQcAUfAEbiuEIixvuvqJP1kJkIgdk/wsgjDxyjMn5L6kbkBReP0NO39qpShjVw1gD4luQJveuMb783qDs/NvSUr8QykOwtmYpKuAzcegn5l+g+eApg7Mu1VJf2q9GjHE4BMGWPqa8341yu2X8WoOc/VMnyN2S+0u/2YPZ6dqnnA5FuB2TMv6pFjJUyfWD4MP1nIN9A7d/5yn+l/5zvf/qPMxsf/8x98LCurYvuZTvaXnofu5Jc3pOkl4UAZPbKW9FPmT6yfdo/5g4SXvgrA7wFHwBFwBBwBR+BGRCDG9m5ELPycRyMQu0fUI6AeAGRKPAfI/dwAhkwdAst6BNBjPwFkSjwD99573z1Z3Z23v+J7s3JmdrbPGBvNVp8J4SFIY6/98yL7PtMt/2nsvtyWHcPsVY961UdW5q+Mv4rRav/Cbvj6XqOeez7GjfFX7dBXNR/Gh+kTwyeLHxk9LWH4Wj/uuImss6/Kzh8aJ+zAB8OnHabfuTrfr7pw6dpYTJ/+sdg+7aV1/FSNy/jRxwOgjJ92Z/4g4WUUAX7Eowre4Ag4Ao6AI+AIOALXHwIxdnf9namf0WoRiN0rvETC8BkHpq8y9egjozfWqgGUYx4B2n/0/e//nuz43e9+75/JyltvvbXvGbh5bu5YJrdmZvoMaqrZ6M8HTwGeAWLNeAyyPtmfMndtz7WG9cZltjGmj91xGT76VUwfvdj8YPjoUa6U4Q/6K1GlxZaTxuzpHWP2tGss/+zZF/tM/w/+4GP/KdP5nY9+9CuF7ogDZfqoENtHXkPGj8kq5u+xfpDyMooAP95RBW9wBBwBR8ARcAQcgesPgRiru/7O1M9orRCoumd4qYThM64yfWTKmP6KcgUYdFwPwRve8MYHsj7kENx809ztmdycms49Ba08h4CvzDXrSX++9Xo+7TTmnakXf7V6czxqG3r0um1wK2xkB92kNprpdbsGr27Yca6b1Efqw+zrSa/fT+3GGD6TmZTpMx79izLE7JM8VSHRGH6hJwdk41NdxeyJ3c/P5zH8haWeieF/8YuffzSzVcXwGW8NmD6mNHaPrO1aP/K6pp30PnPmD5JeViIw8kenspcrOAKOgCPgCDgCjsC2RsDSlm19Kj75TUKg6h7iJdMw1nSuMH+mHZPpp+3lk2PzAABAAElEQVTLegYwmjJRs78A9VWeAfQoySXAU7B///4jWdvBg4f6+xEcu+VlOzO5V2/kHgNZd04uQaPR6DM2suSzPhv5NymTZ25RRh8UqmL0yuCxq0yeerLwidGnqwX6Te12vv7+yrV2n9ETs3/ppZdOZwow+wsXLz6fyboeP6tb7m8dmT7DwuwptR7ZGT9IeLluCPDjvG4DuGFHwBFwBBwBR8AR2HoIVLG3rTdjn9FWR6DqnuKlE2bP+SjDr5Lpr3qr8gwwmUk9BPSjxGOA/Ipb77g1O06/aXB3Vs7N3bQ/K9vt9lxW7tq1s+9BmNo51fcgNOr5abVCjkGmM+qv3jCnO0plZF2MkatyjKGjVzB1KijDTnkwd6o7vfqz2fHly1euZCUMPjvO/p741hNPZOVTT3/36azkb9xYPfpaxpg9epq1T/0y2fuoKJOHuWt9lUw/7FJqjJ96j/WDhJcrRoAf4xUb8I6OgCPgCDgCjoAjsP0QqGJr2++MfMZbBYFx7y19CYXZcx7K8Ktk+qse9gxl1h0IUaKM5RDQTrlajwF2tHz7D/xA30NA/d49e45yXC7xMJTrRh0fPnJ436h66s6cPnOe41GlMnPVIfZO/aQxePpVlVWMnv4xZk/7CIZPEzvzIcPglalTr3oxmXq1Q70zfpDwct0R0B/fdR/QB3AEHAFHwBFwBByBzUdgXJa2+TP1GVwvCFTdc/pSCqPX81eGrzL61Ksd6tGjNB4CKqs8BehRTuo5gNGulyeBeW2VUs8XuWp+VYye/hMwe7ook1eGru0qj2sHPUpl/B7bBxkv1x0B/bFd9wF9AEfAEXAEHAFHwBHYfASq2Njmz9BncL0jMO49qC+ryujBKcbsx61Xu7F+Iz0FTGKlHgMYLh4ElbG/ViUeh3EZ+KTj6vxjcszuMkxeu2jMnvYYU18rhs84ao96SmX61DvjBwkvNxwB/VHd8An4gI6AI+AIOAKOgCOw8QiMy742fmY+4o2OwLj3pr7EKoNXHGOMPlZPf22PjaN69Kdc1nOAEiWeBGXCWo9Mv40uq+Y3wXxiTB4T4zL6Kv2Ynap+VUyf/sr4nemDjJdbBgH98dwyE/OJOAKOgCPgCDgCjsD6ITAuy1q/GbhlR2AyBMa9Z2MvtzHmziyqGHxVe5WdqvGr+tNeVeJpgFEj00/rkWlfr7KKeVcx7Kr+q22vGh9clOFTT+mMHyS83LIIxH4kt+yEfWKOgCPgCDgCjoAjsHoExmVTqx/JLTgC64vASu/l2EtwFVMf1xPAWU+qTz/K1fbHTlXJeY/LhKvsVTHyWP9J+1Xpr/R8YkzfGX7synn9tkEg9uO3bU7AJ+oIOAKOgCPgCDgCkyOwUtY0+UjewxHYXARWe69XvSzDnMc9y7Vi9NiBAcfkcecV08N+rH219ZPaXymjZ54xZk87pTN9kPDyukOg6kftujthPyFHwBFwBBwBR8ARSJLVsiLH0BG4XhBY62dh0pfrST0IWx13GHrsvGhf6/NwZr/WiLq96xaBSX+krlsg/MQcAUfAEXAEHIEbCYG1Zj03EnZ+rjc2Apv17NwoL+3jMnm9Cz1mr4i47AhEELhRfkwip+/VjoAj4Ag4Ao7AjYnAZrGYGxNtP2tHwPNuqu4BZ/BVCHm7I7BGCLgHYI2AdDOOgCPgCDgCjsB2QsA9ANvpavlcHYHJEVjtM+6MfHLMvYcjsC0QcA/AtrhMPklHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEHIHtisBqdwnfruft83YEHAFHwBGo/jalfwnC7xJHwBFwBBwBR8ARcASuIwT8S0DX0cX0U3EEHAFHwBFwBBwBR8ARcAQcAUfAEXAEYgh4BkAMGa93BBwBR2DrI7DdfsM9o2Dr31M+Q0fAEXAEHAFHwBG4jhHwDIDr+OL6qTkCjoAj4Ag4Ao6AI+AIOAKOgCPgCDgCILDdokfM20tHwBFwBG4EBNb6N3qznb7diovmGQIVAHmzI+AIOAKOgCPgCDgCq0Fgs18GVzN37+sIOAKOgCPgCDgCjoAj4Ag4Ao6AI+AIOAJjIrDW0aUxh3U1R8ARcAQcgRSB1f4GVzlxG2WUjx8//vqyfOuxY+8ry3M33/z3yvIKjjsr6FPu4hkCZTT8eLUIrPb5Wu34ntGyWgS9vyPgCDgCjsCaI1D18rjmA7pBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8AR2HgENts7vvFn7CM6Ao6AI7B+CKz1b6o6aU1Ev+o0NOJ///33f6bcp1a35q9cuvzH5fY/+uM/+smyrMevvPfeN2pdWb7v/vt/qyyPOJ40Y8AzBEaAeANXrfXzttlQesbAZl8BH98RcAQcgRsAAfv2dwOcsJ+iI+AIOAKOgCPgCDgCjoAj4Ag4Ao6AI3AjInC9ec9vxGvo5+wIOAJrh8B6/yZWOV2rIvzNilM17WdOn/ndsv6efXsfKst63OnYgPziwoJRmZqeNrIKqf7j5bp9+/Z9b1kecdweUVeushMqt4w+9gyB0bhcL7WTPp+rfd6qcJv0/lR7fr8qIi47Ao6AI+AIrDsCVf85rvsEfABHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBFYfwRMtGj9h/MRHAFHwBHYUAQmjRhOOrlJnairjfCb+X3z619/f7ni1LkLHyrLu/fsNhH/9tJSubnyuNVqLavT69oAZqPZvLfcQeeX7gnw0XJ7erza/4M0Alt1PeyEZTIjRF+TPQKUDayqen6rrveyz9t3vvOdv10+l5tvvvkXynJ68c+U5SsXL/6/ZfnTn/3sr5TlD33oQyfK8ojjtb5f/f4cAbJXOQKOgCPgCCyPQNV/nsv39lZHwBFwBBwBR8ARcAQcAUfAEXAEHAFHwBHYFghUede3xUn4JB0BR+CGQWCtf7MmdYIuG1EccRWWjXB/5CMfuanc54EHHvhgWV68tvhDRu4svqMs93o2ALh3795yczI3N2fkjRb0KwOagXD58pWPled04aVzv16WR2QMlJuz443eQ0DHtxdAW12eFIGq51uf16rn0Tx/p06e/OnyhA7Pzf1UWdbjK1evapWRO0ttE/F/4ttP/PmywoMPPvjVspwer/Z+rcpg8ftRAHfREXAEHAFHYBgB/c90WMNrHAFHwBFwBBwBR8ARcAQcAUfAEXAEHAFHYNsjUOVt3/Yn6CfgCDgCWxqBtf4NqnJqVkUMFSwTQdRGjeC/8t5731jWWVjqvKss79mz+81ludGo31OWL1++XBaTrqyx1136l2RN/7FbbjH9d+/ZY+StJjQa9nJ0JaNBv0Jw6cKl3yyfw8lTJ3+xLK9DxLVsPjv2CKwisjq56vnX59neMBV7SKRr/P9meXq33367WeNfFeEv9x11XK/Z6evzee3KlZ8v95u7+ea/X5ZHHGuGgO4ZMKKLqdL70zMCDDwuOAKOgCPgCGQI6H+ujooj4Ag4Ao6AI+AIOAKOgCPgCDgCjoAj4AhchwhY9/V1eIJ+So6AI7ChCKz1b0qVk1IjglUnu2xE//jx468tGzhw4ICJ2C+1u3+h3L5r5+zryrIea4Re18R3Ozbgd+WKXXOsGQBqTyOO97/qVWYKGqE0jamg89Fd/VV/tbKOp/Z0/En1L1y4aPYUOHHi2Z8pj+EZAmU0NuW46vdBn3d9vid6fh947Ws/Uz7LVtN2X20GQNn2OMeXLl4y9+fnPvfZv1Xul35F4IWynB7bHwhpTMWqDAHNCFALniGgiLjsCDgCjsANgID+Z3sDnLKfoiPgCDgCjoAj4Ag4Ao6AI+AIOAKOgCNw4yFQ5Y2/8RDxM3YEHIHlEFjtb0aV01EjfsvNJWszIT1dk6+76k+3ph8sG2xMT7+7LNd7HbONfkfW4Jd1s2ON0Kvcbi+ZLrWaPf35+XnTvri4aGSN8Ks8NTVl9O+66y4jq1AVUVf9zZY1I0D3CJh0fpsQgdUpXu8R2Ul/H+wDkSRVz/+yz/sPvuc9ny8DvmN29nBZXu+Iv96vVc/bCr4iUD6d7FgzBDwjQBFy2RFwBBwBR2AIAf3Pd0jBKxwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBR2D7IzCpt377n7GfgSPgCJQRWOvfAHUqVkX0ynPJjk2EL5V3lhXSXb1/oizv3bP3R8tyr9d9qCx3kppZ41rrdpY9X434a0S/bDs71naNUOt37+t1O/ylS3bX/57sgt9u2wCfZgDs27fPTOnYsWNGVkEjkhqxrNLX9rWWdT6654F+FUDHb8oa77p8ZUD163V7u+qu7Z/+7Gd/pdxnHdZol81nx1UZAqq/3WQL+IQR//Rkze/D+fPnv1AGYM+ePfeWZT1e7wwAHU9lvb/1eVxaapvfq3NnX/ypso0777zzV8tyemx/IIblqowAMVd5/5n5aWeXHQFHwBFwBLYHAvqf8faYtc/SEXAEHAFHwBFwBBwBR8ARcAQcAUfAEXAEJkLAhqMm6urKjoAjsA0QWO0zXuUkrIrwm4id4qXf6daIfqNRNxF97a8Rcm3XCL22Txrxb3eWD6jprv6djg3oqjw/f81MSeerEf+OZAQcOmyWOCdHjx419lYraIRyUntXr9gMh3PnXjIm9u01Wy4MZVS8cOqU0dc9EtKIr2nfudMkjCS667tG/OsNe3s3my1jr9lqnSlXvHDypPmuexqR/XC5fcSxRmiXv4FGGNjIqnQPjVvL46UZD0+X5TU4nvT3wvx+PP/8yX9TnsPuPbvfW5Z37thRFpONjvhrhN9MZgVCQzJYzr147jfLZo7deuwny/KIY73/VKXqfrQ/YNp7WPYMgWFMvMYRcAQcgS2HgH372XLT8wk5Ao6AI+AIOAKOgCPgCDgCjoAj4Ag4Ao7AWiCw2ujgWszBbTgCjsDKEVjtM1zlBJwoYnf8+PHXlk/l5rmb//uyvP/QgXeU5XSNvA1hlxvT465E3Ls9G5DqSoRdI+hiLqmK+Ff11zX+k0b8r16zEX/ZEiDRjIZu1wbUOvJVgTRCbU7x9ttvM/LU1LSRFxcXjNxesgHCZ599xra3bYDwqaeeMu3nzp018qc+9Skj795tI/RfeMR8lj2ZnbHt/8WP/3npv9vIuufBK15hz3d2dtboT0/b89c9AjTCqu2aQbBLMg7Ov3R+0u+6m/mlgr0A2rr+somwa0ZOmuHwT9Z4Cma8EbZN+6mTJ/+Xss7huTmzJr7clh1PGvFf64i9/n5oxom26/yrZP09rNcbJ8p9Tj7/3P9clu+7//6PluURx3r/2Qd+RAepsj/I0jiGaH/gxujgKo6AI+AIOAKrR6Dq5X/1I7gFR8ARcAQcAUfAEXAEHAFHwBFwBBwBR8AR2HQEVhs93PQT8Ak4Atc5Aqt9RtXJN1FEX7H95te//v5y3aHDR/7rsjw9M/1QWdY15LqGvaybHWuES9uHI+Q2AKURfu1fFYHT9qqI/1CEXjIW5ufnzRROnz5t5CNHjhhZd73XPQLSCK3Rf/nLzZLtRPcM0PFfeOGk6a8R9Ucf/appv/POu4z8wgsvGFnX5H/rW0+Y9tmZGSN/7RuPGVmFo3N2D4M3vvH7jMpNN91kZN1DYM/efaZd9wTQjICpqSmjrxkBVe2aMbBzx04TkX3i20+YlIbdO3e+ojzgGBHasvqqj/X5vXD52s+Vje7du8fseXDfvXeb57msu8JjE+FXG+n8frhcd8999/2rsqzHly/bPSa0vUrW571Kf7Xtq80IaEsGUL1mf941QypNmPrj8pyffOrJv1OWH3zwQfvAlxvz49VmCAxbtDX2B9y2rYXkGQZrgaLbcAQcgesOAfu/x3V3en5CjoAj4Ag4Ao6AI+AIOAKOgCPgCDgCjoAjkCGwrDfeIXIEHIF1R2CtI/w6YY34L/vMa4Tw5pe97O+VDbZarXvLskbIy21rcawRrUltVkX4tF3Pp2qNcK9nA0wagX/mmWfMlC+cP29kFc6ft7vka4T/i18wnz1PtF3tqTw7Y9fIp99RNyq6Z4BpTIU9u+2a/BfP2j0AXlbxFYLD+/cbk2desue7tHjNtJ87e87IBw8eNPKZF1808vkLF4x88OAhI6cRbiPPzNhd46enbUaAXk/NENCMjTRD4pbyAGmGhNn0oCZ7CKRr3H++rK/Hp86cM8+btjdrNXtCotBJeq8vV52/eHFXWd4v12NpaeFYuf0bj33j98rypMftXu9iuc/c4QOPl2U9bs3M/k/lOo3w6/Na1l3JcVVEfr3bq+asEX/V199H3RMl1TcZHC8/dszcj88+/az5qsDxLx03GQLpVyBekDH1/w/NEBD1pKMVIq93EGq9MwzkdBL7H4K2uuwIOAKOwBZBYL1/fLfIafo0HAFHwBFwBBwBR8ARcAQcAUfAEXAEHIEbGwH15t7YaPjZOwLDCKw2Qj9scbKaKifdqiL8R266yeyqPbtjxwPLTa8qIq59J9XX/iprBFDX/Gu7ympPI7wqP/nkk6bLc8+dMPJdd91h5IsX7ZpkXXO/tLho9O+97z4jP/bY14x86623GfnYrbca+fSpU0Y+Mjdn5G9/267JP3qzXWOv87lP5mOMpYJGvHftMgHlRNfMX758yZjYtcsGrDUDQOWnn37K9J+abhl5cWHJyNquGRhGORV0/gcOHDAqc4LnPtljoNNdPsC5V/R1D4NLl6/9l+UBU7xMxLU1Zc+32WzcbvQvXTSbHly8aDMgrly5UlZPvzJh5/vyl7/ctOseFTO7d7y2rJD2/9OyXHWczt5c8MVOYjISdu+aNTdkVcRdx6vSr2qv+n1Y73Y9n6o9UIb1JwtwN1rTpkOKz4+Xbf7ge97zjrKc7jliMlTSr0L8arl9A47tDTv5gFX/f05ucfkeBt/lVVfU6hkGK4LNOzkCjoAisNE/jjq+y46AI+AIOAKOgCPgCDgCjoAj4Ag4Ao6AI7ABCHgGwAaA7ENsaQTWNML/kY98xIRsR6yhtIuck6TKCacRfgXTPMPHjx83EbvbXnHbz5U7zOyYfags65rmclt2rBF8XSOv+ipXRtBk1/wx1rSaIdS+Rqgf/epXjP5rHrAJDhdlDfoeWRN9+PBh0//bT3zLyCocPfoyU3X2RbtG/uRJuwv/xYtmiXSiu+rX5O689557jP0vfflLRv4+icjvljX7RjkVWrILvn73XvVXK+/ebQLCybV5e/6zM7Z9dofds+CFkzbjYVoi5Eli9yjQ+Z44YTM4jhy2GRMvnDQB+OSRR75qTMzIHgGmMRXe/OY3map77rbXyzSmQqPZ2Fmue+bEcybCvnfPHrMpwd7dO19X1tf7f2FhsdycXDhvMwJmZqZNu34lwTSmQrrnh+nw3MlTT6nOJPLefftsyox01vOR5iGxSr+qfchgRcXiwoLR+L3/8HtGfu973mvkmVl7/5rGEYL+/qnKiDX+qrKs3OssLfv/zdXLl80PXrrnxy+UDT7/wum/W5bTz7Z8tiyfPn3GPEC7du34Rrn93LlzRv/ZZ581+mXd7HjE/5+qUrUHgepPKk+agbAsvpMOPkJ/rTMMPKNgBMhe5QjcCAis94/VjYChn6Mj4Ag4Ao6AI+AIOAKOgCPgCDgCjoAjsOURMNHDLT9bn6AjsHoEJKZaaVCdZCYikEb8by9beNtDb/u1spwev0tktTdRhD8dz3wI/cHXP2gi/AePHDJrOmXsdRc1Y2C1A2oE75J891t3jZ/daSPIjaZdQ/27//KfmSm94/1/0cgqaIT04AG7a/yZ03YX+kbDBGyTaYm4Li7aCKLuOXDbrTYife3aVTOlQ5KR8N73/pBp73RsQCxdM27adVd105gKuqv+TTfdbFT0KwKmMRV0TwFt37HziqnSiL9+JeDaVZsws7BoMwbuvusuY2/XbrsnwYsvnjPtGvFXfb0+uqeA7jlw+ozNSND7Te9fM5lU2DEztbdcpxH/NMPBRPwbDfvzoWvc9SsGGvGfmzM/H2kA1/ycJc2mfSVYXJg3Iewj+/YdKM/39PnzFuByY3o8d/jQK8pVer7ltq143O3ZXd0//4UvmP8/mg2L17//uM04+sGHXm0itgf27TYXUHf5r4rw1+X6V+lX3X+TYp5O3tyv6XxMysOhI0dMRLnX7Z0sj3Hz0Z3/g5WPlcWhjJg048C0q7C7MWXsX1q69mRZp7PUNpuyTE01TXuakfBoWf/SlStPleUHH3zQpgCVG/Nj+4M73D5pjX0gh3ub+2e4eeIac39O3DtNElxBH+/iCDgCWwCBtf4x2QKn5FNwBBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBRcC6r7XVZUdg+yFgIjQrmP6yTjGN+L/7be/8vfIYV9vXfqcsjzi2IdlhBfNMpt8J/+myyuzOnWbX/mbLRrjLuis51gj+pGv+VzJmuY9GtM5IhHXffvsd+D/4/f9Y7p6898/aiPhtt91m2jWi/29/87dM+0/8xF8xskbQDhyyEav5+Xmjf+mS3fVe19R/3/fZNeK6B8M995mAb7pruw0wacaARnCvyhpw3fW9I7vAp2u8zfx1DwXNINAIsemcCi+etXseaAT+1AvPa5dl5YVFu8v/y262EWyN0F++ZAJ+ia7ZP3DQBLCXHXtU48WLNoPhlltuMWqdtp2vRuiN8ghh757dxuBUq27kpSV7P+j9M9WyGSg7d9k9EV4m89Up6P2wIGve9+0/dHe5T5oB8PmyPDM9bX6/Dh06ZPYkKeuOOtb7eZTOWtbVG8v/HH/jG19a9v+T22WPh/m2zfD5/T9+zPx/cvdtNkPl/jtsBkvV/aK/j4pFJ6mZiGw6eTN/ta+/b2qvStb+tV7XjLewcM185WFxqftI2ebC/LyJwJfbsuN6vWF+8Ju1mknxmt85a1KU9u7dY8a72k0Ol22mX6F4S1nee/Dw3xC5LCaagTBpxsHC0sLxskHdA2FEhoF5ftK+9oEvGxvveL0zCjyDYLzr4FqOwJZDwPzntOVm5xNyBBwBR8ARcAQcAUfAEXAEHAFHwBFwBByBNUFAvY1rYtSNOAJbGIEqp5cJCaURfxOB04j/Ur1zb/lcP/3Zz9oQdJJM+owZ/XqjaULGtXrV9MuzGT7WCP+wxmQ1ak8jQmpNI3yf+tQfGpW3vOWtRv7m498y8pve9GYj65r8Xs8GJBbn7Rpy/e75+370fcZe1fw14qsR+hMnnjX2dE24RtQ1oq8R117PBPQS1dc1/7oG3EwmFTRCrfO/Kt+Nf/JP/9SYePxb3zZyZ8lGPE1jKmgE+tZbX2FUNML//CmbIXDbMRPwS9LvmJv+uib/ymWbgXHs1luN/pR89cA0poK261cB9uzZabrskgi7Xu+FxUWjPzVt528aU2H/3t0mBeTqNXv/qn5NPhOhGUF79lj89stXLtpLNmNhembGDHFF9mBoNRITYdWI/5FDB+4pG2g17Q+WPv9l3bU4rtpFX8fotu3vxbe/Y39vHvvyZ02X+1/3BiOnEWkj75i2GRd332aakyeetBkq//HLNgD+Q2+w1+uVd9xhDYikv1fpbOyEKvQ7XXv+oj6xqL8n3XQThfJfs1Ez93dz187by+3Xrs7/UVnudjsmpSh9mqx88dKTZf2LF+3zX24bdawZBq2plvn/fnZ25r5yvzO9BZMBk+7JYeR0ywjzg9hsTf1wuf8tL3+F+UG4dOb8yXL7fG/pTFnudtqfK8vpHgWmfYyMgnL3lRyvdQaB3nDL3q8jJmxvqBEKXuUIOALjIbA6NjHeGK7lCDgCjoAj4Ag4Ao6AI+AIOAKOgCPgCDgCm4yAiTZu8lx8eEdgJQhUeZCrnFwm4p9OwDwT73jHO/5deVL1mZaJ+F958dJvltvH+G6xsV/uG45N+5NPPfl3yjr33nvvZ8pyTyJQ5bZxjtd7jf93vvl1M42lro047tm7z7SfPvmckbt2E+dE16jfc/drjH6nYwMMO+W78xpR1/El4J6kEShjX8fXXfXTNa1Gv0rYJ+evGRVTM2YT9nTXdrsktN22t6/uYq/jX75s17CfeO6EUXn++ReMfPSoXXP/0ENmCW2yMG8CXklb5qcZBRdlj4Tpp+0eBGbwEYJmHJy7YiOqL7/15aaXRvRN4whhUSL2+pWAXbvsGm7Vn5EI+jWJ4O/cYb8ioWvQl2TPB81o0Snrmu40Y8io7Ntnny+NGOv4df09kYyaa7InwMuOHn19ecDZmabJEFhtxH/SiH55Ltlx1Zr5rz5qI/APP/y7xsQrXmHvp/Pn7O/XzgP2eZ+u29+LZMHqH9hlI/x/es7ev//835iAdnL0jotmPh98s/1KyG232OfTKKfCWkf41b7K+lUDzVjS+zXpdcwN2mo1ftTYrLUeKctVewaUdcc51gyD+fmOyVBoLy2YH2Cdvz5P6fkafd2jo9G4bNpf6vXMVxWSxpTZFCId783l85idnjIZBJpRcOb0ma+V9eevzZsf9KqvIHzj8cc/X+6fvs/Y/yDKjaOP5QEYUqp6HxvqIBX2P3hpHCF6xsAIULzKEcgQWO3D6Cg6Ao6AI+AIOAKOgCPgCDgCjoAj4Ag4Ao7ANkDAhgu2wYR9ijc8AlUR/yqAbMhUIv66637qwX+gbHA4Anzxo+X29HjSZ0r1zfzSXYJNSCj18P9xebyZHbMPleX1PtYItUZAdPxX3GWWSCYf/4PfMSqvecCuqf2Tr3/TtL/pLe8y8sULLxl5j6xp1gwAjehXrbGvJTbA0BMfqe6SPrQGXyLAzeZkEW6NoM/I3b4k321/6aVzBo8TJ04YWb9KoBkLGtF+9avvN/31fE1jKly8eMFUaYRfI+T6VYB0jazpPzVzwMgq6FcGdE8B1a+S0zXsRkXnq/gY5VTQDINdO3caFf290DXSU7ILfVuurzE2htCo2xtmr+wBUGViWvZIuOkmG2Gelz01pmdnTcRf7/eqDID1jvDr+X73medNlUb8F20ANnnCqqd9nzD9dxx5pZE1wp8k9v46d/mi0dcYzNE7zKb3yYun7X8Pv/wvv2H6H73jlJH/wjuOGXnukAmwm7ZMqPr9HupQUbG4aDOUNINFf4/VnOqnd/PryjppOPdIWU73EDEZAjNTLbNHQFl3nOOFhUUTMV6q2f8PdI8PtakZWvp70pEMH/29T6+H+f8/nY8ZIn0fMRkECwvzRj54aM68Dxw4sP982UBjetr8QOzef3BPuf2d73yn2QTj+edPmvePRi15rKz/9LPP/nZZHvFVA3tDlJVHH682g8BesIo9MdIpmOs9ekpe6whcnwh4BsD1eV39rBwBR8ARcAQcAUfAEXAEHAFHwBFwBBwBg4DxBpoWFxyBrYlA1T2rTi3jUU9PyYZURE4j7GYRqEbYO0ttE2Ldt3+fiVCk9pf1YKdfFTAhmVfee+8byzAvLHXeVZbTNahvKstL7a4JqV+9csmc74GDNoJU7jvOsUb4tc+kESONAF66bNe8PvVNuwZ3StbsH73lNjMFXVM6L2vuO20bMUlq9vLrmkxjPBV0jwBtv3r1qqm6eNFG9K7ILvq66/68rJmfmbERQmM8FV44YfdQuPXO71UVI7fb9vbTNem6S77O55Lson/y5EljXyPiu3fbXc+XZA19SyLK2v78SRti/cPPmIBesnfWBmhuvtls0p3cdLNdEz01ZfHU+ZqTSQWN0Kl8SJ4nzXDQCPmrXvVqM8QOWfOv85mVjJErsmeARhSXlmxATZ9HfT6OHTtm5qOCPp96/rrmWb9ioGu+Z2ftHge6x8iCZBAMz0cDeKqxvKx4vHTeZqh8+MMfNgY04m8aU+Hy0gFT9cKzNkPpztvt/bfn5WYJt+k7jqAR/3H6lHUWL9mMgLe+YX+5OXnHG+408q6dJoBs2sYRNMNFf//GsTGJjv4+12oN8x9KmmHwibI9XeNfbsuOz1+6bNbgz0xNmZS0hfnL5gFqSUaXfoVD59eRPWT0/lS8NENL2xVv/T3V81P5yM0vf7xcd+zYLSalZc/srMkImNk5e3NZf2bHTpMSlG4ZYvR31adMBsH5q5dMxsDC0sLxsr1HH33035XlddhzoGx+1PGkPzj2P6RRFr3OEdgmCBjysE3m7NN0BBwBR8ARcAQcAUfAEXAEHAFHwBFwBByBCRGoiqZOaM7VHYE1R6DqHlUnlg35SoR/xOxMRsDzL5x+pqyzY2bKRAguXbC7/h//0vG/U9bXiP6BAwfMHgI79+z5K2X9NAJ3uCxfuHjFhEQvX75kdvE9deq0iXika3bN/F716lea8TSirxGI8thrcawRRV3je+WKjaDrmmPdBV3XRC9es/07Ff54jchoBEXP+exZu4T08GFzeZIvf/lLpkv6HWYjp9fbyDtlTXiraW63RL+7vn+/7a9rzJOavd31O+7z8xafU6dsRPD8ebMkNNEIkq6xNyeTCm9605tNVVVGga55vyYR4KeffsrY0wyAQ3tnTPvLbjYBqGTnLpuBMOmu/enzZezrngKKT/tFO9/73vyDpv9ByRjQ668R9WnZg0Aj+Crr/az46p4Nev+ayaaCPq+TZgA05H7WrwhoBsA1yZDR+ag86e/VgmSg/PIv/ZIx+eIL5ucymZozCVlDEf9zZ54z/RfnbcbP1csvmfYdu2zE/aZj95n2qd02Y8A0boAwNW8zGF772jvMqO96071Gnmrpf6emOdGvXGhGltVee6nqeWi3eybFrNZoPF2exfzCgkmp2b1r5/eX20+dPGEuqO5xUZVRps+v/l7qV1n091F/f9L5lqc38bE+33v37v9u2cgdd9/7lbJ85NDBN5blXbt2ms+gXLs6/0fl9tZUy6Ro7dq12/xgpxlvRm416uZ959LFS58r27t82e6xdN/993+03J4em+snbZloU+JGKFRUeYZABUDevH0RsG+T2/c8fOaOgCPgCDgCjoAj4Ag4Ao6AI+AIOAKOgCOwDAJV0dVlunqTI7AuCEx6T6oTS0MWNuQqGQHpmvxD5bNId7E1HnFd43zu3EtmjduRuSMmZJtGJEzI+Pz5S8bDPRTRP2NDiufOnjUR/vLcxjl+3fc88ONlvYMHD5hFnmudEaARxPLY2bFmAOh3uTXir7v0a4RT7WvESSP8GtE//cw31ISRb77NruHWiK7aM51HCM2mvR01AlyTiP7zz9uI49GjLzNWFZ+zZ1807bpmdN9eG/HUiFNTIriKt+7yf9vtt5vxNMNAMyKMcipoBErlRx4xAahkesp+RUHX5O8/aB6/JI04mSE1g0J3/dc9CjSCfvHprxt7c3fbXd/vuOs1pl3x1DX/RjkVmi17frrmX79qoRFxlfX6afvRm82S3qTesD+fbdljQO/XmVnzc5ImpNj+mgGg53tNMnh0PNWv+o69nt+v//q/NCY+ftxmxEzNmCXLRjcTNMKvCtpf9TUjQPtvtQwB3TNgV8tmNP3AW7/HnMLrH7C/R5fkKyAa8TadRwh6/UaoLFulGQD6lQH9qkBSaz5rDNZq5gdn5+zMD5Tbn3zyuybiXW4bdVy158v8wrzpdlUyYjTCr7+PpnMqVLWrvsqLizaj4MCBg0blne98l5F377ZwLFxd/MOywrOnTtn/kMqN6XH6VZKpctXePbuXzRhothrmB2t2asr8B5nuKfKvy/bS/39MxscWzBioyFksn40fOwLri4D933t9x3LrjoAj4Ag4Ao6AI+AIOAKOgCPgCDgCjoAjsEkITBpt3aRp+rDXEQKrvefUaWVDrBLhT3EzGQBDEf/XP/hzZWzrzbqJoO+RCOqly1cvlPUvnD9vMgYuXLx0wrRfvGi2pb9y+bIJSe3ctctum13uPOL46pWrJsNgx84dZ8pqe3buvKss33ff3Q+UZY3oaQRPv1Nc7psdr3fEX+fz2Ne+Zqbwla88YmT9rnxnyUY0Xvu61xn9V77SrnGdnbYRzakZK2uE5bvf/Y6x15Fd9zXC88wzzxh9jUjfe889pl0FzZA4eNAkrKh6ohkH+lWA9LvRps+CfEVBMwROPGdu50QzIm66yQRojO1MqIqInTtn91z4zGfNEtCkvWQen6QqA0AnoBEtzeiZleut3+XWDIDvfdcHzBB79tiIskY09+6za8xN51TQiH+3awNEGlHVCGe6y7kxqXsqfPITnzTtr5Pn4TUPmJ+HRM9H9+iYNANAn2czmVS4LF/R0DX8qq/4fuIT/9mo/Kf/9HEjn7p2u5FV0Ii9Rug14q/9VdaMAG3X8bR936FbTdWBwzbivtF7CFRlCLznLUfNfG+WjCXTmAoasdf2IVlv+CEFW6H2dZd+zbhptqbND9z0VNNEpPUrKKdlTxXNuNHfD81Iu3TJ7jmi/78My/b/M3u2yVDGk36VRMd/7DGb0XT1ik041IyrOckYet/73m+msG//gWvliqeeOfGxspxmrE20hl7fj+68/XbzA6p7DKS/r3eUx0v32LEpYN3OZ8rtFy5e+J2yfOedd364LKfHbZFVtD+42josT3T+aXf7H8CwPa9xBNYMASVTa2bYDTkCjoAj4Ag4Ao6AI+AIOAKOgCPgCDgCjsDWQWC10ditcyY+k62KwKT3mDqlNMKv52ki/MePH39tWeHWY8feV5Z1F/5r1xbMord011kz/kHZ1f3E0yf+sGzv9PnzZtFklcc73fXb2C/byo6r+qt+lfzqV93/Z8o6+lWDclt2rBkC2q4RuKH2jnWQ6x4ACws2onv8C583Jn73d3/PyFW70hvlVNAIxuwOG9G/5RYT4Enuvec+Y0LXvJvGVNDvwGtE/8jhI6bL9My0kefm7C7g+t16XUNeb5jbeyhirBEoXdN54cJFM/4LL5w0clVESs/v9ttuM/0PyK73en11jwbNCDh/wSTUJI888lVj//Idzxj56PfY69mcmzHtjRnzOCad+QOmXYX7ujYCfqr7XVXZUHmubgJaydFzNkPk0H4bEe507c+rZgz8o3/0D838X/Mae756vx59mbX/6le9yvRf6wwAYzwVNCPgyjUTYEy++siXTZf/85/8qpFXK3Rbd67WxLr23+gMAc0A0JN74dlvmqo7b7e/bz/0dpvRsHevzZgxnddA0P+/dNd+zXCZmp45Xx62XuuZiLN+tebzn7cZSlW79A9H9O3/f/p7vbiwVJ5Ool81uVki8rtlz5OG7DmjGWqX5Ksnn/nMp814Rw7b62caU2FqumWq/n/23jzOsuuq7913qnnq6uqpelB3q9WaLAlLsiQbeQSb2MaziDEkEAO2wYkhIYCTfPJiTEz4kJjBJLwAtsEvhOQxP9MG44F4wMa2ZLU12JpaPc/VXfN0645vF4N8ft9dvrtO31utGlb/03edtfbae//OOfucOr+11v7+7/+nInd0dD6ZPHDk2IlHk3Krf/N9auvAgCz4vZs2HUz2GduFgDUFfATEe5Pt/e+rHSFgEQE4ASa2DoGGf4y0rhvzZAgYAoaAIWAIGAKGgCFgCBgChoAhYAgYAs8mAkofPJsjsb7XCwJpryl+hGrI+PscfqFw733BC96RBI4Mv2cEJGd+dHRCqvJPj48/lWzvcu4lSXnrVmnunj52SnLIuI9wsu3i74HeHqE4J6Zn+mmTRk4bIcCaANcfvFYpQHSezTWEH9ahyBoBX/iipOC5v/wLZfgnpjQnshDZd5o95nPKSFDPiIDYvvHMMd+9Rxks7uvO/piDT0aKVf+5jzQZXH++pQtW+fc5k6JnRAAjCnzNCbEnI18san9kjAaQ0x7bpYA1B6b2HpH+ybhP9ijjL8YrIByce2lDr49e+oroMzuEMBTdcoT5WWX4Ym06pnT9eX3XT0oT7lrA642MJXOAb7/928Qfa0zcc/fdom82AiC2S4B05oWJ8XE59I4f/VGRL09qTYtSUSNA5otTYk+hM7IrAO1jcluHEJCB+UpHGNywTbZZd6XB/TKGWA0BMv60p/7Y438j/ivT94t80/Vac+Xbv/1e0TMii4y9GC8hcP1sw64h/f1C6DtGNBUKGmHFiCu/K4/0ypz6z3zm/4g+ZPx1PY0x/Nw1hLuWSGdeiO1iQnvKe/B8m5rU9a0P+PH5xgiz57/gXsl5P3rs+MeSfU6iJlJStxK/Wx0hcHn08geS43wWaghYREDyBNjvphDgH19NObPGhoAhYAgYAoaAIWAIGAKGgCFgCBgChoAhsDoRSMvWrs5Z2KiuJgJpr5nYRyZSzvJJ/vFvfOP1yclt3b79Z5JyT2+vMNojI6MNGX7m7Cd9Lf7u7+25K3msu6tDIg58BMHDSf1CpdJU0nAsQiDG+PMLd8z+uv17b0mOv6+vR5OMk0r/O4t96slw3P/AA9KCVbhjVYal8RUIuUJ7w1Yd7W2iZ04lGRdWid8ypFX3h7YoIyvOvXDm9Bk5FGPkxdgLPkdRDpGB8edb9BSqVU1RZE5+paIMNHcBYAQAdzXIoybB7gMHZAiHhm4S+WTmhMgxob2m9gfqH5UmzPEXZQuE/pk94mUsrxEW1ewF0ceEZhn/V+Z/WLqoVmR59DVDNKdYjL1AhvR//d7viclNN+v5Yo2Hu+6+R+x5f7DKPxl+9i/OrkA4ckQjSH7hF35BvJw8p4wtIwLE2Atk7CcnTtCkpTIjDtg/O9vfvUMObS/oNusXyro+ibEXni51y6FYDQExXkIg4z996dNi9fJX3SHywe0aAXDm4rzov/yYPv5fda8y9tddd1DsyUC3t3eIfhA1e3xOuuhzOX0dYYQUI2pGLine83Oz4u8rqGFz4Xzj9YE59KwBwwgF6cwLMX0sYoD+9u3XCJGhIX2+Vcr6vGjv1E2LujoVX7/LgoQQdHR0zSX7fOyJJ/4mKa/077TvR/19ffLCsHN4+M7kGLu7O+WGrNeqjyT1R48efWdSvuuuux5Kyv63PqD9RjnQx0SJsPDGFhEQQ8z03xIBXQ2/pZkpDAFDwBAwBAwBQ8AQMAQMAUPAEDAEDAFDYC0jkJbNXctztbFfGQJprxF+VNJP/OEYhNK6eP78e5ImA5s3C+M/PycflN35CyOHk/aTU9NKwSaV/ndHW0EoomKpLLsA9HV2Spnierb+kqSLOfTvq3CfTupdJvO1pByrEZC0vZLfMcZ/bnZOPul3dXddSvbjc/Ylybc4O7k7qT937lxSdE88qQwcGf7pac25ZRX+vr5N4o8CGXvqYzIZlhe/+KXS5MYbbxSZOdMlMB7cp71a5Qd4cedY9X4G+z5XsW+7tvaMbVZvF9ZkYA2BclkZ4LQRAJWKEhBnJ+V2dGdR5f+Le27mkFeVPFxQRvL69t+R8TGCRZQtENIy/uzyvoWflUP1qi6/E8jRZQSHNF5C4P18+LAsV+4lL3mJtPr2e+8VeQgRMYwAIOPPiABx1gLh4qVRuSH/7bt/Wp4/jAhI2yUjCGI1BdL6p/1dKGly2zWNGX62P1PR9TVthEBXj7YvjmuEFyMk7rhNAubcPXdcJ0MaGZfT4z7/kBKgczNa4+FAmzLsbdvkdAbX50HUGOjp0YgHXo+UyagzImBiQghtd/bsWZnfiRPHRWaNjWpZawAwYi32vOPzrNURAzJ4L7zxTffJoUnMn89L4lfPFuSB4t/nvph0uFAujyTlq/1729BmKTp08fJob3IMjBhI6hZ/b9sytDd5bGDTwHOTcns+Jwu23yXiPyf1K7CrgN5gFhGQhNt+RxDQ1TVibGpDwBAwBAwBQ8AQMAQMAUPAEDAEDAFDwBBYmwjI16q1OQUb9QojELtG+BFJKUznhFL0VfylTPGLXviijyTH3zfQ/8KkPD87J4z+qdNnhfFP2l7J77miVpGmD18lWMZTKs4JRcKq7WS8SpX658VnNqtJhaKMCzHGP+5BLRgh8MhDD7w4aXHq5Kmk6OaBF6v2syo/czJHLl0Uf93IKdy0uXEVbTIgzOF/5StfJf5vuEFzUEW5AgIZZkYEjIwoATI5OSmjKBY1R5YRAWRcmdNfqykhwBzxz1f6pb8LfcMiHx2Qy1t0a1HY3P60DPvm6gdETpvTL429kJbx31PQCJTvan+HuKyWdLm9cEFzilmlmzUbxNkyhL/50pfFanhYlmf3ute9XvSs2s7rsZCX5V7aXg1hbr5YT/bzm7/xfwug584pnjPjEiDlTs0qY0uGOlvW64mMeLLvxd975jVCZ7umGNPc7WpT+90FZcwHtqXD91xV729GCHz2gq7HsQiH627SiISb9r1I5sAq94ePdoieeIrSC4wAYM0D2r/up/6NHOLzhow+awDweUKGm7uuXLyo18/lSxJQ5y5d1sf7kac0Yo7PPz4vuWtNLGKAEQIChhc4PzL2lFlz4QUveL64HBnR+TIijfiyZtLU7KwCIt6bF2IMf6yHWAQA2/uaC3JD7tmzW96fujra5IE7Nzf/8aSP4eEdGnIR1ghImi/+lggLKr2sLwAWEbAERHboHxDgH2//cNz+NwQMAUPAEDAEDAFDwBAwBAwBQ8AQMAQMgXWEgHwdX0fzsqlcOQKxa4IfjRoy/hzGpZFLf5k8tnloszDsU1NTTyT1p06dfSop8/em/j6hbMYnp6RMeq1WlZz/bDYnOf+xCIB6tXpNss9qtXRbUvaMfFIMfnNf4q5uSTlzfX19kmRYnC/KF+VKtSp6z+gKw1Wv1XTja4xgoVQSinl8YlLawzwQP/NXn7g5efDy5ctCAfkquUm1IyMxPtpweM7nxEl7Cpcva/uXvVQ+sLuXvuw7pMnPv+99Ig8iouAdb1fGdWDT6mK8yxVl/FjzYho1Bfz9IvP9+lOaU/upLXL5uvXG8Mvkr0BgzQDuOsAIgRjj39O5W0ZxY02WC/eyzW8RPSNqxsb0ej9/XmtwTE7I7ey4y0QFu0Bw14b+gQHpf3ZmRuRDH/uYyD/+4z8u8k033SQyc6opi3ELBEYcxFyOjU/Ievez//n35flWuijLa+Au23Zcjg2OaQTP6NnGhGaM0RfnLRDmhvY09PKJWc2RL87r9dXRqev5zTffKv54fV4elcerG5nbJfZpBUYAvGJb4/Mzdbuu/3wesKo/r8/YripTU5Iy7i5f1vPPCICZGa1hMI+IrlHgxYgU1tSJ4dfV3SMmrCnA53EsIqCvV99Pvvs1rxX/7W1SJN+NYr0ivrm87gpw7MSpT4vDqyykZfjTDo8RAdxFoLdHb7ByqfzXyT62bN3yj5LyEr/1BcEiApaAyA4tFwH+MbfcdmZnCBgChoAhYAgYAoaAIWAIGAKGgCFgCBgCawgBYRvX0LhtqK1DQBiRJdzyIxEZ/yWafPPQ6ZOnP/JNybkY4z89Of1o0t4z/Ekx+E3GnwZk/KmPyZlc7mTSZm56Tig95nhzn2LWCCiVykl3rrRQFEquv79f9H788ok/oyUVXLlcFspxDvsUT00qQzwxpjmKzDEeA2NfF/5Mhva3AhmhSlXnx6r/zDEOPeqRd73rX8iBXbtluo74vu3tbxf7t779XSJ/7BOfE/kn3vlDIt933/eIfLUF5lAX+vT69xEjMqSdO3eKfOA6JQi2jxVF/2dTqj9c2tiPgHPl7xR8zjmVgwiBzo+KPRn+l/S/WfT5guJbwy4SNdxgjCiaAUM/PaOMpHS2hNCBfdI7OjQnm+tVJ/Tnz2nVc0YApGXkOcRm29Mf5cFNA/J8OzisEVsPPXFUmjDnfFdeI2qw/LqD3borjThbhsCc/s3der+Ozur5okvm+FP/xYxGWPXI08S5LVu3SZOtW1RmBNXXz24V+4k5eTyK7kqE/KZrpdnmaxX/6sz9oj9z/5MiO0SEqTKUWDPFR9yJEXdZob0Ye4ERONRvxq4qZNz9rkHShPd/qaR6MfYCazBQH5PZ/6lTen6vO6C7PHSghk8J45+dmZb3m/ZCQS6gZ3tXgBge1JPhp57y2XPnvpo8tkREwAuTeu6CtcSuAUnzxd98H9cLmNbOyXro1ZE3vNCBHVk/CPCPu/UzM5uJIWAIGAKGgCFgCBgChoAhYAgYAoaAIWAIPIOA0hPPHLYfhsCyEZBr6P777/+2ZMutO7YJpTo3Py9lZH1VVE2yTDb2v8nwM+cf5isuVuvZ08lORkcv707K3d2aY0lGtx0MGxnsuTlllHp6NCePNQWSfS/+ZhVjMnzMCWYVYOYMFhdK0gWr/jMHlFWM9+8HowMG5EUverH433PNNSL7GgciV8oaYUBGZgARFD/4fW+U9h/87Q+J/F9++f0i//mfaw70T/+rfyX6m26Vy1t0VyIw55/XS7P6F25Vyu+Fwr84N4vr7U+n9JvwwzN6/idH0jHQa63mwO1tGiHx2r6XyWm9pf0lwphkM8qo1MAg1up6/fJ65fW8sKAMMKuQ+32lZTwF5ORS3w3Klzm63EVi+w5lgB95VAKy3He+4rukf+56IsolhBjjz/ud9s3qH3zwsIyqY+RBkTtRhb8LVfjF2Atk8Ccu6vUTs6c+Jhe7JQDNcc+UM5Vd4uKaqRMijw3qAsCq+VN1XX/PndceSsXHxV9XT+MaKrGq/2x/oEsj4PwbgPRHoWvua3Lo0iXN0R8e1gipeuR+5P3D+5XPV5/jLv3z+eoj9EQfEzzD3NCkVNIcfO46wxx/7pJD5+yP68n4+IQ0mUZEUldXl+hHLpwXmfb+/Wlf0iCby+kJSyqv4Ddz/Fu9a5KPkJAbPG1EwPjYmEQEdHTs+O7kND3+P5OU/fu0hJzdddddDyX1/reMB7pFUR/o4S4BSzSxQxsFAV4cG2XeNk9DwBAwBAwBQ8AQMAQMAUPAEDAEDAFDYEMhIOzthpr5xp0sc4CIBD8KMceI14zIu3bt/vdJh2Q0pyYmpcp/0nap32T8GRGwVJtGx1j1vwuMPPX01dnV9UDy2GPfeEwiALbv2J5Uu+EdWlVZlF5YcMr4kVEgI5gH49DZ2ThHlPbcF5kMABkEzxHLkLlv8cCA5qTPz82L/cy0Vhm/71/cJ/oOMAii9AJzpMnIkMGk/uVgLL/y5a9IF8ePaU7ppS9/QfQ/82aVX/2unxT9D/yzt4rcjfnEGHzeH+LsCgT2F3PBCJA3D2mLNw/J7e1KwyofnlGG+3efnlQHV1kig39bjzJm12SVMLmtQ8cfDlf1NeH/Q+vYkSoiWsjgkfGfm9X7jzm6lHtRxZs5/xxfO2oEHECOL2uEMIKA/iiTwaeecsw+rf5zn/2sdHHy3KjI/UPKeO+aOiL6mBBj/GPtYzn/HfuVwS5ejHlU/eWeu+XA3ms0RZjr89wTGhFxoW2XtCejTwZfjL0wAHyp3zPcqYem9QYrlk+KfuDiUZGd2y/ysWPHRGYEgCi9EHu+0J4MP++vYocy+KwJ0GxEAJ/XZOw7OxRPPu9zeX2dq1b0euCuItWKRjBwfWIEAO/PJWokyfvSXNHJCevq7pIIUeJPOcb4U8/2MZkMPyMAYu1j+umpSVlwhod3XJ9ss2Pbjn+ZlP3vH4EcE/UEh9b8e0BvwNDejqwjBPjH3jqamk3FEDAEDAFDwBAwBAwBQ8AQMAQMAUPAEDAE/gEBpXP+4aj9bwhcIQJ9/X13Jpsyx9gzlEoJJ4397+mp6S3JQwObNyml7Jx8kk4bIZCW8U+OZfE3vwjv23+tUBLHjx2VpPexMWWcBgc3i8utWzQnk1/sO8go5PWDLr+wi/MlBPojg0BGmPsKO6c55dy3uLdXcyLf+kM/JKNgTrTPiRN9e7sytu1gNJiDyarq4swLncipfBf2Nf9PP/8+aTLTOSUyhU9+6tNy6PBhzUH9kbe9TfR3360MHBl6RgBQL868ENPTvlmZOddkgJ/Xr4zS3XfK7eu+MqE56ydr+shhjYHYrgRk+F/bp9+wb+9R/2HKqZV46gAAQABJREFUo9ojJdgxgoT4Meefesq8PkvICSajNjmhERRk+BkxwPt1YGBAhsD7qX5WUlDd2FNfEvuBW79P5FYLvJ7IGDarv3DxkoRs/MrvPCQnvGPT82RKN9c1pXYzI7YunxJ7CrEaANSzfazKfzcY7wmnITr1ghCGrpbXiIG2XiX4zmF523vpsg6poPK+kc+J/qt9WgV+fEZlMfZCW4dGiA1u0fHtmdbn4+7MWXFRWNCQhxheTz8thKp7/vNfIP6yWV0falWNCGJNnjp26eD1yvWwA88rMuwyGC8UCgU5xF07yMj39GiNIdYUYkSPOPcCIwq5ywB3GeH7CPHh+BgRwf4p+/eR/TiWKgKg1Tn+GIsj48/3P9rHZEaY5jOZ88k2/vkuN3Rvf+8tSX0Lfst66P3JetkC/+ZiDSHAi2ENDd2GaggYAoaAIWAIGAKGgCFgCBgChoAhYAgYAstFQD+HLreV2a1nBJTSc9z5OCoLNplMJtUXXTL+ly+PCeMvzr3QbE0A+ovJ/CK8Y+eeM8k2X/nK30gEQF+fVklmjj2/wLNqL6s0kyFgTh8Z5eTYlvpNxpA5hGQYL5y/IG6qZWUwXv6KV4h++7ZtIrMqMOfPCIUO7ELAfco7OjXnkVXVyZhyH+YfedvbZXy//uu/LnJMmJ5WSu1XfvmXpMnd99wj8ltZMwA522K8AgIZ1lgXzJFl+0CfUcbx7gGN6HgeGLX7NrU3HAL7A4Hu2yqBUa2ozIgRdhZl/LP6jZz2rPpPRmwBVfvnsevCxMSEDIkRQ7w/uA84dwnh+jH7hT8T/7lPfkpk5RO96jM/K/rr/+MvitysQAaV/tLqZ2fnJGf1P/3qn+gJQwd7uvV6G8zuFYvBvL4SdeTOiZ4Cc/g371eL0Vn1R3sy2jG9wwkb36EMdrYdAXaXe2VAe5BjL0ovHJx4WA/p8u4caiScKZ0U+wvI+V8o6vOvp6Dr9e4FZfwz5SfFX/GY6oOaCxoQ4UoL+rpw6tQp8bdv3z6Rsz6kJ/mP93dSt/g7k9HLi7vyVKtak4c59P0D3OVAe2DNHkYYcFcfMv4cX6mkNYYYARCLUODzmBELrMHDmiWcv87WR1Tma1IToO5yX6FNI5k5/s1GBJDh5/teo7EsR9fX2akhMWhULldkPfPXl4aMhu/j8BAV9QaJmpvBekZAV7P1PFObmyFgCBgChoAhYAgYAoaAIWAIGAKGgCGwgRHQz9MbGIgNPPVmPwJJxIBnYCWpL18IvmA2hHpidFwogqGhQbGPRQSI8QoI/EK8Y8d2Sdo9ePBGoYzOnj4p2wD48cuoOD8y1mQwpLEXduzYIYfI4LejhkAsQoD2u3fLB3q3HwxKsbgg/b/4xS8RmTn/zCHkfKmvVpXRJUPDqvv+epP+mSPJKsy33qIpdq9+9auk/ac/pYypKL2QLyjDTT33HT965IiY/DAiEG6++WbRr7QQMPioUk99bDw8f2nbc1/5haIyWIwgof82nH9e79mcLndZMHqcHxl+6sn4s0YDa3RMTspy4UYva8711PS0dMEq7aJcFHpw5Gt/JQfI+ItyCWHkWglgcvfe8eIlrJZ/iBEcMYY/5pnrxb993+8Khfv0sYvigjnoF6oaAeCcRihxV+1dYNw7ZsGQS2+hQEafFmn1yofTm3NPfaOKgxphMjHTmGAFoe5Ol/UV8Qz2od/VpjU+uIvC8aw8/lzpouKfaVPGn/iS8ed43Hl53LrBLl0vHn30EcFj586dIpNhF6UXuOsHApwcdwXo79caHGOXT4vL7TtuEvlpPA8OXHed6JmDX6/p+S20KZ58vvF5WUHV/+KC4iWde4G7EjEikM/ny6Py+kd3gcwIgkwuK0VzsrnGEQFk/BkREHQYOUDGn+97Uf38vFwAg5s2yQ04NT8vIYOMCCgU8rKezc/OPYEhy/u212kIEIyXEPUByBC6JRrYofWLAC+G9TtTm5khYAgYAoaAIWAIGAKGgCFgCBgChoAhsIER0M+7GxgIm3prEJibmz+e9LR5qOuGpOwZOeGsPGMmSYuxGgC+aqrkUFXqdfmimuxrJX7zCzD7uOk5twnFe+zo00KBdBX0iz0Zvp5egcfxizv7O3nyhBxijQFGCPT2aE4oawiIsyUEMpxbkeNPBp45+XWUXSdjQUaVDAaHxBxvCR+hsZeZw9iGXQLe8n3fL6327tWc0Q998LdEn1YgQ/JzP/dz4uL1r3+dyG/+3reIvNICGfVqJS3BgBEiogBax/O3gCT/8YlxabKAmhCs0r2QU4KE9w8ZPzJsjBiQzr3A65H3A3P0p6ZleXOXLl0Sl2HOv0YAFDFfaeyFXUful0O5o0dFTivc+IY3pG2Syj5tRAAjSn75l94v/T3yiOIpyuUIqAmwnCZX02Z2m0ZkbO7ZL93XTzX3Cjd6Vh5X7kuuS/xfmFKGf7s8fZ3bXYisDxOfE3/z2GXgyKVviJ7+AsZfrJ3jrg1PdpXFInfilMjTiLDp7tYQD15v0tgLXE9mSvNi0oOaLu0dQgiL7aKweUhTvLk+7NmzR9pgeXR8XvJ5y/WyWNTx8vnL5yNrDjDioK2g19/sjK53rCEgk1lCyGWaqwmwhMtUh9Iy/nTuaxIJ4z82Pi4XwNbBQQlpzBTyckcxonFuevpL7MNkQ6BVCFgEQKuQND+GgCFgCBgChoAhYAgYAoaAIWAIGAKGwCpGQD/freKB2tCuGAHJKbpiL99syGtG5JGRS1ImfmCTfABdZGCFUvUM2qPfdB3+IuPf29crlE+zuwB0dWjVXu7TyhHFvhBvGtwkSXB33Pk8oeQe/OoDSukESbz6BZ0RARwPGX/qGSEwOKiMw9YtW6UJGVFRLiGQISBDyhoApZIyRigK71g1mAxHFUmZfpcJGVUOVdt9DqHqIYvSC2S8P/XJT9JE5InxyyIX5zUndbqoMQlbYD8/pzmY/+OwMrqPffRPxP+7P/Q7Ind1a8SIKK9AIEN7BS5SNSFjzhx5H1Ek/sjQ8XrIoap3jOHyOZfiv5LXGhKMUOD1zRzWaTD+E+Nj4n90VK8XRoRMTc2KfbWsNTZE6YWxKVkOnd7NtE4vE+8Mb1jcT6zhkL5HbfGBX/0VOfCxj/+5yDGhWNYaMrXCAWlSKSneY2Cou/IagTKsy4n4aoVAxr/cvk3cjp/S6/N0fafoDyiB77LFp0SvV6OPIEEO/1f18nRcn1xf45on0pkXyNCf0eWf5kHNgcAAB85UNsmRHCLsROmFE8c04mDftVoDpr1d59cBOZvVC2ByUghfducGN+vz1uH+GRrU6/PCiN7PdMj1aGZmWkzI+M9Sj5o90tgLjIigTMa/s1PfnzYPDYlL9i/KZQjthYIsaT5CbCTZjDn/zdYEYIRn8L6HHH8y/smxLf6m3kesisn+3TtvlgMQxsbGYkVH9AGWviYAejRxIyFgEQAb6WzbXA0BQ8AQMAQMAUPAEDAEDAFDwBAwBDYsAvx6tGGBsIm3BoEzZ04JBTq0dat88twyNLgr2VO1VhXG3H/BlvZk/Kenprck2/vfTdUAiDH+6MtFvxAvLAjHcdMtt8kX3CeeeLw/6XNyclw+mVc7QeEkjf3vWEQAzH3OotYcYM4xq2pvBmPBXQXmkUPIqsLsf35eGW7uQwwC3xMkcrm4alXg9DmPmuPJ/pij6b/Aiwlz3LPIUf/whz8k9h//9CGRKWwZbXz5kZ9XPpveQvmrDz4oB3/mlfeK/G8+8kci79qvDOfVZvRjVfMZETI1pQwWd5UgA02ZjHxVt1H2+3ZrhAgjSsqoct6F24/7arM9q/xPT+v1QMbfMzpyvlgDhIz/Qqnx9d51WXOcndOIK+lsGUIvCCnmFAc1PnD/UJ82IuBDv/WbMsrf+/3/R+S0wnxRz0dnh+L/tUDfJ13cUtX2Xd26HrltYEC7db0TZ14gw5/ruUtMlF92joy/GC9D2JU/I1ZDE/I4cnM7NMf83f/Xu8X+xufeLTKFmQnFs3jxuJhcmlO8Ok+dF/3Jk8rAl77xqOg7USOAuxBkBoUgRgUD5w5s1oiJvuMaAXAOOfpcfw4cuFbGw4grPv/I0EtjL3AXE9boqVbk9ccdPyYBhG77jmFxOTurESzcNYU1Sip4noozL4xe0F0Zenr0CcYaKdlcQR7Yfj6y4D59RNd39keZz/dqvbgvaeMj+iQCoFnGP+l78XfA+ON9jox+MRIRwCr/vZs2HUz22dXZyffZpNr96Uc/yiWBf7PpthDSelkCSWCpUeA9yPldlkczWjMI8OSvmYHbQA0BQ8AQMAQMAUPAEDAEDAFDwBAwBAwBQ2D5CPBr0vJbmqUhsAQCjzzyiHxC3rdvn3wRzuWy8kWxv6/3tiXcPHNorlzRJMZnNK350eoaALFRvfQ7vksoiE99/M9enGwzOz+XFIPfbe3KaAQGOMAIAMrMIWREwBAiApizfMstt0qP/ou4yGQoROkFMrSUyfAyx5F6/0VduujuVkqXDM1jTz0t9r/zux8UmUKM8ad9q2VW5f6Xb3ytdPHuD3xA5Dtf+B0ipxVijH7MHxn/6RllrOZxvTBnn4w/ZZ7/WMQDGfxOXC/lsjKWnB8Z//k5vV/HxzUneGJCZUbgxKr8s/9KtXFEAO1jcvs7flBMps/K8uS2XCMEXLALQi6vrxClBa1ZwF02GBHwp3/0u9L/f//QfxOZwtYBXf9GJhrjEdoro0//jBi4HwYXzut870T3m3fslxaDfSD4Lora1cd0Pqp1jjn+1FPenTkrh7ZiV4ip7Tr+5/z4z4v9rj3KePP8sUp8Nxji6o6d6k8k5+58IQlNGERErg+Tl4UQdrPndD2fm9Sc+tnCJunh4nktevDYNx4T/Z/+6Z+I/E/+6Q+IPDgoAX3B/dHfrxE5ZPQrFb0+hraoXEIE0BzWGxmMFxjBQD1lns9ySfHZv1+vhzxqpviSK/J+t22rRmQ8faTx6xsjKDg+V5nbnTzW3tX7laTM381GBDDCk/4pMyKAejL+O4e3aQgHGjBi4/Dhw6dh0mrRGP9WI7qG/FkEwBo6WTZUQ8AQMAQMAUPAEDAEDAFDwBAwBAwBQ+BKEdDPwVfqxdoZAn+PAL9YvupVrxFsxi5fki/G/guwfIEcHOiTiAC/b7cksfsaALLLgDi/AoE1ADraClKToFgqb74Ct9+ySVd3l1ASL3/laz+XNP7D3/89iQiYKGsOHXOCB1FFmBECrLKe7GvxNyMCaE/G/9TJU+Kir69PZOY4IqXfcR92VnHPVBuntDFHkIws57NpQBmabEa/eb7vP75Xxk+h1Yx/5lq9nDrOz0qXQdVt0bqwKjf0v/gTPyFHvu+nVX7d9/6I6CmQUaeeMhl+6mdnlSGfQ84qGT0y/JQ5PkYo+JoiMgTaMwKkXtcc7th4GLHA630SjP/oqCwnrrQAylhGGwpk/LdOjodGiSOnMhpxkFD97c+O/XfIoc3HNefY7RO14/nNOmVwuWsGIwLmZnVXk/vvV0799z/229LhdTdJSRTRLSX0N+TTnJs8NynNwoiAdOfjVGeb+CsWlMG/A1Xp5yq7xD4QKnr/P1XbBhMdP5TuQJeub5myBOC5qe2Kf/V1/15cVGq6Ho6NKqPuqiWx7x3Q9Yvnm+t7hruyoCaHOPcC7UfOKQF68dEvSJOZaXmcusygpFi7tiGVzx0/Lu1PndLnGWt08Hn7P3/3f0j7f/7DGhGw7bobRD+NKvzdPb2iZwRePqev5I88/rDYUxgejtwAaDDQr9dLHyIUWPWf9vnILimsudOOXZZYowDDi4orvitArSYLEBn+WM7/nj275f1t+9YhAZwMf2zChw4dwgIda2F6Q2D5COjqv/x2ZmkIGAKGgCFgCBgChoAhYAgYAoaAIWAIGAJrCAH93LiGBm5DXTYCdVgKAw9dy0VsEx3kyE1NjMlHqAr2vfY5dNcnB7V917DI/ovsXyb1ly+PpaN0ko39b7/Pr1IcTt0xRyxWNRbuA5ERAffe+8KjSaMvfOGvJQlvvqhVpslYMCIg6Wvxd9oIAY+nuCAjyRzCLPZhJ+FDhigHxr+GCyYHRoQMxMyMMlzj2Hd91y5l4P74j7Vq/tGTT8j8Ws34i3MvTBe1RkHHfmVwVOsZ/68/ShcNZUYQfPi9vyj2pz+vObI/9mvvEz0FMsCBXgN43EJJGUPWgAgY9pouT2TsKTPHP8b4M8e0vb3xI481MCgzB3diQhn5qSllbMnwMec/VvV/fk5rarAqulMCmqfH7alrDnL7d94rNtOP/n8iO/d6kRnB0Yucb1+VW+x5fv7wf/130f/hn/yByP3DQpC5vsJ+0U+Vj4lM/eBOjfg4e0Svv1iEgI8REP8cDyMIxHgJ4WRBI0oujOr6lN8ky3ng4UJVAt4C/fac1ljIFp9SG1ze+Te/X/Qzkzpf7tKSyej9yH3v2zu1Knw3IiC4vkvnSwiXLmjNghjDTxd9175ADpWdAnDurEYQsAYHd+Hg87Vc0vvv3EUlZH/7f/+h9P+ufn19uP6W20TfDwb+zBmd/+SkRvBcd1Bed4IIunxe778CGPpCW5v0n8XztIZdAQYGdL1gzr848wIjNnj/M6KAz+tqRe9f+s9hfpcuj0qRgb7+PglZabYGgH8BlKIQ/n1PhrRt21a5AXbtGn5R0qAb28ikZfz5vPHz0QEkO/u735XwkB0xBJaHgPzxtbwmZmUIGAKGgCFgCBgChoAhYAgYAoaAIWAIGAJrDQH9XLrWRm/jXXUIMGfpPe95j4yRObhk+JgjXCyWJGKht69PKIq+nu5XJjvwVeDPJOVzZ7QscKVeb1gGmjUBkr4WfzfL+Le3t8tHN36xvvbgDV9L9jk+Pi6k8MMPHR5O6pWfcG7k0sWk2vX1bRL5pS97qchbtsgHdTc1pfA88fhjYv/Ek0dEjgk836wJQD0ZilxOP3CTYeK+xMwxPHNGLgf3uc99Tobc2aE1DDq7lEEU4yUEMu6dXcoAsknnuBAMzm3S80P7vtvvkkNkpNJGCHzyM38s/sb+2QmRf+yXf03kvj7NWa1FGP8ZVPkno8GaD2SgaogIqOOC4T7WXD/I+MtkvNAGRoy7AnDf+znsUsCIBlb5n5qeli5jOf/MMZ6e1vvv8iWNUBHnyxCqr3i5WI099SWRIwEEwXrQDvyY88vz8dWHTkh/rNo/MqH3w9YBZailsRcmnSyPbqqsEQSxiIi9e68Rl137VP7SFw+JnkIsQiB2vnbCYSwiAOZRcc8//iGxefLUeZG3DEmKs5tFjjrXV94fjPDpQAQXGeEJ1BQ497XPyniYwy9KL/T0alX83gPPE5PJaY2wGL2sNQEmEPHA+5MROZWyrv+XxselPwqPPan353/9jd8Qk/f8u38n8padu0XetUuviN5eIZhdqaTPv3q9Ju25PorSC7wfaZ9r1wiBTZsQAYCIAj6v2V9M5voZs3fgv3v6Gj9f6Y/vV3z/ov3WgYHB5LHhXcMHkrKPONiVlMnw83nH+yHZdqnf1XJFX1hCI70gQr0dMQSWjYD8MbLsVmZoCBgChoAhYAgYAoaAIWAIGAKGgCFgCBgCawqB/JoarQ121SPAnKVSpSaf0DvaskJ5xr4o17Dv9eT4qEQEkMHwVXblk/quazQJ3H8Rl0/2506fu5AEdWp+Xim4pHIZv9NGCPCLNL9Y33n38/8m2e2mTZuem5SDGgHIGd6PfXxvuOHGZPMgp5BVhW+44QaxLxY15mBqShlPMkasEUBZnC8hMAe1E1WFnVPGggzDhz74QfFKxpXb8pbJ0EvrUCDjnzYioNAmAR5hBzhC+wIiBJij2nO2MaHw1QcflB7e9+ZXivyjH/iQyDt27xWZjH8ZDBoZJzL+gR5V/GO7AJDxp31npzJGGRSl4PVawvgXFooyX1b5nwbjH8v5F2deYM7xzIwuP3tny9pECTvVeYlV/mlQWLjIQw1l3q9kVLciIoA1ARjRsGeHMu5kWKcVbtfboesNB3vkMY0goD62q8Bc/SSbpJIZEcDGC+PdcuiJ0W+I7CC3dQgB6Yb69fp1vXdK+9u+V+/XsTkFsDeoOq+7DrR3tIs/1sio1eVx6/eZV/98fk8efVj8jZzRCDJRLiF07bldjm7asUfkiQmNEGEEDnPMWXNEnC0h5At6g21BhNbkvOJBF0+flJR09/5f+VUx+Q/v+VmRu3s1wqoXEVZcH6TxEkINEVNcb6vwH6vyz10eluiy4aGLF3W9YYRnw8Ze2dWt909PDw7EHEDfns9LEY4bbzgoRRs6u7uE4UdzR8afNQ9on1b21+vxtG1S2msIScrGZr6+ELAIgPV1Pm02hoAhYAgYAoaAIWAIGAKGgCFgCBgChsCSCFgEwJKwbKiD/CKoZWVDKJiDpBQC7EvFhVPJQ53tBYkAqLpcPanPuap8YidDmLRd/F0CQ+dlaT83o8Pr7OwUSpu7CvgEeynLe/7iiHzSn5yaFko1LePP8aeVWSNgx849Mp7P/NUnbk763LFjhyR9MseTDE6y7eJv5ti3o4pxd5d+oR8d010DLly4IC6nUOWY+wSzyn8Xqupy/GRoT586Kf2dPy/wuJ4ezfkXYy+033qHHCIj644dET0FRgRQ32q5vU2zuPftVoZ1BgwWx8+IheMXlIH+2bf+gAz5bb/wfpGvPXCdyGTgybDzfg5yVGu6HDHnVTpbQuD10dHRKVasMVCpKMO+sKA5wGQU5xEBw5zPWM4/q/4z55g1HXbtUQZ4d4HLr0zPZV7wEjlQPfpHIscE5lyTIWQEwDR24eju7pIuygXNQXdO1wsyrO2blKE+c0zcRSMC1NoFuwo417jqeIzRf8U938UuRH7qwhMin0VESYxBrhUOSPvLk0+L/IqXa8TTjq1aw+X/oMbJVtR4YQ2N4c07xX+1Ko9j0S0K46MacfHpj39KbHq7dH3dpZeD2C4KW297hRxr79AGjDCbm9Prg/dvrKq8dOaFDuTAM8KsgudFoU3XC/pjjZYTJ5XQ/a//7dekyb/+yZ8SmTU1sjnl6FiDRRp7gQw1d9VhRFRbm74fMWedDDfXa74/HDumN+zjjz/OIYrMmiybNwsh77Zt2yb2m7BLwSQisPbs2rk/2WD3bi264COatiT1/B3MFxEVWUSQsX2zcnG+qC9MzTq09oZAAwR0dWlgaCpDwBAwBAwBQ8AQMAQMAUPAEDAEDAFDwBBYuwjk1+7QbeSrBAFSUpJE56sGS9JeJqPfnPJOkwzxwdUxZ5cMYgwD5ixS9lXvJWKgo1NzwDZv6t+V7GNoaOjbkvL01OSRpDwzM/dkUh6bnCQ+SXXwO1YTgA26ursuJY+9+rVv+GxS7u/tvTsp5/N5LUOcVPrfuayeHzIQMA+qqvt9csWkB/uGj2hAhTt/XhnCSeR4nkWEhzj3Qh77Gp84oREAtGcNAO5TTPuBTRJA4Sq3KsPFCIHi/Dlx0XFeGStRXoFAxj/mIoh4QISDQ845IwTo/4Mf/E059K9/6t0icx9p3q9kkMi4VFEDgPaxnH/uUy+D8wJrFJRKyuixpghrSjDnnxEA7I8yrz9Wjd+3Xa8vH+NEFyJX3vg2kbvO/LXIaYXT5R5psregrwi9yFkmPpOXJWDKHZiTgC/3VJsGmF2e1JzyfkQI7BI+z7mFcfXnJhrf76dP6q4BgztvlflR4C4B1FPeNLRTDh3QEjfuxIn/V/S+rj1kFbt6dH73vfEnxOC77ton8he/8oDIPiJN5Kkpefy6ffsVUDLeFy82JiAfe/jz4r80pv5HIbtd+ri5/fkvk/bVmjx+3diYlAxyviaO2PN+5XrAiCNp7AUyzm3tGkHV1d34/HSiJtE8au4wAoA1W448Ja8L7jd/S9fTd7z9HTJkMvRcD8XYC/75LodYk0OUXiiVFF9GQNG+HRET58/r9fLhD/+2NGGERQ92OSDjv3evXt8+glH8DQzqDXdzf+99SQOe3yAirazrfbLt4m9GQFDPGgutjgh4+NGHn2KfJhsCK4VAdqUcm19DwBAwBAwBQ8AQMAQMAUPAEDAEDAFDwBBYPQjo58LVMy4byTpBYGx8/ERyKtu2Dr0oKfM3IwRiOcDMQYt9Iaeecqk0IUMiI1gotMlHs47OjuuTDbZvGxJ5aOsWoUiKc7PyyTwWMZA2ImBudk5y3HwEQHJ4QU5/jOGXxl7IIoIjpicjS8aJDAAjBHyEBrsQ+SJqDJw9pwy8GHuBjGMsAoDtWSWaDDtlJ2fDM5hgnOmfclrGn+05XuoZ4eDu0IgHMrxkuD784Q+Ly3e+850ik5FiREAVOf+8H8WZF8joMKc1X1BGj/bVqgbksIZEsaiMGKtWk/Eng8bxUr48OiqHGCGyS+F3m3f4qiSJf53/6HsSkq+SDcY/bZV/ceaFBzsk4Ml9O6qG8/5l+/PHNAee+r6KMtwjBa0JMDn/tDTZquaONQL2OK15wV0F/D4L4o8RQoWyMoysWcCIAOb4HxTvzm1WgtKxZsAnv/wJacFdAl79HdtE/6oX3Szy2KgEfLljxzXnmhFXrAHQ3t4h/mZmNUJpEDnYD33mkNjHhM1g/K+9WQLQ3Ni4rufh/agRL8zxX8AuBLxfY+Ojngx7HwJwyGCzfRE1Q4bKmsM+gV1yuJ4+8cTj4vKXfvn9Ir/9rW8VeXC7rgei9MLkpLxu+AgUrUHAqvys8bN3335x6SMeRT58WCNqPvKRj4ie68PQ0B7RP/e5t4vMXYZ2DOv8OD6/nsv7Fxl5Xh/SmRdiDL/GJ7H1ysvnL17UE9h8lyx6Ivh597XmuzAPaxUBXgxrdR42bkPAEDAEDAFDwBAwBAwBQ8AQMAQMAUPAEGiAgEUANABng6r4xbCpa2R0dOTriuN1KkYkRgTwgyUZxVhEQEzP4ZQrCke5rIzJ3LwyTNN5ZTh8xEB/0qePGBB565ZBiRgYHNosZZhLxfmnku2Ls/Pnk/LIxMRYUva/hSLyOdZSNjpWAwC+4mJWd3FwNd3FgQ7ICLPKeFenVm2fQpXf0cuXxeWnHnlE5EKh8Tf8nVsQERGxL5f1/EtnXogx7LSnzIiAGOPfbH/sPyYzQkKvfueOYFcE1gj40Xf8mHTB+zUW4cMc31iVf+nMC8wJZo4rGaPpmWlxUUQOsiiXIczNzohV+Qm9Xu9Ugi1g/Pte/cPSvuvkn4vcLONfblfGeeGI5mBn71JGUDr3Ahm/7rK2H+zbK00u17S/rllEXMwcEPuRc4gIGFY8xdgLw9uUQeT1OzGu64fbJMupq83qCbl4Qnu4+6BGDLDqfWVOrx/qX3LwHnHYNiiPA3fTAY1omJ1RQvChh/X6EWdeYA2AflRN5y4s27frfB766qfFJRn90TOnRc/xX3P97aJnzYwYo88q9uJsCSGX1/WeMpt0YleQwcFBMfH7sItMoVwqySE+nxgh1N2jzxtGFIozL5w5c0YO/eL7f0lkPi9E6YUKahRs3aL320033yRNnvOcW0Tm/fIHf/D7ov/Kl78s8s3PeY7Ir3j5K0SmPhqhValI+3K5Iu9DolxCyGacFpWATVDjaIWr+rPGTSwC4RMf//gDGHJaUQFM29rsNxQCFgGwoU63TdYQMAQMAUPAEDAEDAFDwBAwBAwBQ2CjItAUu7tRQbN5Lx8BftG89wXfro0jOeWu3toUJe4qEIsIyDr9AF1z+GaG8S0s6AdYMgpF7CM+19YmeHR0tMsX7O6ubokQGOjvE3nLju0CkM+JlBoD1WoZWbTSXfNChPGvAR92GNvXuBMRAYy4GLl0UVzmc5oDvmPHLtGTIaE99bGIAnG+hBCLIGiW8Y+NLza/mH6JKcmh+eKUyA8+fL/In/3cjSLfe++9Isdy/sXYC11dXXIoi10gyqhqzRxjMpBpc4jJ8MlgvMCc4OPHFI+9fXq/d177cnGx+UXKgLea8ZfOvHBoQCNclP+mdSizyvgTFzUCgC3atklAkrs9c1JMigvK6E5N6a4iU2WNAOCuGz4GQPwduXhO5H27lWF/6T16Pd5024vEfud2RaQGQrKC50MVNRMyqHHh7hT3ro5dVypVfd5cOn1CGjz2jcdEZlX1vj6NKODzhjUCyPgzYiHG+BOvkPHX64vjqSLCTia3DIGM/wJqeHAXhGHkmOfy+rzII6IgNgRGEE0gB39sTAP0xsa0v9KCRhwwYoDtp6d1ve3t1aIFMcb/xhs1AmBoyxaZ4vmzZ0V+9au/W+Qfe+c/FzmosQIGnzVSZueLcoFn8P7A9zFfo0jeh2I1iGqISIxFBMhkVoHw0Y9+lAuohpz4MjgtHqacjxb7NnerHAH8NbPKR2vDMwQMAUPAEDAEDAFDwBAwBAwBQ8AQMAQMgStCwCIArgi2DdVIKW2f9nxVZ48IAfkc7AfCFK4a9hHnF+UY4xizz2EA9Xq6b2g1fG9lhAAjFPJgKIi9H68MwKe0CwVWACNORj74oo4v6LVqWWZMe/ojo0+8meNZqeoH7RqqtJOhfeJxZcCIR0wm4x2zT6uPMf5p/dE+xvjTnnJs/tQzIoK7ANB//8BeOfTXn/+syNu3bxd5P/YlZ85/W5syZm2oYs7rJW3Of3FB96Fnjq8MdhnC6KjmmLPK/21veaN4YY51/9E/En2rhVzPXXB5TOT265oLGJqeU4ZybKtWnQdh7shgI2Xa9fV1y/jm6rpenHAnRd9X0JznV7/6NaJ/3p1KwXcU9HEGAt5NTGpOf40MJxj+SyN6/llVn9d3HjVlNm/XiKWB7Ttl/Nt36P0jSi9UsH72d2jExcmnH5QmZPx5/nh97tinOeOsak9GnOs3708ZzDIE1pCZmNBde3j/kvFv78D1iD75vGXNEZi7wB7P6w6sV73YlWcUu4LMoAYJ+7vmmr1y6NZbbxX5hhs14uqaPXtE393TIzKr6Pf3D8gbCp//k1PToq+UFuT9gM976cwL/nVF7PH6QvNAzubldSfQt/pAli+YLe6ggpoTHo/5lF3w/TzWvBYzMP3GQeDq3k0bB1ebqSFgCBgChoAhYAgYAoaAIWAIGAKGgCGwqhDQz9+ramg2mBVCQL7g+j7ki+wV9KmUjHOSs3To0CFJwnzPz/289J+PVG0NxoOIANYIIIPOquP84ryML9YyBNqzP/qXxl6gPfUcL3OWyYCk3kcdjFVQFZdf6HP6jZCMP8fP8VFPmREAs7NzYnLpkmxq4M6dl8tJbFshRBnwyK4AHEOlLLcD1allRhg0GxEQGwD7m5zX5WLPjmvERaGtMcP28b/4C7F/y1veIjIZ0w5U7Vb+yLkyIkjS5vwzAkcGswxhakr3Rbhw7hFpdcdr3iRy26Dm7F5txn9smzKCbuaYjI+7ADgGDIh1KHR/Q/EYqGt/U73a5tzUZjkw3Dcqcg9CAgbbbhf987/tdSIPDytDnsvI48ZVq7rrwFRF788qQwDEeyiQ0e/rV8Z9DAwvI6D271V8WBOggPX37nu+TQZx/Kiuh+0d7aI/e+IhkSkwx3/bQc0R37HzWmkyOakRHszpZw4+IxLE2TKEPGp8MIKANQeGd0gAnGOOP7sMGPyc1qCgfUxmDYHNQ1pDYus2jVC59dbbxKVn4EXetn27XMCFgn9jSvyrIiKF7wvFkl7QC2Nj0r5UDghk0eN1YLFn0SeGsuTPVjPofP9gROJqy/mPVf0vl8pnlgTODhoCVwEBfbu/Ch1aF4aAIWAIGAKGgCFgCBgChoAhYAgYAoaAIXD1EbAIgKuP+VrvkZ+MU11DpYXiRBKAfGenJJ3yi3MNSfMZV0s2X+J7NL9pwV5bRyUy9mT4YxEBsQ6YY5jNKgORQcQDGRD6Z1Ve6vlFOg/Go5DX08n50R8ZLTL6tGd/3ajy348cyV07kQOLHPILF2TTAzdyUXcFYMTA2JgyjBfOq32Q8w7Gn4x+vtDGKYrMKs2xqv/c5zlmPzuvERPS+RUInD/nu3MLKNwr6CPZ5FOf/GRSdG/5/u8XuVDQGgBk7GuIAFhYUEaXOcmx+0c6X4Zw8YIysNuHNSd3V48QeG7XeZ0vuyi3K0NYWNDrk3q2pzy772Y59Fcjh0SOCfPzWiOBOczTE3o/ZcpPisuJQQ0QK13QKvWlwf1if04JZjfUofgyB1oae2FsbFIOkZEl4yvGyxDIsNLfyKnHxMvg8HUqd2tNg1KZzydNAW5HBMxA96D4y+dHRD569KjIXQuKR9ug4k/Gf8u2vdKejP/szIzoWUNDlF7g/Uo972/qGUHAqviDg4oHdwWoVrTKPq+Hzk6NWGLESTfOV3u7Rlh0oqZAB55nfH5yveJ65Al7WTCmZ2ZFZk5+pcLrBwjWq40Z+4y+byDgxCFg0OfwN3aH3lsussZQrCYA39daPiA45PsV1IHo74/jyYML/l9SXuI337+XMJFDkQtEbE3YYAjwr6UNNn2briFgCBgChoAhYAgYAoaAIWAIGAKGgCGwMRBQum9jzNlm+SwiUCounEp234UIgKRu8Te/OGecfrPiF3a2p8wce34hjvlLGxHA/tkfGaS2Nr0l+UWZVXvr+ERfr+sHX0ZQZKGPfU7meIkPGX9WYeYHbTIeCPBwBezDXADD3gmG5eDBgwLxzTdpDivH7/cVFnsK4+MSoBLsO9/Z1S2MDHMOmZPKKtmDmyTgxU2j6vODDz4oQ9qGnNG9e/eJnvvSX7hwXvRf+MIXRH76yNMik/EXpRc6uzrlEGsCpK1BwPaXkSP9l3/5cenvvvv+sci8nstg+MjQhhEqesXzepXOvEB8L18eE5OZGaWsb27XiJDBUxqhUd6jDL84W0Ig4x+LCNh8zw+Kl4888dsipxUYEcT2Z86elkOHt3eJXJnWiAxReqFt7JgcKuaeErk0OS7yn39dGfZ77rlb9Nxlggwu58MccTLE4nwJgYx/9+BusWpHFXhReoHXb62uDCt3HehETYRr9+t6cPjwYemiS4u+u01DGlE1OKTjnZrU9Y9V/Mn4xxh+GcwSAnP8+wc0B55NDhw4IIcGB7WGRBcYe0aYtYHBJyO/UNGc+WpVn6c+wk7W/+nZOXmgTE5rDQzuSsT1SybjBf++oxcAYhwDAh4MPv25iD6b0fn5EQQuGh0Ih6vWwfNXNy1S42VIsef3MlysKpPifFFDGFfV6Gww6x2BdHf7ekfD5mcIGAKGgCFgCBgChoAhYAgYAoaAIWAIrFMElG5cp5O0aaVCgJ+ENUksdKWUmnOSJMecpoVKRZMUndMyuKH/VEeC7+fBF22dXrMRARwcv3hTz5x/Mv6MCGD7IAIAjD7tKYcMhJ6+WgiguGAEACMQGCHB+RLvDCIY6H8B+7RTJt7sn3qOJ9TrN9ES9umtzUwLQ8P2ZBj7Nw0KY1QVNJ3r7ekVf3fceZdYMGKCuyzMICeXNQfuvfde8UdhbFQZ7VlEJLAmAXdJoL+YzAgA+v86GN4DB74mLq8/eL3IsZz/aoWIS/NAKCIFkxEAp06dlDbtm5Tx21VSuXuPVqWXxlcgMCJgzx2vFS+/npLxH750o7TfdYOuj21tGtEgxl64cFFrAGztfY6aoGTE6bLiV3zws2LPh416d075Xuf++vOaM//c2+8Qf6wK39enOfCsms/7V5x54dw5rUkwPKwMejcYerbnrgGhXo8wIbjqNKIk5/T63r+nTxyQ8WdVf46Hu3DkUIWfu3x0duj1wQitjq4euaC6O9p0gc3mdH2sakyYrwEg66Mfr9jXKiXxVwFjP45dC/j8q9f0+eefT9KfgPl3QkwvTVjTx6Gmjxh7IS3DHwloo/slZIEv0DMCMzDAAT4PoQ5E7kIUGDR5gO8T7I/vU1x/qI9FPPB9iMNnROfDjz6sIU9s4JxeoKHejhgCV4xA47v/it1aQ0PAEDAEDAFDwBAwBAwBQ8AQMAQMAUPAEFhNCFgEwGo6G+tjLPxiKRTBzOTY48lpbtui++QmdUv9rmMbWn5x5hdfEtpaY9cn2MGADPVSY2jmGPtjlf/YfNh3wGhgguyP8wMB7/fJVkYp6I/+kdPXhl0F8qjizi/krFkQ9KeEkAvHLwQTm3t7PcTrI5bDyv7Um/NVkrV/nj9vL4wRzwftyRjh8nQzs8oAknFjDuwm1Bz4Jz9wnQzYj0fGlwOlxBxYHyEg9nOzWhV8DlX4Z6e0CjkjDGZmlDGfLyqjy/PD+ZFRYYRHLq/+ef5i8mPf0Jzz6cmj0uSOzF6RBwdX9pE6ePD50t8Dx0+KnFZoa5OALXdwWGX6Y870Vx/8qpiwijojCK69Tqvil665RtqfPqnzYYTAhSnUFJg6Iu2/3qY1Kzg/Xj89ea3KTwZ/YkJrEDznllukv75eTbLP5eVx5xixw/s9tFdOhrumMEKhUs/ICnfg+pvk/qzWgyRvGT8Zdt7vfv0T/1zvfFV6GTCfR8W5GdHPTIs75+o1Ga9/HkBWe9+/6GUySwgY/hIWjQ9heQyMeT5j9nSQz2E6sZx9hAgoOvS++PCJWYRtGh0hA875U27kaylds+2X8pk8xvcPPj9i+qSvK/lN/4888ohum3IlTrUNX+Dk/vOm8vzXpiZtNAR4cWy0+dt8DQFDwBAwBAwBQ8AQMAQMAUPAEDAEDIENgcDK0hUbAsJ1P0l+UWzqmuEXz2sPHEwFID6ABwxvzBm/MGeQQ19VwtZX5dVvZBnkXFYjhAQZiFDWD7Ksqs/51KoMsFALMjTB+JXwDRh1MvK1us5fewsl9pcHfmELPcLxqzZk3JmjR3t+cef8yHixf8qMkOB8aR+MRwm1IIIg1p7+2D9zTHO4Yby9nFAyVmTQYe6o7+nVnOq+fmW0stu3yZDJIInSC+yPesoxe+Z8sn1MftOb7mtoUqsqI12t6P1Zi9QgKKN9w868Mo99xzfP6i4ELyh9p7jItuk+56L0QleH7krh951XyjCrWbGV0oKc4O/5nu8Tl+3tBZHz2MUjn5fLT2wXBV4f+fx/EBuezzpyyMV4CYH3+xImcsiv14JHcH/WKjIhrkdkxJlTTT3lIna5KC7ovvYO+7wjIGeRAdbxIWJJJuuFYH6IYAIcbL5U+8Cm0YGY/0Ztr0TH9a/l/SPnn+c/7ZjTMvp83sX6Y0Qi7bne5hBgUkfNJd6v9LfWZN7fXK84H0YYUO937TjNYyllfeCkbGzmGxsBeThsbChs9oaAIWAIGAKGgCFgCBgChoAhYAgYAobA+kWgKTZ3/cJiM1spBPjF8w1veCO6UsYJSoeUcPD1IYO4BKNBlw3lkCHQ8eWyGiDBiAC253jKZf2Aq/xZODQyRDHGgv3xCz574HhyADzGYOhsPGOJDth/swxB7AsmdzVgREOGDAYjQlBVmuMnvjwf1MciCABXlFGj/yDFTwnAIOKD1yfHT304fr0fEGDC6ay4rHxt2F02kgKZqen9HHpofKRWB0Pb2Dy1tlLWiINgvejWKvDsoIAaHZ5BFpNyqSgM/2SpKHpG1HR1ac0A1myYn9MaEeH1Ku6D61O1S0oyXlpg+aI6qLrO6903aOifDrk+UL+Ef5o0lFfaPzvnekB97HzSnnLMP+1jctrxxPCM9Ud9jEEPGfxUl5fD48jXUND7l+OJybHnOdsTXy4ntI/JrcY/1t9q03/0ox/VIiPO6QLvEHLa/AQkoql5d+ZhLSMQe39ey3OzsRsChoAhYAgYAoaAIWAIGAKGgCFgCBgChsDfI0CCzoAxBGIIxEjeWPtVpa8jR89XJb6q4+MXdTL8sS/0bB8bfFp7Mr51MMphf3p5qBRGBKQdT9hf4yMBw4Ac2JqmxAcEOhmOGIMXY6A5WuIbjBcNmsWLjCiLUNM/c/6px/CCXTXIiKWNECCesZxWMoqs2VFizQ4waBlcHyHlpjPOZXTXAtWGUqVGgie0aeqIEvaulteq+OUsYgbyGsFRQM4+x1LTou+ugpoHjADgPvO1SIRF7P7ieHh9hXo9Qv9c7YP7j88HdRdEEPD+oL/g+ozcEGzP8WM4/v5rjtPh/cX+OH72v9Iy8W11f5xvWv8BfnDA5YXn19X1icldj+BuscYDD6WSw/HgjoDoi8Ck8h8z5vkM8Ig5WOV6Rkwt8bxP9wBxTi+Q+Px5BuMtzGLDINDc02LDwGQTNQQMAUPAEDAEDAFDwBAwBAwBQ8AQMATWNgIWAbC2z18rRs9PyOmS0sIRMClNKK9Dhw6dSzZ573vfmxRdtsVfmPlFmV+cuQsAGdEKKVMZbfMCGRV+IXZOv7jTvvkRNPZABjZWRZv4gm90zMlv3Lsn5DkANIhV4YV50yIjMhgRwflnUUU9NgCef16/sfZRPSJcOH4yYLzeqI/1R0bMV+mINUml5/hijXNZXe6ywKMCvqReUPt8BRR7pMO0jH9ubkE8VpFjL8olhPmS7kPf3uQTnow/r0/KvP7D87/EoBOH6iC4eP9gm3ifoc/HTcLZ3/7U9ZPa4P6KMOjYVCOIeKF/yrH7h+OJ2dM/8W4+IkCvf/YXk9Pen7x+Yv6b1bM/4k//sfMRzhcLCtY/9s/+YnJzZ8e/XUTetwpYLyPmseE+6/pYVf6YPu0E6K88XzyT1ofZGwIrhUBr38ZWapTm1xAwBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBJpCoEl+oKm+rfH6QIA5SamuqWKpIlVQu3IF2ZiajGurIWMNAEYE8It/+IVfR0SGkbsCsGpvmMOnjFWsP+09LpFQT8sY0Z4MIBkUypkoY9d4DvyiHosQaOwtvZY1GmIemOLL64ntiVfAEEUYyrQ1LBjgwggY9k/GiHqOn/Pl9RNjKGlPvCgz55/6LHJsqS8wKRY1A8jnsT0Z/3xWGXnq2T4t48/2nW0ScOX3PNAaALSPysCLEUB17HpQQ02FOotYp4wvY80ARgTExs/1NcZ4ZIPx6nrM65n9x65/2l9tOe39xPHxfqc+9ryKtae/ZmWeD/ZPfbP9sX2Ih64gfB6zPeXYehnd5SSX6vXMVTOIMUDNj1xe/WW4XtRUn3bXn2xO79hs7PkHwFp9ftNGHLIGgK+Rcjw5xAX/Lykv8Zvv10uYyCG9wERlgiGgCOjdpTqTDAFDwBAwBAwBQ8AQMAQMAUPAEDAEDAFDYJ0goJ/n1smkbBprB4FSceFUcrRdnZ0SAZDULf5mDia3aQ6/uNNDc3LAAOEDeVrvQYQA9qVP6y+tfcjg64Q4X9oTbzIsZAzTFrHNgQHgNu6MCEg7/5W2T8vwND2elAxJ2v54vnl90F/MPi0jSX/sL62cKSlhUsrr9Z825z/G+Deb48/5TS7oePvbU1LscFguawSBqypHUMMuBuVKLAdfOyCDT4ZfrUOJ9vTHFjUsGFnUVAkjknS+9MfrL3b9s31M5vqZlsHkeHh/cfxpx0P7mD/Oh+1XWo71T31avNk+7Xxi/fF8xhj+WP+5vN6vmbK2yGU04qWOmgVq/exLra4Z1eoZ8f3k/IURiXhtdX/mzxBIg0Djp10aT2ZrCBgChoAhYAgYAoaAIWAIGAKGgCFgCBgCqxYBiwBYtadm1QxMKTKWpQ+HyZyl9qQJc54WKpWjSb3/fRvkVCK/mJOh4Bf32Bd85kRXlXBLNbYljZEz60AZ15BDx/HDfMkuGh2kvxge9JXWvl5XhoE5jcyZp3/moLOKO/1xvDGZjFnMPqbnfJo+XyB4mcMfG09Mz/uF91OsPc8X7Xm9pfVPf8z5z/udsZP/ashRT+oWf9fb9Bt4GyICuPixfVq52Rx/9kfGv5aP5Pzn9f6jP8pk3GOMP+1j/qqIIMg1OT72xwgB1gzJVvV6cTk940yBdmRIcUO3+vqnP94/nC/vX+rpj3rK9BdrT3v6i61XOYT4cb5cL2L9sX/KnA9l9s/+OB76TyvTH98/0vorAM9aGREAiIih/0xZ7weX8v6kP8rElzn/tG+1nDanv9n+T506/mjEB9+fI+amNgSuHAF9+7lyP9bSEDAEDAFDwBAwBAwBQ8AQMAQMAUPAEDAEVjECFgGwik/OOhkav2hKWeyZybHHk/PctmUoKTb9m1/U+QU/bQf8Il91uIVQBTfYFUA/wAfdsyYA990F4RS0bzWDTbyIJwdAe9YMoL1D1e2AEcgqY0l/4T72GqLR7C4SrPrP+ZEx4vxi54vzpb+o3ul82X+McYvZZ+E/Nr7Y9RFrz/FQJuNPfQXjjX3hrpdmxUVjNMV0SSFW5T9WI2BJpw0ORhn/Bm0XVbVS4xlXa40XrBjjz+4Dxh8Ue7ValCY5rq+iXUJAlfNgfLggeH/kq7reMCKgjl0hHGpu8H6J3Q9cT2L2vH+WQKDhIfbX0NgrY/1VCWBdGeNaHREWjHhDRAUIa5dHlflCoSBD5nxYc4bPQ9rH8JbOrkDg+n0FLppqUuPzNcL4N9XZEo3TVv2ni7RV/9l+tcmPPPLIky0eExdorHAogtLizs3d2kaAF8vano2N3hAwBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBJZEAPTlkjZ20BBIIsAvjk1dQ/wieu21B5J9+d/KyJBwIGOAxlGRX+jrVWUwog7SGoDxcGREIJPh9pxM2h5T2ZMhiTFAtI91FrNnf5RZA4AMe5hzr984Y4wPx8f+4/OLWag+rX9tHZeavT9ieHEExC9t+xjDz/6yiLihnjKr/ru2bjFhRIAolxDI+JPhZxPaUx+T0zL+VdYQQQfZNmVoy0UN2GLOf8Cow1/A8CNnOAfGH839aq+Pk6rT8cT0bRWsj1lljKtgRHN8vmQREaHd+01n1H9WlxcXPJ9g0Or7nc8v4kk5bf+8nzk/+g9kPM9i+lpNAeX82tokgDBwxxoP3IedEQGMGCA+fL7w+qd9MKDIAa6PjDCMNA/UuaxenzR4tiMCeD45vpictuo/q/DH/Derj/V3+PDh0032wRWpSXfWfCMjoKvtRkbC5m4IGAKGgCFgCBgChoAhYAgYAoaAIWAIrGME9HP7Op6oTW11IsAvom94wxsbDrRZRpPO+QU/yxzSjO6LnanoNzN+sY/uEhBjRDDAkBFtfMuSsYG7Z10k3rEBkTFgDQDWEEgbIRDrn3oyQtRTJuO02s8Px7/WZTL+NTevU9LbW3XLkMj4k+GnnjLt0zL8sSEWOhqvF2T8q6yKH+sA+oDhrzYmrGIMf1td11tGQOWwT3kJDH1bTTc6zzlEBGQ1oA2bADh2n2FOO2ok1LALBWsEcJcXPs/ICAPeQEy7ngYOcIDrU2rGH/7SipwP13sy/HkUyeHV4qCnf5QYcOyP63e1qj2EEQR6PUXnzwimTOP7NYPrLfDPCyowSHegXtD5osRKOmdXwZrvC1ehy1RdHDp06Bwa8AmU8gKCt1BESFNoYEc2LgK4uzcuEDZzQ8AQMAQMAUPAEDAEDAFDwBAwBAwBQ2A9I6AJgOt5pja35SIQuyb40UiT9J3rQEftkOUTt6/quzWpf8j/S8r5QuOcv7QfvMlwJPta/J2t6wfZKnISaU+5WoswXqwiHYsIQM0AMtzsPybH5k+GhIxIzD/17C9glFA1mowZzy8ZMn7xT6vneFstc/70T7ypZ/u09vSXViaebE/8qY+1D3bJwP1BPf2nrQGQNsef/ZGxjzH6MT0Z/+qC9lhDUYtCmxI6zPHX1s4Tdsp4Z5wy4mT8a2AkmfPssB4G1yfaBxEBGCAjAMj4B4w+KPmYHt25Kqr257C+0t5l5XHlawDg8Qh/vN5j90dMT3/B+FIe4Pli82B9pkFM5noO+9j6BXPX0aGvE11dnWLCiD1ReiHtPu+MMIjVECiX9Xmfwfxj7wMcb54RJDRIKWfzuF7RPosaGHy/yKKGB5q7HEIo6ohgiO0CwOs/m9PXS+4CEKsBQH+xnHzOl/OjnPZ6KpX0fXLLli3XJ30uLCxMJ2X/G08Ap9uiOBRFYQgkQ6QWHwH2zxD4Fgjo3fYtjOywIWAIGAKGgCFgCBgChoAhYAgYAoaAIWAIrG0E8Hl7bU/GRt8SBPjFsPEn5LBL/aN+9UkAAEAASURBVCTuUNYZsv9iK0m5lbpmYWZrSvk0u687GRUyImT862R48IU/nH7jI2Q0q8j4IuOGItI+hbTxN7vY/BqPLq4lXuyPHsgo1SI5wfyAzZrGZHzqiKDgeNKOl+NPK5OBSNs+Zk//ZNQ4/5i/tHr2H2vfLP7MsS2gynUFq00dOd2Zsi5fVeQEt0Vyasn4c77Ul6vKUJZxfzunehSld2T82V+xpPMhAVQAY5eW8Q/2uY8y/nxccMSNZVb15/3fuLVzjBiI2eewfvP0RCMCYh1Az/uT9w/XR0Y88f6B+6ZF9k+HWV6gsYgJPC/pn88z4sP+OX+uByEhqh74/FBtKNU44NCkuSNgyB0iZiogbGMRATGGn4ONMf60X+8yIzxiEQMxPNjeX99PxNqY3hB4thBo/NfEszUq69cQMAQMAUPAEDAEDAFDwBAwBAwBQ8AQMARaioBFALQUTnPWLAK1SkmrpOY7djXrM037DHYBcGCs63nlFLgrQKyvkMHQFtksSyqonhIZXzImtG9WZn/0VwWDQsafDAtTaumP/dGfA6UUMGw1PV/0T3vqKXM81McYLdrHZPbH8xsbf7Pjifnn+Dle6puVyfjTX11T3F0tpzmY9ZJ+854HgV3A9VvOgrFnh5ALOQlocowIgHnA+JORpn1MLlfUQwUMPlNCue19Fdtc5HiDQs+c2AwiLLh+xsZPfYzhT1sDwGW1JkK61Zaj8zIiCnyRADUiI471CMuXw+Xn3en1yggB7SwuBf7RJPTfHEKBP8wH3UfFZtezWHvm8McGVIvU/Anag/Enw88IgKD9Ch9Im/O/wsNZc+4ZUVAulS8lJ+Fz/pnjn1Qv/kZMG9WB3PgFJzC3A4bANxHQp8s3j9svQ8AQMAQMAUPAEDAEDAFDwBAwBAwBQ8AQWEcIWATAOjqZa3Eq/CJanC+OJufhqwBf1QiAZN+Lv3PIOa5W9JtZFQwi2wcycyiRwx7Y4wAZDFaxjTGwZJDhPhDJALN/+qtWlIKlfdAB9vEO9Qow5xfzz/HTf6w97emP44nZU59Wbna8afujfWy+tI/JVVT9d5H7oYoc2UxVCZAai2pg3+5awNjqCMsBZal6SoxoKSPHv0wGnQ4gBzn40FfBMJNxgrlfv/R+rNaU0a1GaiBoPAG9h3K2qgx4ra5ydFcAMMTM2WePsQgB2ldxfaXO+UcERGxXAPZPOXY/RyME6BD4UU055eXO5n76en4Dg8gBrqcRc18DRy1i+JHRj402eJ6DsdfefQUOrD/UU86hyn2NIUusEQAHsZx/5vijueP7AvU1RBDFIgLYPlb1n/brTWYNgLPnzj8dmWNaxj/iztSGwPIR0L9mlt/OLA0BQ8AQMAQMAUPAEDAEDAFDwBAwBAwBQ2ANIWARAGvoZK2SoSrl5knyyLj4hbO9kf3U7OyRpH5g08BtSZm/mdPYLKNB/9wVgPrUMhio1O2f5QZk/CtIyiYjQ/sYY9QkoeTYP+Ei48Tx0Z4y/dMf55fWnv1RJoPD8bM/jof+rrbM8WYclxMdEXfFIOOOgADw4+qrFVJQpT+nnGIFjHcGjCXHwPWL+NA+0EcY/AVE2OQj9uyvWTkLRrMKRjVtRADHE4sQqKZkxOk/kLlAtdp/0GFzB5p9HsbWD65/zY3W7wGBGglc7+ifEQFZrCfVtDn67AAyGX+uTzD3L0e6PnB+2SbfwGOMP8cTk9My/jF/1Lf6eqF/MvDUr7TMiKwzZ05pTauVHoD5NwRSIGARACnAMlNDwBAwBAwBQ8AQMAQMAUPAEDAEDAFDYK0i0OT3x7U6bRv3s4gAIwLakmM5dfLY/Ul5z84d9yVlBhy0muEgw1YHw8OcQh1bKAU5zqFJwyNMMcyibDfHG2NsGna2DCUZDPZPFys9HvYXkzn+tFW2OV/6I2NFxoN4UB8bP/VkwOif42V7jp/6tDLnE/PP6zsYLycIyrxGPe7XYPwxfdBAD3B8dTB8FLW1LxofRAQ0joAIcpLpMCLnwajWIzUQMhF82J72wfkIxseAMT4OggYND5DhD3L6EXEV6Ok9RskCnwxDUGIXAPtrVsZ4Ys9Drg/snvcv9bH2tKe/2HrA9ZP+eP1RzwiTOgIUM06rWlBfq2rNDPrP4H5iDRLa+6T74NDVPBDDMzqWnLyeeXNdr+qI8In6g0EWNRGgjoq8vqINrrLBI4888mSLu9QL2CHEiyeoxZ2bu/WFgEUArK/zabMxBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBJZEwCIAloTFDqZAgF8km7qmLl28eCFF3+vOlIwoJ0gGhYwMv/iTsYz5oz37oxwymuxBZTKG2QhlxfGot7jE8QaMARhREMwt34eb8+cMgvHRADLnFyMEiCf7o7+YHsMJcnjZH/3H9OG+6ppTy5zfOAPNEatcizG4yAHX0aivRSmoWRCayJEaGN1wn3kxb7kQY1jTdpgFXjw/1Xrax4U+blhDoFJXTiPDXVfAALso469nmOttcH2mBQj2vN/SRiiF49MO6D+tvXoLpZg/Pp/ogesB9Q41JAI9DnC9Ce8nZbTJ8MOdC3YZgUE2JeNfA4Oej60/6C+tmDbHP5dRfNL2l9Y+i/UvLZ6x/rgexezT6lmD4PDhw6fT+oB9cyFScGaiIZBEQJ+WSY39NgQMAUPAEDAEDAFDwBAwBAwBQ8AQMAQMgXWDQNrP7+tm4jaR1YkAv5i+5vVvkqxZn5EmlAwZ22wWOXfIAV3pWTOnsNmUrBgjQj1lzpeMCO2prxJgOoRMhrtZRjHGKKH7qMj5xRpwH24ycmwfw5PzIV70t9JyDI+YPjbfQC93s58dIzCQYxufvzqMMvhxh6ksOL9Y42bvB09BNu6C61/MvrG3QJv2eiXjz/nnMF7ak7FjTZVYDjLPTxWMP54W/ukij5dgn/sscv5j1xsZ9xDQxhwMA6S4ftAf+2vWPna+AReH4ziewAAH6lUlPCt4/rAKP5oHYozRDxrgQIzxh7lzDOFDjnseb9yt5tdjERbBeHkgkvMfmAcPSLVIe/619dqXDh06xF0ASphVZEGHdSjyEtIHYmhvRwyBZxBo/PR5xsx+GAKGgCFgCBgChoAhYAgYAoaAIWAIGAKGwFpGQD93r+WZ2NivFgK8ZvgRiaRKBwaGb+CuPakvFApbk/Lhh7/+taScByXEHHIyJsm2i79pH8vRJYNERpRyzN5FIhJIIHD89E+GJy0DUK3qB+iA8Y8wtBwPawKQ8SOjxPPB+T7bcsBgMEcRd0Pa88HzFfQXASCGN88v3fH88XqmPeWgPfmHyPVDf6td5nzrLMKM+XI+vB+qGtDkAyJI6MBDSkaf/cFbapH3b2oHTTbA8h944/1HA+boBs8L3N+s8p/N8fGFHtA+8A9zirHxc31o1p7nk4w+++N4KcfGQ3vKXK/KZa3KX6uSQKWHdHK0in/EXRb3rwPjz+aMAMhmCjRpKGfz+sDJYpcDPk/oLFYDIMcBwgEjbnKIAIhdL6z6z5x/dOdiNQBi/YX3u+LH/mhPPdcf2vvr9VKyzebBwXuT8sLCwnRS9r8XIBcha0iMjzGBng8MPoFhbqIh8E0E+MfbNzX2yxAwBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBNYNApHP2etmnjaRZw8BfsFseM35L7rzyaFWSyX5ZJvvkICBpOnf/WbV5wjjHjpYXUfIOKZlaGOzoX/mZMfak/Ehg1Srre1vjMSbDEYMnwBfNCDjRRnmUZH9cfwxB0F78glguNP6j/W/2vTEIxgf8KCeDHzTjD9y5qM1ATigNSYHjBsXGM4npqc9ZK5nKDkD68WIssbrW+gvcCEH6C/WPmYfY/ilcy/E/KWN2GL/7I/3R62ihCYZ/1jEXo4hUeywSblZxj/WfQU1JlZ6V4AY48/xkvGnnnLa52WM8af/Z1tmxFalXB59tsdk/RsCy0Wg8dNruV7MzhAwBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBFY1Ag3Z2FU9chvchkCg7upnMNFdkBuLiAhAUWHflilVjd01q2XOcMYp4xHzn82QktUWzTLIrWZ0ScitMEGjYDwLEhnjZs9HjAFkf2mnHNwPYLRbfT2kHd/Vto8xjBwPGczY+Yjp6T+QIzUAOJ6gfcoDMQY3pbuoeZTx54IS8cj7hzn5GUZUwB8Zccr0j+ZRRp32lGP98fwQnlj7sD8JuKM6kNl/YJDyQKmiz+MgIiD2ACEAKfvPxaYfyfFndxXEP0ZS7N1KM/4cX0xmzj/teX1RTzmW80/7mMznU9rxxPyn1c/NzR9PtvE5/8zxT6oXf+MKoTqQ070wBs3tgCHwTQQsAuCbWNgvQ8AQMAQMAUPAEDAEDAFDwBAwBAwBQ2DdImARAOv21F61ifGLJHcB4ED4xVOS+vnFdHJy6ljSQUdHh0QAZCMMTrLtUr9jOY2xotxL+Uwey2WVsU+7j3HS17J+gyGM7VMdZSTBCAdjQNVr6kOGTPGg/aqXiUdk/rH5xBj4oD37i42H+sDhxjqQluEnOmTYef9Um728cf/G+qd+tctRhp8TiDC6XF/ISLM57dkdq/znGDKABmQco/5T1gyIzQfDiUYcxJ537I/+Y3IBz7tyrTGlXilpVf9KWQnTWoTxr6PGT41V8SPrX73J9wfiEWP4ad9qmRFnsV0BWt3/WvPHKv4cP9cr6imPjFy6wGOQ+f4LtYmGwNVDwCIArh7W1pMhYAgYAoaAIWAIGAKGgCFgCBgChoAh8KwhYBEAzxr01vFyEFgol0eWY7dcG6bwRQiG5bp9xi6DmgLVCAPyTMNv9QMMx7cye+Y4GI0sGMWQUQFDA8aENQvIgHLXgJBBUko0xpA9M4+//0GGjXrmAFK/6mXgHR1vzD6mj3awvgzI0JMRjt3/wfUegYc5xEFEAO7HiLs1pyZjFr3feUIw41h7rjd0x/bBeoKImgjh79IyqrTneDFdx/FTz/GH88N6TgdNyjGGP8b4k6EuLsimP66CGgCssh4JoHC5Jte/oMp/SryY88/mbYUCD61qmVX/ef1x8Gmr/rP9apP5vhSsb7ggH3jwgcdbPActiuE36oB/RuBCbaIh8K0R4MX0rS1NYwgYAoaAIWAIGAKGgCFgCBgChoAhYAgYAmsWAYsAWLOnbt0MnDlRbcmZnTp57P6kPDw8/Kak7BluoTxYE4A5j8pH+12ewQClZfySY1n8XWcOYlY/4FZVZHPHCAKON2iQ9gDm6yIUKHcpiI2H+BHf2HDJMFBme+oZEUCZ7a+2HBtPTM/5Xu3xt7o/zic2/2b75+XO65URA6n7QwdZyGnpGo4v9XhSNuD9SsaL7shAUx+jtGPtOR4y5LH2vL4osz31wXxwgPb0Fxs/28O9h08eb1T7nP/G+qABDnB8UAdijOHP1fRxXqrr+Irzs+KzUtYHIhn/8Ppp/ASqIQIgVnWeETs1/wRP/ksbEcAaALVMa1+xs3i/SI71avzm+hy7fq/GmFayj9j6x+v10sWLsRoAKzlc820IpELAIgBSwWXGhoAhYAgYAoaAIWAIGAKGgCFgCBgChsDaRKC1nyfXJgY26tYioJ/0nd/atol/ab+o1oIcW92UIJtRDk6/94cRAdks7Ov6zYxfxJuY6pJNyfA0W8V8yU7W0cEYI9Hq88Uq/iTkqCfUacdD+9h82R9l+qN+peVW908GHwR889OJOGT/zCGNDWC1Mf4BAw1GOtBjgjGGGe6WwXjr+usY0YT+W13FP5bTz/lgOE1X6ae/tHLsfKT1x+uVjH+5pDn+pQWt+l/lNjsAMJvhE1pHyAgb1V59iTUA8nmNiKggIiDvY/5a+Y/XZyt9L/qKPW9qVX1fWm81AYgndxE4fPjwadqklPWCSdnYzA2BNAjgaZqmqdkaAoaAIWAIGAKGgCFgCBgChoAhYAgYAobAWkGgKXZ2rUzSxvmsIsAvmqmuOX5Rfc3rpQRAMDHWAAgMMhoRkEHOX73FX+SDXQDQv0tb5T+YkB7IZDQAozF/shjxoAwEq/6zyr/25msegBEN/GEA6I7umpbJuLeaYaY/FAF2K90/AQrHs7G+6dYR8cPrlwwl8eP1S32sRgbbp2X82V+MoeV8Yvb0T5k5rrx/mYNNfYwRpH3QP28gGoDhZ4RNs/7ZnvOJ6TncWPvQXtdf6mNys+c/5p85/RWcD0ak1SplcbkwvyByPXje6fxjjL848wJz/Ct4nvP5RftarChPTt8XsmC4HbcVwgDLNV2P4Q7WzYvcZcEV2sVplviINi6s9POG5yO70oDFp9zQgjUADh06dA4NNOTFYZsoGC9D1BCLxbJT9s8QuEIEdHW6QifWzBAwBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBFY3Avr5dXWP1Ua3OhHgNcSPSvoJ3bkOTIMRAfLJulAobE3aH374619Lyvl8Xr6IZl1N+mNEAD/Yxxg16qtgAPhFnLKrawBEEBEARiSsYZCcbfg7YGBQddmBIaUHMpZBBASqKpPhpj8yojEGLaYno8b+gvFwvMwxpQOTVzUCvJ7I6KVm/CPXQ9DfqkYn/eB4v9EDc1rJuDPnnv6C+xWMceAPA6A/qKM5yGnb056MeixiifOlP9Zw4XzSyhxf2vZp7fn8C543YPzn5uekiyr0orwKQtoc+3pWXh+CEXLXABqQsa4jwiWHF5DY+DieQuQGqucLMqQ8nofZvM4vx20LpLWPsMjJpkzQxmsCxGoAMCKDHRBP6ikH9yPwDyKeoA/8RRaASrV6Kdlm8+DgvUl5YWFhOin73xoS41wRen1hDCMG5H3Xt7UIAABo4vIR4B9ry29ploaAIWAIGAKGgCFgCBgChoAhYAgYAoaAIbBmECD7umYGbgNdswjwC2fDa9B/0dUywtWKbCSczWd7kkhEPpD7z6UasEBGhfqk71b8zmX1g21VAxY8w6W9pI0IYA0AbhuMgIMQD3xQ1tHq2JYjBYwqGAkyuEEEBRjEoE/4C9oHDRofCMbb2Lx5LcZPBpGMa/Mdri4PAd7Ag6MNIj6cEiKBPzqAnNYezaMiI2zYgIwU9TE5uF7QgFXwa1j/YO5TmnV95PVHhi3QszkYtKA9BhCbD8xbHhHQrP9mGf/geYT7gYw87Tn+tDL9xxj/4gIfz1oDIBJwEzzv0o6X9hnU2NGKOIs1A3CB0sEKy4wgZA2BWERA2uFVs/p61WwNgLT9r3f7hWKROf/rfco2v3WEAP7cWEczs6kYAoaAIWAIGAKGgCFgCBgChoAhYAgYAobAMwjo58FnDtsPQ2B1IjA7Nz+eHFm+vaMzKfsv/JrkllT636z6D3Wgr4aUI5s0J5OSb86bH79+0+O+vHQfwyNg+MC4poWHeOayyuA6nj4wYBwP5xOTY1XjY+3T6smoxdpnwNDGzk9axjTWf1p9WgYdm0YE3cX1zcakBF02dYD451DkuYrrmfoKanbkM3o/VBHCEzD0HD0YznB8aIAImxhDTz39x/TovWkGP8aAAw52H8jpx5+OUY6NNxhQygOx9SbWf1rGv1TSgL46rmemKDPCLTa9yOM81jzQNxuRU8Xyw5oAVUTQ6dM4GI4LagIhQpAvM2U8cDM5fYWnfdijHikjRKIAB1WEcORwAhlxx/uH7x+xmgA6urjE/uItWmuxsFCSCACf888cf3aoNwy1oawPhFBvRwyBK0Ygtj5dsWNraAgYAoaAIWAIGAKGgCFgCBgChoAhYAgYAqsHAf18uHrGZSNZuwjwiyW+KQcT4xdR2QWAX1SLxeLxpIf+WnV3UmaOK3cBiOX4kwFJ+r6i3xncYtgV4Ip8NmhUB0MPAtJVq2SsQGnANxnoxtbOkRHh+SBjDELDZXgA83GBjAFDTMv4xxg0uG+5GPav32gzmH+t3lifQVVr4h9j3Fs9wXB+jXvgeB0HHKN4YR/4a9y9IyNPczL0lEPGXtcDlRa96/nk4hlW6ef9rCNkznMtJePfaoaf/mKMdOz06mxDKcYQcjyhBz3SbI5/2utfew+ltP5oz+cd95EvLug25lVQxtwH3eGEgTD2/Lhe37w+wxmu7iNVzDdT1vlV8vrE5PWWBePPiIA6IgryqNFBBp7+c1hAKogo0tH6kvN4nqC76MngeGL3Xw0RfrFdAWID4PoYs29WPzJy6ULEB99vI+amNgSuHgK8/69ez9aTIWAIGAKGgCFgCBgChoAhYAgYAoaAIWAIXDUEQgLiqnVtHRkCSyLAL6ayEe3Y+PiJZKtt27a+KCmn/U0GJIsc3ICQTttBjPHPI2Ciku6bHBn/2PBYBZiMYLjPOhlGZTQCf8hhj40nZGQVjzoYCTIcMf8xPRmxmH1Mz/k0O95gfGBwY+Ph9R2zpz7onwZNysQr6g6MG+0ZgcKcXZ4P5uTTn0MVbepBsFEdVDlvNqc5YExJ0SHkJ5yvDjHG0LG9tg73AY/Zk/GPnE52F60ZwAax8TTL6LO/1Sbz/uf9Vqno47ZUUsa/XNaq/vUqksYjEybjT/NYTn6zNV9i13cwHh6Iyfo4BL8ea5xeX0MEBZ/O9Bis3/p4dTWGaCDnn/4osyYA9c+2zPWy2QgBtn/gwQceb/EceYPxhRBnsMW9m7sNhQAvrg01eZusIWAIGAKGgCFgCBgChoAhYAgYAoaAIbBRELAIgI1yptfJPEdHR76enErN3SBfTP0XrYakHBmRpK/F39STMaF903LA+Cvjwpxgjq/p/uEgViMB5j4jPcZBaAsycmEEgdqnzSnU1qEUMCKhSUuPpL1+iA8HExs/CCnf/OoSBmnnmzqnH4CQ8Yc6LkYYfhJkdJiW0ScjFfir6yMZok+x1vstLcMZ9Ad/1NM/+6c9Gf5QzyMqsz/V+l1cIuOlfVqGn+tr2vbsP63M/tO25/3H9ZOMPiMAKLOGSmy9J6PE8XA+wflEhBPbh/YNH/cubQQfzzfPRwG7HFTRPe9XzpevJyTcmbPP9lz/eb8Rr8V9jxr9C2o4RAbA64m+V3tEAMebViZely5ejNUASNuF2RsCVw0BrtdXrWPryBAwBAwBQ8AQMAQMAUPAEDAEDAFDwBAwBK4eAko3XL1+raeNg4Aw9H7aTV1zqb+41tE9cmSv9mmoZjTnkgy/cwUMSccfMhT6DS9tTQDm/McYAwzOMQU5xuiSkSGjRH+0D8erIyIDwRxw+tfWocT+GbHAFjUwlCEjwxYqx+wDBkybNy3F+o92gKr7gT3wYdVw2scY/ipOEKvuU6b/FWf4ccHx/q0jYCmnt3NQNT3t+W+WUSfDSPwo8/RST5nji82P+NFfTCajS3+U6S/WnvaU2b7Z+42MLKv4l8saAVStao5/paLPF46XzyeeXzLSsZx/+g/s/3/23uBJkuM4883KrJoecElCAkVCB/FErnTk9b23XF10llGU/p5n9g7vH4LNgaaTjMclpZEwoplIrckgAyAKBGwI04LgdHVVZr4cviWV/vNGfR0dWdXV3d9c0J7u4eHxZWRkIT8Pj5TCFBns0vnI/iiv8HMERemnmhMRnyg10+sIKQBdtFjxFCAGAFnNBzV+ZvBxvjEhYL3GgoN4HppIBp97+lWGFvF4/vz5B7xWKPMHYWFzmxuB2yPwuJ7+2+PklkbACBgBI2AEjIARMAJGwAgYASNgBO41AlVs7L0euYO/KwT4xbNoDvKL65//xV/Fj1iK0uOomSFA/bHldApAZBDIwDCcYsYfDvjFm1WZySikqsykTEgRoT+KIEipnmoekxIiIxRlxk9GkeNZwT8ZmBxf7I8ZD4yXGQFpgOIC/fVg2Dm+bC86qFUjnuSucD4oxp/+j83w8/lI/XM6kMJHg7YFYygykpjRAndJ5HxQjCFvD28n9anDI19QjHxp9/SXGNJCh6Xtub6o7vo+MvjDEN8PmfGPNWSo5/1V/Su9ml/pfSEcMiNEmEs1GX7ZAAaJ4YeeYo+iAOuiXzflNS7Yf5KxvnD+J/sTX2hR80F1Xzs/yPir/qjn74tnz579HDYxxaZp4gPbNPz9i+ZJjAvAlHSSLHzBCNwSgfg/T7d04mZGwAgYASNgBIyAETACRsAIGAEjYASMwHkjAP7ivIN1dPcCAc4pfmQCBdY8xaj4zfxirr+4uPjKXP7bv3/x07n8ZJ0527k+MXAiA0AxNtyD2XTxA+9wxQ+482imz8NgeJomMjjDQPhie0o9GCLqldzv4wdmMqD8Aq7wUf1V68EgkNGv9c+MAVWDgBkB7D/VPBAf9GlPf6Wyul9k4FlDQY1PMfKl8Zbak+Hh/KU/pSceIxh+tYe/SQxc2fNMRp/xk4G9a8ae8SmZjJ4abymDWcrQq3jV86Pa831BRl4y/NizP/R436AmBvfoq/g4n5I91tu0HlIPB7zfUBeLtQy/7JALABbE9Tr+3MHrs4E6dcei+8NqE34wbNr4AyDdH+DN54O/d7I+xs/1UN0v6pmxRH2L9VNlALQAiP643hNgjqfUnv72ff/J/NrX3nrru3N5u91+Npenv7eQLyHHBzhnDIT5MLWNP9DgzKIRKEGg7NdIiWfbGgEjYASMgBEwAkbACBgBI2AEjIARMAJngwDZ1rMJzIE8GgT4BbRsTvb7z+dIDeunb8zltmGZ3rl2+hsMXSMyAtA6iz3D55YwNGENAFQFb67iB18y/B33FMO9Evs+MgC5Knvsn/7I2CXGnA1YMwB6+oP6GvFwfGQAmMGg9qgCnWvmS/xAP4KRIUOWv7imHsIYW1CEtRkBGl/gif5DcJOgGH9VpZ/+SuUODJFqz/nAYyzIkAGNqSo4EpiwfpChUvHwfpDhu2+Mvhpvrb6W0SeDT/ypV/GS0ac9Hx/F8HPP/n4fX4/UM16Oh/FQ5nyjPslcv7nepQZ1F47O8KvwwPjTXDH+exgwY2AXCf7mYt3jFXH4/cB4Ti2T8T91/+wvre80qJS3l5dhz//E+JPhr+zBzY3A6RDAYnO6jt2TETACRsAIGAEjYASMgBEwAkbACBgBI3A6BEhXnq5n92QEFkDg81+/+nTu5s3N5stzmQxf0L0WBONPRoWMS/KHGgCpBmxqEC8MYPzbJ2AAriID2baRgU4lBaL7JHUdOc5owpoAUTudUVBIUZYy2PSfGHwERHuor4n38PjZnnJEf0oQ4Ba9Qnzon9GlL7akGOGgFG80Twy/IMTYPLVXh3SswKgnh+JCuv8oCUKGn+4Uw5/t0x0JJlw/gnISyMBWThe6nzIWYnyKsU4OcEFMt5xAhPZK5PpK/KhX/pSe/krxIR7HZvjTeBgAJhDnV2pfeqGQ8ef8U90pxn9EEXVlr/qr1nNBxKZ/Mv7sb9rjHy4NUWyY4cSaDovf3xCNBdYM+PWvfv3PhajEFB7dGDNAN7CFEbgtAvHXwW29uJ0RMAJGwAgYASNgBIyAETACRsAIGAEjcNYIOAPgrG/PgwiOXzQjhZ2HyC+m4RQA7rm6vLx8b+7izTe/+s25fN/+Tow/BsAaAO0TPMLYM4rmSWQNAJUR0IHhYIZAYmDRI/Vk9KlH82sYfFqcl8zxcLwqWsmAgvGjv/SFl4whGjBjoEVGA4tio3kSyaBwPKkBLhA/ZvQoRp9V+kGI55oO7B8NSuNXDJ24fYimXFSMNqcD46G+PILDLRgfGWMy9Ie9NQ39KXvq2Z79U889+ax5wSr9tB9J+TKgu5aPzPir4e2QobdZ4X2nHCysJ6PPPf6l3fU4JZ4lTThfmBFQ2l+tPZ/PWn/3rf12t/2RiJm/X4W51Ubg7hBIvw/vLhT3bASMgBEwAkbACBgBI2AEjIARMAJGwAgcC4G7/Zx6rFHZ70NCgF9Un8wH98tPP/3Xufz229/407lc+jcZxV5tqueef9Fh+yR+c+t3ogHUm6fxkd1dRnhWq01oMY6HOyDjv8M505t1TNgAISMZeW7pXg2oaRCitaAQIANNhlK1T5u0QfGS8Zf+aAAKOTH4sE/62j378J9O+YCejBbxhXmjMiCGJj7fgKMB3EmmPftXMv0re+pVe7UHme1Lx0OGnfEpmf0zXj4v7I+MK+1ZlZ+nEqj+eMPpn+NL8xGAJj0dlMqFjL9yvx/jer9esapJ9MA9/nfN+MfomoaM/5ovxCa+n9leycwI2Gy4nkRZ+avV8/ngelnrn+1bpkDQ4MQyM3T+5oc/jBO6Ph7kgOAFMiU11XdhD0bgegROu5pcH4OvGgEjYASMgBEwAkbACBgBI2AEjIARMAJHRqDuc+WRg7P7B4kAv3hWzcGXLz/+SUTpT6KoJFDUrOKsmlO/2sdvam3IV2ia3e6KTYrk/TZ+gM4ZAYcZf9UZGf/SGgHKP/WJAaZBoVy6x77QfTJn/OyfcnJQeYEMIBkLxfhXdl/fXDD+3MPfxccr7eHv2sgwjiBUiBcZfTL4LQiYUTGksXsSvhIvMtiygTAgIy3Mi9XaP2/Y4S6UPzLmZCiVnnv0G5xrv0cGFOOhfz5vfN7V+kA0aM/+OH+pT6dYsIMjy7wfG7wA+yG+/zrojxyedE9Gf4/fB0rf7LEAoEfWEIC6IeNPPHn/VY0R+ucpNSxB0WJBLGX8lX2r1k8GLGQ+L8K8Wv3XP/jBj6ud2IEROBMEyt7OZxK0wzACRsAIGAEjYASMgBEwAkbACBgBI2AEyhCoYl/LurK1EVgegU9+8YuP5l73oGCenPgT8bg+vGWrG+Mj16/iHv75WK77u13Rf8wI6Nq4Z58+eIoA9ZRZI2AAQ9aiRgDbr8DAUmyGw4wJ/SmZtxvTQTV/+HrsIS6mqBVCoLCJP+8P3ZUy/qo9GTMQXBO/f/gbOPXxaWuOAB+fb47wsExG+LB11pJxzBalV5jwdbg942c8Sk/70j36itE/HP3r+RDvH4+JJ2OrngfO3zTh+DyLABkf4xHNpZoMMBn/AYx62xx+/62wp541AqhXDD31akDKXumTf5GgN3Tx9wHxTPcL861pDr//Gc86pVTRok5uF/bPU2VUdMpePX/K/6SPKS26weEJr9vbwggshsDhXz+LdWNHRsAIGAEjYASMgBEwAkbACBgBI2AEjMBdIhA/N95lJO77sSLAL6JFc/L58+cfzIH7/vf/kiTdXD2dEx0ZKWxBDrbXCcMqfvBtx7jJn3r6KGX8+6v4ja57Ehkm+t8P0X7dRvvSUwLUqQDsv1QmI0vGrNTfXduXMgpkyBePH4x88q8YRNU+OTx8geNtkQFChq8HA9i18fnlHn8ywGTQ8hbd6O9w9KfXcjy1EZBBp79j94fbPVHmcX1i/6UMPhlyjpfzj+NXDDn1bF8qM57S9YP9VceH+9GIPdt8vnj/uMefjD8ZfGYMXKzi87nD253rxa6J9hskmO1wCsEGpxQQz8XlzeGfNzwFoI2v8xQOa5SkjLt0SsHhDAHeT8opgCNfYP+1z0dpuDwV5NmzZz8XPuIEnA6OEPZUxwVxWiFpYNkILIWAWF6W6sZ+jIARMAJGwAgYASNgBIyAETACRsAIGIG7RADfU+8yFPf9QBHgHONHJ36Sfgoc+Mn8Yq6/uLj4ylz+h39492dzWZ0ruwYFnRmnwx9gFeM/XMUPuu2TOPzSUwFURsBwdTje0hoAcyxv8zcZiQ6Mcq8YaHaaKEQalMlk4Mpaa+sRDMwKe2CX7p97mHWEsCi9H7ifPHUg7WFGd5LRQU2LFgxeAzzhfgonLj9klGh/1zIZ1NJ4MuMND2R4oV66f04PMtSMl/O39vlgfxhuEoc4XRocKpHsSy+kPdxwwOeB8xfm0ykYMWDa903UdyAUqV/Dn8oASPFUXuDzyfl4ged9B4J1g4yhvomb7rtmEyJU+mB8BOGNLhLG23XMKOy6+PNojZTFDWSGOK7jeJ/g9w7tmy72z/vRISWBevpjDQCeAsDfZ8of8WB/as8/7fm8UX/56vLD+bW3//DtP5vL2+32s7k8/b2FfAmZGQFxAjQ4dsYZAIDP4pIIxP8bWdKzfRkBI2AEjIARMAJGwAgYASNgBIyAETACZ4MA2dWzCcyBPFoE+IW0aI7++nL76Ry5L73xxu/P5RZfzMn4z22v+5uMv6oBoBj/0lMBWAOAGQEr7IHkGHhKwNIZAStQaB0ZWwTEjACok8jP5cmg8AIZgFrGkd2T8ae+ViZjWuuPe6iTv8QQRoaRDGRqX3hh6ONyEPNpmoanYgxgPFsynsgoKAxHm3PPNBl3pUcPZEChnm7X4YwfqksZceWf8XD+sL16vkrjS/1XXiDjT/zJUFLfoYo7x0OZGQEJH8wXReASb2Uv4VLzFw6ID9RTDZ74BJPR36JGD/WK8Wd/SmZGgLIv1a9Rs2TfhQTG5hUyFni/NiCEeUqKimfN+8dTAsD4dzhliDVWVH9L60sZ/aX73+127819Tow/Gf65+jZ/xwfiNh7cxgjcEgFnANwSODczAkbACBgBI2AEjIARMAJGwAgYASNwnxAoYlfv08Ac6+NE4Opy+/585MwAmOtu8jcZTcX4K5+bTdxjJ2sA7PGNbh0/GDMjoNnHPYM89aA040GNh4z/SApt4SrLZEju+6kBZOwU3ifXk/FfOIDEeAr/xKsnxY32KWMEDCTMp3yB+LytEgMX9WzfiAycFaqakxHN/uIVjp+Me7Sul9iful9kuGsjID6KYWZ/qr3S0x8Ze2yJnm5HXJ/ZnjLt6Z/2pfeb+SF8n6U9/6lDXOD4kKFAPHnKA7w1rMqPBL3paYp7+BkvTwGg/26Me+DJyNOe8n6I71Pu2X/VF+pXkUDeYQJtdpg/FzFjgPFRVnvkab+0XLrnf+n+mRFXm0Ew1QD4qDDGmLJW2NjmRuCUCKhfM6eMxX0ZASNgBIyAETACRsAIGAEjYASMgBEwAkdCwBkARwLWbr8QAXzi5qa01I5fVMMnce7J2u73/wIP34FcJCYGbHUV2q/GEE4z4gv/sMM3tjEOp8MewH4V9Q0Y/4YZASEaLeQaCIgPLkYwoGT8Yd6sBePPqv+sAaD07O++ZQSQQU1V7TlAyIPAF+ZaBMNPhlA7qLMYsWdfekO8iXEUDvg8Z/OUMwATpYe5yFCAdRJVvJxPyQEusOYF1A39kaGm/dKyup8oMTIlXMTXCTMEKLM99RwP9T1qUqg9/2T0S/FsE+Mea27wFADGXyrvMV/JuEt/iBePa0pgYI0O+ud8XY3xJ+uI9yXfBy0ybuhfycwY2CGlgnrlj/oNMpJWhYw//VFu1zFDYVhF/Dbc8w89/SmZVf6lPTIglL3S1zL+9P/uP777z7wGGT/YoLVoBM4YgcO//s84cIdmBIyAETACRsAIGAEjYASMgBEwAkbACNwcgfg58ObtbGkE7goBfnENm+p/+fLlr+aBvf31P5iL1X+zBgCPaQVB0LSbyFANMYGg4SkBzdXhR7IHI88Bcc8/9ZSZEcBTARTjT8Zlj6r/zAgg4894lJ72x5Y7MFo99ryW9k9GqxEJMGRkOd/IeHAPpIqvlvHneE5dk4F7hgc8gGTQ1XhpnyhLBSj0yR/0Ssz3P7ZQjDIZaPpT7WNvy0uK8U89Csaf9vRPRr9Wr/CjnveD8fJ5GrDedDjVgu2VnOdjfD+t0Z/yp/b4I6GgWQvKieMn49+McQ89T7VpRUaR2tNPhn+DhAu15594sWYA9fT3JRpA3nDBg75WXCFDscUpAXx+uOef/bddzECgXj1/tK+VOb/oj+/Tf//FL/6DNpUyU8j4RMQHsrIzNzcChxDg5Dtka50RMAJGwAgYASNgBIyAETACRsAIGAEjcE8ROEw33tNBOex7hQC/iFbNyZcvP/5JHP2fRHFhaUBNgFL3w9XhD76pJgCqlLO/Fuecr7DHsBcMEhl7Qag0ivFnfMeWmZFQy0jXMv7jGKf3ahUZEaUnY3F4tjQNGYxj401GeYrg2F1G/2Asc+8KseguSdzEnAxwgZQn1ErMeB5uQUaZjPPh1tPdAn60VxlHtC+WO3AQYPiTP9oj46N0j3/yjwtkPBVjSTxL7we6n/KDMEAaKJnzEfNZZQRwPOwO7qan/3C8A95fqgZA2uOP95ti/BkvGXnF+Kv21JPRZ3+0/9KX4s+dEcfMq5oApVX/N/H1w3CaUsa/dM8/O+TzRX3KaBMZBWxfKrO/v/7BD35c6sP2RuC+IIC3730J23EaASNgBIyAETACRsAIGAEjYASMgBEwAiUIxM+PJS1tawTOEAF+sf3u//XfEGX8BM49w6Vb7HJNgNgdGaMGVXcb7LmLracd4qh6zC3jvWLM4LADQ6syAtA8iSljIFncrwuJcSeDVjgcMv5srvSljDD9H1suruJ/5IBWrJEBhpuMbGZACwPE/OjxfJHhXPp+kqHvMF41GrZX9lLP9YiMPfXS4WEDrq9kFFNGAAlqxMd8kRb2rPrPUwEWLmqeB0/KPVvEK8Je18SIiBBfTP/XKSWhf2YE8HkIxpOwGmLG1AjGn/alcinjX+pfMf70pxj/9fph/0RXz29phgPxLZWZQffOO+98Ch+o4tTECQvjSWTNqmwRr2DFiUpLRmBJBJwBsCSa9mUEjIARMAJGwAgYASNgBIyAETACRuBMEXjYnxfPFHSHdRABfjGtmqOpKj4YMlbBZ2RkSEoZwxWqyHOPeoNzd/uBw2dEy8odNslHvmfZvu7EG1M6mPJxJ0F9cae1DDEzGL64p5tpGM+5Mf4cxYg9xtRTls83KE4y/Kx5wHPbWcUdCQJNA8aT8VFWjL3S05+UFWMPBl36g8Ee7deqP+rRPq1fsB9gT4Yf4aUaCcwgISPO9pTZnnvs1XyM/PrkHYx7OrWCegYkZC6frPrPeHIGCi1ih2m9Av/J92Xpnn9W8Y+9Z2m/34WL6/UmyEofjG8gqD3+dLE0I672/LP/WnnoI2EuTwnA+sv5Qsa+Nr79Lt7/6fl+Veiz9AdcWrIK+7O5Ebg1As4AuDV0bmgEjIARMAJGwAgYASNgBIyAETACRuD+IHD48+z9GYcjvT8IcM7xI1TcpN80TzE0ZgRczPUXFxdfmcs/+rt3/2kuP1nHXZrMAEiMx7zxNX8zI2CNqu97VH3nqQGrfRw+90DuRVXla0IKl4Zd4QdpMmaV/YdgTiAgoaFp1A0VGQFkwE8whDvtYkgUNcJBRstdZwSAIEKwWiQjy+eZjGrOANB9BAvB+C/O4IfOTy9w9ZEMvwixOGMAjH+D9a2BnhkBrCHA8GhPvZKZAUCGU7aPr7OG8zVlCMAhMw6glsunbs/XfeyB4yXjH62zxIyAVR8Z3LGLDD71pQw/MwAYEf1RT5kZAGrPv8oAaNf8+RR77FBToDYDQMUbe59ex6KKPzNq1HhLMwA43xjf5avLD+fX3v7Dt/9sLm+328/m8vT3FvIlZC6BMQViygGDPXJgoLVoBBZEIP7fx4KO7coIGAEjYASMgBEwAkbACBgBI2AEjIAROB8EyKaeT2SOxAj8/wjwC2rRnB32Vz8PQK6f/lGQFxb6FuGBoiTjr7pfC0aWGQK1jD/jIcMynDgjgIx+KUPUCIaf46WsGAPaU75vGQSs0p0yAlBDgzUuGugTo06AhEyGsdafZPwZD/dQl/IzYPxLGf4R68nqxDVCqhbfCUusho1i8JWeGQTKvjR+xfhzetBeZQSQ8ac/JfN5IOPfIeMpPb/ogM871JMYGfzUPxqo9bJDBlGLKv9c7zd4/tT7p5bxx3CapRl/+leyYsBV+3PTqxoAZPT5/lTzq3a8u93uvbmPifEnwz9Xv/6bSwz1lMn4U2/ZCJwMAWcAnAxqd2QEjIARMAJGwAgYASNgBIyAETACRuDuEOAH+ruLxD0bgSMgMO3pejl3+/TpshkAHfaQNqu4B3Ho4yO2BkOzA0PNc49LawJgi9/EYMxHX/63YlzKPda1IENU5+34rRVjQYbj2BGREeSey9T/IAgLMP6pfeUFMv5IqCn2Tn90oBhOMq5sn6r6g+FsBJ6ljL96vOPqk6Itpq9Uf7mHeKWUwY+tJ7oN660aH/ujv9KaAIrBH9OW3tRj1QXOXzKmdC4ZfjDsbE+Z/XesQcAGkHusF2Sg+L4bsGecGWlw33CPP/WK0ae9kq9AED+5CCWJVPM71w99PNa+bVlyadkQWQOA758R66Oa36X2ajTT78WPlI31RuChIMD196GMy+MwAkbACBgBI2AEjIARMAJGwAgYASNgBGYIqA/oM1P/aQSOggApxsNlbPOeq/DJnXu2tvv9v4Sox+E7QW5idyDkZRXk6Ov1sd7xkerEnt2NyAigf9YEYA2AZB/DyRkBYNQSI0aHJ5a55//YGQCKkVeMfik8S/tL584zIDAsA2Sa18pk1MkgKn0948/lJY6I/Udt0+Sq/ygCgD3+U5lrugiyYvi5x5/2PdYTPN6J0a9l7EPwk6D6oz1lMvhKTwafMqv4J5kd8BQA6iFzj//hu4vGNxDV86/mJ9fHlOGjarbgAVOM6w2GFEy45z8oJyE/PhFh7hnv1HjYwZHllu/Pwv5UFf2+j0XjVU2AYR/t1akAPAWA7wMy9BzewJoOyPCg/bnL7/7ju/8sYlx6SRXdWW0EjoeAMwCOh609GwEjYASMgBEwAkbACBgBI2AEjIAROBsE+EH/bAJzIEbghgjwi+yTebtf/ccv/2kuv/0Hb83F6r8HpgxUekwZAchQ4B5JZgQc5jvLg+Oey3OrCVA+osMtyMgxI4AyvbE99aWyZPQLHUpGBxkBaQ8xGW5QeGQgGZ5iNGm/tFzaP/eYpniAh8rISe1xgYw/1BPBHV/Ze2QE0H5pmYst/cfopnStbayJ0lzEc9kTow+HzBhQ9mieRTK2hRkBPEUidxCvqJoB0XqquV+4J5/t0/NKg0K5dj1Le/4xPr7PyPgz3L6LM6zr1YyMHgbcbzL4Sh+9ZWloXoWLbfNGkCnsUaSnXcdTF1q8/9mesmL8aX/XcmnGwdLxMuPlxYsXP1u4j5iS0TQkXZf+ybZw+Hb3kBHgZHzIY/XYjIARMAJGwAgYASNgBIyAETACRsAIPFoE4ufURwuDB35GCPCLadUc5Rfdb33r22c0VB1K18YPxCBok4NhFeFqxzKGJDnEhVNnBPRgjHAs9bSJFHuyEW8q4qDs0V4xYCojAO6SuDTDnzoovKAyBJI7MOD8okyGJ7XHhcyAxvuLLcto3UxF+uPzohhY2rNGQeqg8MIaD+weVdMV41/Y3Z2bp9UGjD8D3GPPMjMEGmYQrOMecfYXVz/2do3MjIBrTOaXWBNgrnv9dxunK9X3Xh7H+HpereL9UDUsyPifGyDMCFDxtes4AxXjr/zV6ktrANT214o9/6z6X9tfaXv1/maG1/Pnzz8o7QP2cUJAadEInBMC/L12TrE5FiNgBIyAETACRsAIGAEjYASMgBEwAkZgIQSKP5gv1K/dGIGTIMAvut/73vdiv/iC3aIqfzTWUmofCcmmxx5edUoAe+QewWEfKSdVE2CNJx5bEBtZRRsBtdhTCXUSVQ2BEYxcJ/z3lfcrBVh4QTEMzBBQ9oXdn725yihQGQLMCGBCyNI1OBSg3DOa7FETgYx/sn/oF8jgMyOAMvGgHusBljO2XlxWDL/KOCl9/juub6j50A+RgU/2AgEkpKQaBGT0G7y/6F5ltIzIGCrNCGCNgDh6RpPlWoZ/2McZl+Qm1rxYr2PNixxRvML3uYA7Np4k1gDo+MJPLU57gfdvvTmMT+nzwtGUvn/feeedT+HjCnJMgYHyFmL8AXcLB25iBG6LgDMAbouc2xkBI2AEjIARMAJGwAgYASNgBIyAEbhHCMSSo/cocId6bxFQc44fpfiR/ylGfgE5fKLfbDbfmOv/Yfo3l9sufoEmg08CZt72ur/ZnjZr7FHmOb+0VzKrCCv7YYwMBe1TRgANKuW+8hznFRjAVCOAe/zVDaR95fjYnHv8W9x/6tleyshgaeC/XUWCYRjV44ce6R9q9kd1qawyAkr36JfWDOhTkYmyEXBPKVuXnhLQg/HlKQDUs79zlzfN5yHEXfNfgkyBpwCkUwLY4MhyPpeer6sYgGI0mfHSNYJwFBQxGX6UpGioj9FqiTUAVIsO65OyL9Vv8H5RVf2VvrR/Mv7qVIDVRfz5sgZj33WH5xPjYwYA9SojoO3CIUpsPs2X+POMGRW1NQHov3T8KkOLz9/VVST4v/71r//JfNDb7fazuTz9vYV8CZk1APgAIycUExbOLBqBYyIQn+Zj9mTfRsAIGAEjYASMgBEwAkbACBgBI2AEjMCdIRDY0juLwh0/JgQiJTkdfVw4eH5h5RwO8vRFORzMu9vFD7AXZR/Ypy/gMVy1B3m1B+OOL/rqC7fKECBjMIAxSnsKVzHjgYzkGlWNl84I6HC7SzMCEuPPyYP7I08JYPtKWTH6Sl/Z/fQ0xW+6Axk36AmX6l8mTAj/qT3iIwPEeJghoJ4/tlcyGV1mBJDhV4wTny/VP/WK8Vd6+pOy2rMvHZQZkPFXDD9rlITFvqzrG1mrPf8NquDTKRlH6ilLxh8NmFFEfMj4r5ERtMexBvQ3dhHhVRPfnzwVAOFNNQUKX7B0IGQy/ju8Xxq8b1sQrmSwRXdSvcf7fr1+Q7Y5poFi/Nn30EdGfL1hwiVblMmsAcBTArielu7hZzTq+ZveJz9lG8tG4LEgEH8tPpZRe5xGwAgYASNgBIyAETACRsAIGAEjYAQeGQLx8+4jG7yH+/gQGJvxQ4z6jyAfFEsZx5FVgMF4HuxsUpZmCLRNZFy4RZQ1A3hqwH4Vl4Q2JgykcHvsyU8G6gKqpitzqU8Us2xRZLA4gw/GnMGQoVfDW7eRoRvGZb/xpniaw/5VvBxvqVyakVPqn/ZkqKivZfzpT8msAVCdEcCq+yoAoS/d4889/XTfrSJD2Y+H9yyn9mCAmeFB+xUY82lFpkmQyTiuUBV+bOKCul7F5zU4u0bgHmuuR3wasWW7wfAbZgQMaNCNcQvzgGM4FMN/7D3/ZPyZEcA9/jtkBCj7pTMErrmlVZfUnv8eKXzFGQFIIVEZWmowZPxr/an+lH53tftkbjPt+ece/7n69d/MQKWectkDztaWjcAREeD74ohd2bURMAJGwAgYASNgBIyAETACRsAIGAEjcFcIRLrvrqJwv0bgSAjwi+7lq8uX866ePn1alAEwb3vd36zyvxcM73U+Sq4xQ6C2ZgBKAKRQyHB2XfyGWJoRUFsT4NR7/LlHlgxcAoz3X2SAkGGnP6VfmvFn/8WyGK/yxz3/yv7UGQE7ZLBscM459Sp+6hXDr+ypP7XMPf7s/wKM/haMPn+glDL+7E8x/mnPfyz5QndSJuMvG8CAjD/UDdcjlICh+XRQfBzQHqeCrJkiAA8tjtVoUROA7weW+Ek1Seif6yX0bE98mBEwYLwXGB/tV29EfNqYcIJopoQKnCK06mPNnx0qHm2i++Sv9MKwjxkaKiNA+VenAKj2tfrSGisqI0vF8+8fffypsrHeCDxUBOKv94c6So/LCBgBI2AEjIARMAJGwAgYASNgBIzAI0eAH9gfORwe/hkgwD1Thzdd5j1Z8WBdDOh/ff75/5xf+r3f++p35vIq7Wku+2Rfy/irL/j84j+P/fXfzAignhkCrBnQrCOj0A/xG+EajCf9swo19cwQGLHntIF/WXMBDE9pRgAZfDJq1HM8xbJkuKJHDi9q715S8Q18niozAlLVdexRrkVEMcT0T8af+lqZe/rpT2UIUM/2dy0rxr80vg7zrQdDzVMekv+03B9+/Rx7z3+Kr/ACM2LYnDUAqE8ZAqiJQMafGQIDGHfWEKA9+6dMxl9lBLA9GX/q11dpAtCkSh6fxPcpe1Pv76rO70HjWka/dIjvv//eP4o2pXv+hTurjcD5IBBXo/OJy5EYASNgBIyAETACRsAIGAEjYASMgBEwAgsi4AyABcG0q7NAgF9sQ5no3eXnPzsU5YhzhPmFnm0lw1LLeKJDZgiojAA0lyIzAnowOCP2ONMhMwRuyAkyAABAAElEQVTIECkGt4V/bKFsRuxBZf+DqKpNhj+3xzdRwdg3vL+wVwx56p8DpsEjl1swkA3wTjUDoE/3C3iSIWZGgNqjyj3/zBCgHt0XiypDgA5pf+wMgWP/wHiyjs/r1T4mkDEjgHiUyt0qvl64x59ybZV/FZ96/6j2Sq8yBBSDT8Zf9UdGn/bMAKCs2tMfZZUhkKrYwwFrAkCdxHatfmGkJgcvpN8Dm5gQyXyWEaf+HHT+AJUvXrw4+HvwFkOOKZRT1Q34iAsUlBaNwCkR4OQ8Zd/uywgYASNgBIyAETACRsAIGAEjYASMgBE4EQLH/kB/omG4mweMAL+oVs1ZfvH91re+HaDjnvO2K/tCz/ZIKAh9LSGUZgSM63gO9WofqxarmDagtHc46D1lCAyRMetQZrsfwEmIPd0rnEOtMgJ4zjCONU7DTfaKkQfDDHhSSQLqUwAP7ALHW1sTIDH8Ai9mDGC6itZZrfaoHpvxzxHFK2T0FeNfq4+9Nw0X57HlFbaI8grrBdtTv0WNkgt018flZyqSHwk4ZhDQPkY3VX1vsH5iPepkGX56jDIZbDLc0bpp+L45dkYA+6dcyvizPWWOn/jQXsmqPfuT/pAhR/u28AcAa/SwJgBrDPHUoQaMfzvGB4CnaHB97vGC7FijhwO85/Lz588/qBxCBLjSmZsbgVMi4AyAU6LtvoyAETACRsAIGAEjYASMgBEwAkbACNwRAvhefkdRuFsjcCIE+MX3+9//y4M9kzHkF3M2JgOzFwxA+oJPh5C5x48ZADBPYinjTwdk/KmnrBhG2rNGQNfGBJA+EnipORl8Gih9shcJIJwfpe1r7dm+VlbjqfWv9uCXMvwqngHnnL/mcJf8xz39zACgTPslY3nti88b/R9bT3TJ2DMeMvzUX7TxgeepJB314OM6/sLZR85j7OMDrtZ3xlcrk3FWDHVtf8duz5oAKiNA6vG8xrs3Pc3IMFHz7djjT/67GPEOGSsryCTcmREwcILG6duQ8R+wx3+DGjlj2qKeRlB0gfOX87vI2QmMnz179nN0cwU5/gCB8hYil8hbuHATI7AMAnF1WsanvRgBI2AEjIARMAJGwAgYASNgBIyAETACZ4YAvh+eWXQO5zEgoOYgP1Jh03jzFCCR8wllcDebzTfm9v8w/ZvLbRf3eJLR5wf4edvr/n7CquXXGc2ulTL6zAiYubr2TzIKNLoCY7pClXtVA2AFym3Eptoe37+5hz8xwKgJoDIA6I/jO7ZMBr10viwdH+Ohf8an7BWDT/+lcrr/pQ6EPfdMj5jfbM5TAKhXsmL8T50hoOKt1ZOxT3v0wdhTTwaXVfz3oEjX2LOc4sepIs1wmNBrsac/+cMFVZOEpwCQEVWMKd8/nL8Ip6E99aeWFcN/6njYHzMWkh41XqhnzRuWDOKpASPm70UXf87QH9+37J81AnpkRFDPDDjKas8/7RlPi4wHznfiwfbdmj/fogWfT1WThfb7vv9k7vFrb7313bm83W4/m8vT31vIl5CRc5SKgMQUpqVT0BCMRSNQggD/56qkrW2NgBEwAkbACBgBI2AEjIARMAJGwAgYgXuCwOHPbfdkEA7zXiMATlhsms9D5RfYg3N6+oL9au5it4sfaC/iB/m56Y3+JgPDGgBqzz8ZfX7R36CKPoOSVYLZADIzFna4O6oGABl/uG/WYPR3Yg8i9yiCQJmqYMf7R0aO/R87Q4CMOvu/a7k6PjJigkEvHS8ZJt7fUn+KMV1hPPsRE760Q9iT4Ye6URkCtK+V1SkBtf6v9vF5XIOBT6d+YI+36j8x/vDfcj5CjtGp3rSeVf/3Yj3THqOFmr9838TWp5fOnfEnIjJezJ8VMgTp79gyfw+MyFhhOuXA9QzvS663pfGT8S9tf2r7/W738tR9uj8jcK4IOAPgXO+M4zICRsAIGAEjYASMgBEwAkbACBgBI7AgAgfZ0gX7sSsjcJYIXG634RzYi6cX35wHukoMFb+xz63136UMvWL82SMzDNjfClvU9quY8qAYfLXHn/GkPf9gyBhPU5gAQgZDMcYqQ4DxHztjgP0tLZPx5x5/6ov7B4N+7BoBKj7FmKr21I9gzNSeU7anTMafGQLUs32trE4BoH/uyWfVdTLye+xB5p57uYe/jTVY2J7xcc91j/VFtae/Unk/Rg6Fe/6VP+6RlvbVD6zqoU7PPfWSYa/rTrdG0ZgB6xUzyuiQ8ce3Ja2bpgcjP+L3Q5wtuT2v8P1c+uuDe+D5vmR/p5ZVPIy/Nr5f//rVe3Mf055/7vGfq1//zQxT6ikvnWRE/5aNwGIIlK5Hi3VsR0bACBgBI2AEjIARMAJGwAgYASNgBIzA6RBwBsDpsHZPt0OAX1TVR3h+sQ2nAPCL7+XlZfgi/OZXvxwyABowSopBJQPJPZpk6AkJTwFgTQDaU06M/35HkyBzT34Dhol7/lWGQHB+A2FseDt5u2/gZGZCRkFlBMyaXvsnMwYeWkbAtYOuuDjgFAm6asWpGLX3i/2Vyh0YOw6nbnY2zRoZLmT8qWfGQa19acZBB0Z+D3yIL+NvsEd/zSr80NMf23OPPzOM8v2LnCn3RNcyjFwfmpQBwddRGuHBC3x/JOM9TjVYcz1NLRa9QIZ8Uec3cIYEndwCjD8NUnsw+Fyv5PwRPxA6VMln1f8R8XJ+cj6whgnXC473vstqfMSL9h9//MlHAoO6B1Y4t9oInBMCzgA4p7vhWIyAETACRsAIGAEjYASMgBEwAkbACBwJAWcAHAlYu70fCGx3u4+XjJRf6OmbDD0zAhTjX58hEBmipRl9jrdWJsNWy8DXZggwntLx1cav+mN8x+6P8XD+MyNGZghgU25pRoDqn/FSJqPWNHWcP2sIsL/EmMNAMf6JkQfDTv/KH7pvuhYM8xDXjw6/ILZgQJ+gCvkgGP/U3xj7a8iQgsLtoWeNAMV48PkhHpQ7ZExRnzICUFWe9py/1Cf5xIx/6v++XxCMvxpeyggAw9+K+U7/fD9xPnB9IsNNBpz+2N9Dl7n+fvjh+z9/6GP2+IzATRFQ78Ob+rGdETACRsAIGAEjYASMgBEwAkbACBgBI3DGCOD7/RlH6tCMwO0Q4J6uJ3M3u8vPfzaX1d+K8FHtqWdGAL/wdwPDp4cy+dSMPxm4HnugyVCTsSADTIaO7YkG/VFPmfbsX+npj3Jp/LSnP8oKD9pT5vwGoUtzKZPBYkaAclCLd23/jI97zKnf1wIGh2TwoU41BcgIkgFjDQDqycBvh8gR8AdDj4yAC2QMUM89/KzK3oDx5x5/jr8Dg6vuT+nzxP4a9Mf4uN6l9mCIG1apx/zh/E3+cIHPVyiAM9nu+ICjfalYW/UfCRwNEoCalDGEjBIcwpDCXyGBh/Yr4I3XU/JXemFARg5rAJT6o70ZfyJyWH7x4kXR773D3n6jRYoUikbVppDdIACbGIHbIhDf7rf14nZGwAgYASNgBIyAETACRsAIGAEjYASMwFkjwA/6Zx2sgzMCEwL84lo1h/lF+Fvf+nYAGQRNYihIqJCBIYNDhj90NgmljH+PqtNrZAwQLPZ332UyeqUMeCnDzIyAWvxq42f/q1V8HMaxLoOE85v9USahRr2SyfixCrdqr/R8Hvm8jmKPtvJPBpgMsWpfqyejT39KPzSb0GSNGgjMEEiMPTICgrNJIOOfGPsxtkAFgKi8CwkMdAoBGQJJX3iB85Pzl+6oP3wGDFtP9wcPMP2xRekpAGT8kz8w9g1OnSCDz/aUaa8yArj+cH2OZ0qwtyzzlJt+iB5GjG+NY+eHVXweu7N7IPKYS64wY6mk7W1snz9//sFt2s3a1L1QZ478pxG4awScAXDXd8D9GwEjYASMgBEwAkbACBgBI2AEjIAROAECkS46QYfuwggsjAC/yBbNaX4R/t73vhfCa7Fnc4Uv9g02DSrGhAw/GXzKtN+BQQjBXiPsV6QMCNc1jY54KTGkwI8MfilDXxs6+yMD1Pcxp4L2tRkC7C+Ph/eTFjE+ZgTQmgk1xF/HEz22eD5AKE4ZNJEBI+MYvWVpabwZD25vCqAWn+RQXcAe4kZUFU/PFxn1wj36eDwnQvbw/GP/aXiKQU8NcIEMe60/uJci+5cNljVQzwvnc7OP60FTeWpA7j9NsKIBlzL0dJ722DdxfvZMGERNiy5P8NAFRod8mGD6G2FFC8xPrh/0wIyhVWofUyQGAHjXjF6LUzim9YoQxhcAAVhY5ikNz5494ykAV+gSDwy0Wow3iCksur0tjMDJELjr9eJkA3VHRsAIGAEjYASMgBEwAkbACBgBI2AEHjMCJ/0a95iB9thvjQDnKD9axU/+TfMUPTEjIBRG3mw235jb/8P0by7zHF9W8SVDmhiYubPpb1UDYNPGD+Zk/Ev3KPcoYnDqUwCGVBQ3AqLGQ8aklGFn+9j78aXSeKcqEMcP6mAPdQSIwpsZAQyFDKOqAVCOL3uMMuOnf+pjay2R4eMeeJ6SoT2WWXDPbdtglzgYftYMYPuy3idrMJqyvWLcS/3JDoUB4pEZD3DHUxCgfnAiaz40qFHToIZNKQC9WlCUQ9xPmnfIWOL7Ptmv48+VVdrDH9d3+uuwyZ8ZT5TX6/jzhs8nTwlgvPRHucOxDNQzQ1JlAExwBoA6xJ/iw/3h+JT9vu8/mdt87a23vjuXt9vtZ3N5+nsL+RIyUyj5wnQGAACzeL4I8H+mzjdSR2YEjIARMAJGwAgYASNgBIyAETACRsAI3BqB+Pnw1m7c0AicDQL8Qntwjk9ftF/NI7+82n86l7/0xub35zII9aYDY88qxyojIO3xn072nv9jRsAVvjevUbX8agwf2OeufvP3qov+VUaAYvBb7nlEj2T4mQFBmfZwN+0hj98sydDS/vzkyAAxvjbNp3g/s56EQ/Rfaq8zEDABMQAyWmTMQahN53xHB+l5OTHDy+44P3msM8dHhlsx+kof0ZkkUROADHNizLBeqPud2+OGMUAwdlQnmfa8AanBHV9AfCmjI03w+Dri05oY0zse3oD5oeKjvWT0axl/Tr/K+aPWK96OMRLYk8SA2KJM5nqC6ZacsSZNXP2n5QLvS5URkDqovEDGn+9vui+NT9lvLy+5559dWjYCjxaB+Gv60cLggRsBI2AEjIARMAJGwAgYASNgBIyAEXjYCEQ68GGP1aMzAhKBq8vt+3OjLz29CBkAJBwascd97uu6v1n1nzYDqjjzgd2z6q6iDNABMwLIqEf+eaphAIYI7qZ8AH5TjJwXGf7MsEaPpQxNbL28RHwkowEGJmeIRAZpEKc8ZD05nzjmUnueCqAY4tjbEaQ0v+N8qu2RNQc4P+mfDJ3U43lRjBX9sYo19cwIIDotJhyfp+Sv8ALx4Hoh3RWuV9IfDbhgH7s/7HHvUdOjY1V6xruwnBj6hf0zQ4DzT+35Z5X+DniVhpsyVgodpOeD64/wN6KGBqefaJ7Ute2TwzO/wPWudL3k8Lbbq5ABMO355x5/NokpO9RmOU35bOIrRuA8EeCv9fOM0lEZASNgBIyAETACRsAIGAEjYASMgBEwAlUIkFCscubGRuAECPCL62EKtGn4RTecAsAvwturq/84NIYRDDcZYVbxJcPYtjF8xYAnhh/B0T9rCuxXEZ7EcMCfYrRBKHLLM7zlUw/IsFJODsQFxsv7ocZL92Q0qWd/1CuZe/Jpr/S0X17mN+GYocCMF+LNeIi/xBcUMmsE0L+S+Xyo+abikxNeBESGi+aMT60PbE+Z/fHu8v6wPWWJDxvUymTsSylRtkc8ajyl+MD9xGcfrplBhp6MOv2dWmZ8qX/s6U+MPvRs363iT9BxxfUmtliJDKlo/Xq1iv4G7OFnyQa2VzIZap5aUjt/VP9L61n1f2n/p/b38ceffCT65O9DYW61EXg4CPD3wMMZmUdiBIyAETACRsAIGAEjYASMgBEwAkbACPwOgfj59XeX/YcReLAI8Ivvk/lIf/npp/86l9/+xh/86Vxeoeq9YoRTVfO5s1v8nar+g9FgTQEQqg0zBGi/HiNjxQwCjlcxwCTgFKNJBpQQkVEhg1caH/0vLXMPPhl+pS+NR/mr18cMltL4aE8GjvOJMu835xfnD+3Zv2L0VXsy7PTPeKhXcm17+meNjqUZAOLF55XxFMu84XDA/qEuFkv99ci4YgaAqgkgGffiEdQ1UBkJXA02OIUmEfbI4CDjv0L7FD1OSUl6XoC/VcowiBlybE6Z83nVxQwD2h9bXvr3xbHjVf6ZUVFb0+HHf/fjf1J9FurjDySmxPGYmELnNjcCp0Rg6ff/KWN3X0bACBgBI2AEjIARMAJGwAgYASNgBIzADRFwBsANgbLZ40Dg5cuPfxJH+l+DqGoAZAY6NJ/OsScnH/VKSjUBBCNGf2T8yXCR8Wf7UhmET2pOhjcZ4ELOIIgcFMeD5icXM+Mf4+V8qQ2Q/dFfqV7dH8av7BkP7SnznGt5f8XzwPaKwWe8SlaMPcdHf7V4Kn+ML2UEqAeWHVTKvB+V7lLz2vtLRjJ1gAsczx41YwgvMwLgTtYQoL2SJaOPUysGVMFvoWd/tG8w4MSgI4ON4ogL6e0p4mF8KcMgOWSLwzKr/jMfoCy/4HXNnMqAEC7nL9cXymh+5+KIokMqI4D6T37xC1UD4M7H6ACMwF0h4AyAu0Le/RoBI2AEjIARMAJGwAgYASNgBIyAETghAs4AOCHY7uooCHBPVtWc5hfjPTYprhvsalz2g30xQMwoYNVzOiRDRf2p5XoGgt8wI8NeOh4yMEvjpcar9uir8dS2p3/GSwabMttTZlV/tifjz/ZSJoMmMgLIkNF/LYNMf5SJL/WUaU/8aE8925dmBBTjIfBnvIyHGT/Us32pPdtTHlBFvsE59Wr+0B9rAlB/bDnVGGCVfjL+WF6HARf4vGEAtVXlOd+YEdAgXnRfLHL9L3XA54vt6V/Zs/2p5buOj4y+Gj8zBp4/f/6BaiP0rBklzK02AvcHAazm9ydwR2oEjIARMAJGwAgYASNgBIyAETACRsAI3ByBKrb05t3Y0gicDAF+sS2a4/xi/Od/8VcHA1/6C7liuA4Gc42ylMEmY3iNy3CJ4y9tH5zdQiBepeNVXZKxWdo/+1d79GlPubY9/VHm/aZe3n/s2VV4Kj37V1X9k724QIaXjCSbk4Huey5HsYXEK5o3pfZoLtu3oARYI4D+lMznU9lTX9q+1J79UR5TFfm6hLP9GAHm+rJ0hgD3/Kc9+hgw7z/UUw0bXuGud+orZTL8hRkl7J14F68vdLiwzPWD7hl/0ucbRJMque3SBKjyt3RjZpA9e/bs5+jjCjIf6MMLNhpPIlMOj/xA5AB8xQjcFoHzfppvOyq3MwJGwAgYASNgBIyAETACRsAIGAEjYAQCAne8gznEYsEI3AQBzll+xGLh3adwyoyAi7n+4uLiK3P5f/yPv/3ZXL642MxFFjmeGJIYDr/Yc89+cLaAUFsDoJZh5BCIB/Wl/ZEAIuNHRqfUP+OrlRlPrb+7bl+KZ49N/wMY8XGIBAzM03Br8eQe0dQBLpDx77B6jH1cjmjP+VmKH8I5uqie19oAlh7/seNV4yVjy4wR1Z769SoSiv0qTrhujAQl9RsQkorxZ/94fVE9ve/i63Uc4/PLU3Jq8UjPE04FSAFWXuD7WrlrsSB0OOWH85P+uy7iSXvKHRh4zj/umSf+9EeZNRuoZwYAM0xajIf4des4n6lnvByPsr98dfnh3ObtP3z7z+bydrv9bC5Pf28hX0KOD1zTxAnvDADAZfE+IRD/b+U+Re5YjYARMAJGwAgYASNgBIyAETACRsAIGIEbI3D4c9yN3djQCJwtAvyCWzTn+3H4FCP7/blMxmOuO4e/axlTjoEMPPWiKDTNk0z/ZFDZgONbmmFkf+cmc7xkbFS8qj31yh/vR4M9/2x/bMZf1QQgwwhCr2mweiCBoQHhx+HdO5n3m/OJ+rse4NLxcLxqfGQslT31ZIQbVOWP/PDUmhkBcJhqNogFmf1TJsOP7lJGQMyHobWW+Tymqv/aRbTg+oOaAnzfNDj1gXhE55NE/zglItkXXpD9F/q77+bqedvtdu/Nxzgx/mT452r/bQQeNQLOAHjUt9+DNwJGwAgYASNgBIyAETACRsAIGIHHgkARG/pYQPE4jcBvEbi63L7/279f//dLTy9CBsBc9/pvMha1jAj9l8pkEBJDKxxmhkQ0gLqUoStl/NFdEsnolcaTHJ7ZBY5PhafGr/T0r+YT/Sl7+leymt+l/ZHhz/3HLaA5gyFxttnF7ArvH/Gamd7Jn+cWz9IgEP+l/XN+Kv+smcE95am9YPhpr+IhA980kSNSzxMZ2uyPEUWZ78+onSQw7vn5Sy3iBbSPyinBAhkC1CtZ4cP2tf3R37nJx36+ON6pBsBHvCZk5HgJ67znXzawgRE4VwTi6n6uUTouI2AEjIARMAJGwAgYASNgBIyAETACRqAKAWcAVMHnxmeAQCyb3MhNePziG04B4J6x7X7/LxjjdyCflchTBtSpACp4RTAxQ4Cy8q/0pYwK/d13BpOMXS0eipFReKn+FSNXOz8kHoUdpD3/mED9PjL+3Toy/NTnItGHc4CIt7o/CK9R7aln+3OT98B7DbyVvnQ8xKcU/9L+pL1acKnnfKdedMjnmc+XaJ4y3hTjLxl+dgjGnutLbfzsrlhGxkApfqo/vs+ZMaCq5Cv/913P8W932x+JMfH3nzC32gg8XAScAfBw761HZgSMgBEwAkbACBgBI2AEjIARMAJG4HcIOAPgd1D4j0eKAL8IP5nj8MuXL381l9/+g7fmYvp7hSrC067CZPOYLyy9x19hSUaPjJ9qX6onI6XakzFie8r0x/EsPV7VP+PhHt2kxwVW0SfDp/BJVf7bmBDEpy8x9nz6ER/Fq330H/MBaP16T/GYL86udEhBUM/HrOm1f3I+0EjND6Wnv6VlMv70r/S0p8zxUa9kMrDKfnH9wow/40vPOxhu2lMubg+Gn/64HiT/aKD0XE/QvFjk+kUHpf3Vzk/2f+4ya0aUxjsOcT3+mx/+kEt+qUvaxxQwFsVwTQDiZfkeIeAMgHt0sxyqETACRsAIGAEjYASMgBEwAkbACBiB2yLgDIDbIud254oAv9hWzfGXLz/+yXyg+/GPA6W3XkWKn3sc+Tl6GOM3t3YVv2DP+3r9NxmnWoaQ/imTgVCMItufm3zq+BXjQ4aKMvFj/Lw/yp56yqp/2jMepad/blEe8Tys2vj4jgOeIDKgYPwZj5L1nv7ooUsZPlHfx+VgKkgSlouG92/p55n+eb8ox+gnOguMGvV3LZfWACAex44/Pf+c8AyA8xky5xP9t7AfMP8UQ81wKFfPB8Hwsz/KXD+ov2uZGQodlivGp+Yj8e66+HuB/iiTUVf9pfZtWX9sf2qZNQD++gc/+PGpY3B/RuC+InC/nvb7irLjNgJGwAgYASNgBIyAETACRsAIGAEjcMcIVLGjdxy7uzcCR0eAX5T/j//zu0ftk4z/0p2RQaplWEhwgZBaOvxif2RAyLAofXGHaFCLL9xJhlaNpzQeMlyl8dCecpo/NIDcTik383+lhHVi/PvLubumb0IJkKZbXQV9M26ivNoFuaM+aHNNgHGM44G5FHm/VQPan/p5UPEpfW0NAOX/2Otv6p8PQDKIF/j8xnyZ17Yx46SP4pRRVjbf2B/bUx+jfT3fY3+l9vRXK7N/xlfqvzbDorS/Wns+/909Y/w5ftYAeOeddz6FDRbwaYk//K+wKsxhZ9YagXNGwBkA53x3HJsRMAJGwAgYASNgBIyAETACRsAIGIGFEHAGwEJA2s3ZIsAvunc750d8gE4MSawJQEaKMlHnnmKeIzwoSpcOC2UMJxVppzsyMtQrmQwm7Wv19KfkY4+HDA7HR7k0njQ9sIeX/hUepfrVtGt+/m9s4vMw193m73QKQCQoM+PPTsD4U80926wBQHueElCbEUD/nC/UU1b3l/6UPf2fm6zW0/vOkBLv0vWgtn1pf6X2jM/ysgi0hTUIanvnnn7l7+oqEvzT+vRKtYGevw+hTiJfSMixSfa+YATOFgFnAJztrXFgRsAIGAEjYASMgBEwAkbACBgBI2AElkPgbtnQ5cZhT48HAX5xBYcngeAXXz4D4ZPys2fPfj73+H//P/9v7J9ld+fG1/29igznqY+RZUZAz02i18V84Frtnn/uwaxlgBRDqfQHhnqtqjbea53OLpbGWxqPZHAHZKygyvgs1Gv/XPr+XtvJgYs99vgXht/0Y6wJsGo+D72NzX8JMvVN86WgPzeB86s2Pvrj/FJ61b9qTz39Kca/dDmnf8vnhQDXH0bH+aDs1fyi/wcvt138PTSVfTjlmKf15aen7M99GYGHhIAzAB7S3fRYjIARMAJGwAgYASNgBIyAETACRsAIfAECZD+/wMyXjYAReI3Aq89/9W9zJJ68+dVvzmX+zT3VrBpMBoLtR3xQX6HKM/VsT5k1AMh4lDLIpXv+GU9pf2xPmYxjqZ72lO9bvIxf4UP7pkXGSmnZ/eSw7MKqJcFU1r5r4iuub5gAdNgfTwHg80x90+CUgCaeEpBOEUD3rAnQoCYCzJPIGiDJoPAC5wsZUMp0X6unP8rKP+0pk/Hnekj7qax9unSXFxjv0uvTqcdWOp5Se46n+HauluXM2D/nM9/XHZZjjufc5NI9/aXx7652n8zbbKd/c/mav8teAKdO0bwmYF8yAsdCYNnV7FhR2q8RMAJGwAgYASNgBIyAETACRsAIGAEjUIVApEeqXLmxEXh4CPCL8nTu7C8xyoMZAGT80TaJitFPX9TJyIKhICPIGgBkGFJAR75QyuCQkWR4mUFh0V62OCyfO6Om4lN4HR5907Q8BUA1qNWjvxUyYMaVyAjoSwmewwEPY2Tw21Vk+JW+38dXbLeO/lRGwOHoplPfgZey53xQzwv19F/r79jtGS9lrj/UH5vxZ//qeWZ8pfZsXyvXxs/+jz0elXHHeJTM8avnhfa1NXQYn+qf9qVyi98X0++bo6bEMEOH8V6+uvyI1ywbASNwMwScAXAznGxlBIyAETACRsAIGAEjYASMgBEwAkbgXiMQ6Yl7PRQHbwR+gwApX7VrjpThxSEc/9fnn//Puf73fu+r35nL3LPLPcPMCFCMf/RtiQwHGUTKCrFjM07svzQ+tk/xjqzSH1u0C1NMxL/BKQGDIIQyAwZGHwxTU8hwx9E3TemefzL69Kf0tO/WWF7GmEFA+2PL6f6hQ6Xn/FX21Je2R3hJ7Lr4E6Y0I4LPE+dn6nDhC6p/6hfuvtpdbXwKb+Wfeu0v/jxYOiOgGtB77oDP+9LDYQbku//47j+LPrAAC2urjcAjQsAZAI/oZnuoRsAIGAEjYASMgBEwAkbACBgBI/B4EYifzx8vDh65EfgtAvxiHA4C311+/rPfGt7kv2T82UZV9eee/dS+jd/wphoFNKmS+UWfDJ4imEsZORUs+1f21JMxon7VlOE3NhF/Fd9q4qRL/q36GE/PstF0xpQT6AfcMLXHEs1lTYAWp1SwPeUB9qfOiCll9Bk/Zfob+zeCSXkVb84XldAUultcUOsB9QyAej4v1FOmP64vZHSpZ3s+D516vuBAMc4wLxbpX61fxR1UNmB8le5S81r/nA/soNS/ep/Tv+VlEeDvmxcvXhT9HrtBNFxw4wvepwLcAEKb3BcEOLnvS9yO0wgYASNgBIyAETACRsAIGAEjYASMgBEoQMAZAAVg2fReIsAvulVznl+cv/Wtb1eBsjTjyT1yIxhkBssMAyYQKMaJhBkIZnaXZOU/Nai8kBgfsYdedTcOsaq7KolMRl/5p74TAHOys30p40+GlP6UXNqeGTGDqvqvArhjfaoBwHgqawKQ4VSMN7uvlRVDX8vwq/GV6jne0uchrR90eGS5tH+1vpb6U8Pj/aB96fxU/uhfyXxfKXs1v0vbczyMh78HVquY8ZP0KoAz05c+bwyfv2+eP3/+AW0KZWZ8Fja3uRG4vwg4A+D+3jtHbgSMgBEwAkbACBgBI2AEjIARMAJG4MYIVLGhN+7FhkbggSDAL85//hd/FcqYr4cxksDYNAgxoUJGPhkUXiDjUMrAFHZXbE4GSjFWxR2wgWD8V0yBYHvIHareD73i4OGgUJT+28gYrVClf4RedU/GRvkj48/27I/2vP/pmGmRAUH/dy33+/iKlRkBCHjEckLGEObTsfWHv+nzfhB/+itdL9h/18V46I/27J+yslf6DjVT6L9WVv3TP/GgvlZu8byzv9J4a+NR/an5zcef9nyflMabGH88T/RPe+pV/7m9ahH1fJ6jtmno/9jzn/0rmYy+smcNgGfPnv0cba4gL/1CDr/30JdFI3CvEIhv53sVuoM1AkbACBgBI2AEjIARMAJGwAgYASNgBG6KQGQrb9rKdkbgfBBQc5gfuSJF2jRPMZRI2TXNxVy/2Wy+MZefv/uTv5/LT9aHOX4yMmT8Kc993+ZvfjEnA0SfA6rIk5Hlnl62J0OT9bGqPf3TnrLqvxEMP/2VMv4Naiqwij39l8qS4YfDFmXlS9vDnRR7MGLdGO8n9XQ4Ft6fYX94i2bPx62P9n0TZcbDqv3U18rtahNdFO75zxkAXM6ie0pkCBXjz/anlhlvaf+lDOyUMlHUBf0rRrvI+RkYKzg4/jMIedEQ2i6+/rm8cPyJYcd6rOzZnvYd/SGDhc8L/VFWGQC0b5HB02L95/uH7cnwM17qeTNpv+/7T+Y2X3vrre/O5e12+9lcnv7eQr6EzBcEMwbiC67BsTVwZtEI3CcEyn5N3KeROVYjYASMgBEwAkbACBgBI2AEjIARMAJG4HcIxM+dv7vsP4zAvUGAe7LKKJ0mUYQHn4npC/ergEy//3wuD+3my3N5vT7obm56lL/TF3Z8z1YZAWQkSoOk/1LGX/YnGGXJ8IPRZ39LM/zJP2oGbEAw7FBjIOlr28M/46NMxl/pB2QIjKAYW6SMDNSDcaK/DhkrpG8Y32OTFeNPhq0Wn9r1orb/UkZf9Vc6Hkxf5X4K9/DriuulspcdlhqgCn1beCpHLonD1/XhgGTG1+HmUkvGupTxP/n9ECMiXhyfaH7narUe7Xe7l3cepAMwAg8EAWcAPJAb6WEYASNgBIyAETACRsAIGAEjYASMgBE4hMDd0pOHIrPOCNwDBD7/9atP52G++WbMAJjrjvE3v5grxk/FwBoErAmg2i+tJ6Oh9vgnxl8w/CreFgx5aUZAhz3p7I8FKagn41+qpz0zCqhXMhl47glV7cn4J3txv9gf46G/romvOFUTgO1L5bTnv9RBE2cECWI+76XuqxlLBlQawJHtq8cn4ivd818bT237ppLBzxkVhzMWCB8Z9QbraYMModQee96pT+8HGCQGHBlFML97Effr2AElfI7dYaX/X//61XtzF9Oef+7xn6tf/809/tRTRo4k1ZaNwMNBwBkAD+deeiRGwAgYASNgBIyAETACRsAIGAEjYAS+EIFIj3yhmRVG4N4iwC+6kWLLw+IX43AKAL84X15ehi/Sb7751W/OXQ44h52nAMxtr/u7lvG7zmfNNTIGZGBIEGKLd03X17ZdmvFnJ6t+Fy6pyROMrxHIwJPhH8CAswoz9eyC9h1qBFDP9vTfowo1GfjUXjB6tKdcm4GiahT0IDDJ2JeeCsD2HE9TWPVfPe+KEeae8RQPH9BkcPiC6p+tSxlz1gyhv6Xl0vjYP+EsxSetp2OcoKV77hkfSmRQPRHykQPKjH1usuSVoYn9l/oWCQLSXe14ef94/9X8GpEREe++DD8ZMJ5kIC7w/cD1vrTqv+iuWP3xx598JBrx95swt9oIPF4E6lbfx4ubR24EjIARMAJGwAgYASNgBIyAETACRuBeIeAMgHt1uxzsuSGw3e0+PhRTKeNPX9zTrxhCtqdceyoA/S0tM6OAe/4T479wAGT8F3bfkPFX/snIk6GhnjL9Kz3tmUFAPWVmSDCDQO3ZL605QYaK6T6M78movnmHhJ/manXYY32NASLGiMtkMpCqdak9/SUGFYwy7VV/I+7PCqdikDGlf8qlGUi1jD775x78rI+cL/GsXe/zbI/9pXhOfIHjVd0zo0GdMlDKiHN+sj31Kl7qsz9aWJ4jwN8rH374/s/nev9tBIzA7RHI74fb+3JLI2AEjIARMAJGwAgYASNgBIyAETACRuBMEXAGwJneGId1NghwT9mTeWS7y89/Npcf+99k3OSe5DsGTDH+fReXSFXVv3Q4ipEn40//1NOf0tOfkneCstuQoqPDfR+vrCMDzhoADe1j6yQN7SZeG2INh6jMEhl/nTGADIFVnC8Nzk3v0x7vyMjWMoxpRKC06Z+MpKrKzj3j9Jf6L7yQ/QGfYn+FDSrNE57IYCB+4nGqjGb55vn+HO7j2Ot/qpEgMlAOR2vt0ggwg4WMvupvHOL6+uLFi6V/b+GFlIpSxABUwNYbgXuEgDMA7tHNcqhGwAgYASNgBIyAETACRsAIGAEjYARuiwDoitu6cTsjcG8Q4BffqmeAX6S/9e0/DkDUngIQnN1A4Bd31hC4gYtgQkYWH+SD7WsBhOMkRwaPjNCq4e2I9qkDXkDVfKpr5VLGnwx8af9k7Evb0742HvqTDD8aDDsw8GD8Sxl+uE/ieoyXmL4TtVnSjH9s0xcyji0zBKK7/ABRL2Q+b4lhRrzcY9+0MSNDdPfo1Znxj5AofbTWEu+vbvHALMQpI2m+3/Hw8fqT0bSp5og5ujloz58//2Au3+Lv0lfCLbpwEyNwPxDw6nI/7pOjNAJGwAgYASNgBIyAETACRsAIGAEjUIVAFftZ1bMbG4HzQIBfhIueCX6R/v73/xKjOm9GLe3Jw443dS43Ga5UxR9oUBwb4DOCMWaDSlnt+d/hXObSqv0Mj4w+GXnq2Z721CuZ/mv9qf52IkWEGQTHvdsq2np9R0aSNQhSF/EB26PqfVeYADNgvpIBTYxxoiQPd8jnuxlixg77T8N9bBeQYbH08FMGVbqfS/d45v6OjHfp6FciHp5iwaeP7dP7uTQg2KfnGfpTi8xYZP/MYHz27BlPAbhCm7hAQXkDMS7QDYt43MCDTYzAPUHAGQD35EY5TCNgBIyAETACRsAIGAEjYASMgBEwAjUI8ANkjS+3NQLniADnOD96gYJunmIQzAgIB4VfXFx8ZW7/t3//4qdz+ck6cnIt9thyjz3lua/Xf6sv5rTnF3TqKbPqLjMAeE47GSlmAFBW9s0YP+CvBKPcFNYA2Pcx4aOW4Sd+lBXjToae7Skrf7Q/tcwMgE0bHzfqjx3fHhkl61U8JYD64njI+PPUAepVB21cbsgA8vmhOzL+ab3g+oMMhkEwmOyPMp936u+7LBlUgV98G2Q0eP+yRd0Vxs/7VatndGq+0p5yOlQE85X2pacsEG+On3r213Xx50Ni8PHrY4Xnb42Un9Qe6yefZ8ZLuWN7yl1cn1s1fzFe9sf1ivEqPfHd9/0n82tfe+ut787l7Xb72Vye/t5CvoQcfwA0qQiRMwAAmMWHi0B8+h/uOD0yI2AEjIARMAJGwAgYASNgBIyAETACjxqBSDc8aig8eCPwGwT4hbjsGen3nwcc10++HOQTC/wCX5oRcOJwq7sbxJa9FgxGg4wABsCaAF0fMxTI4Jcy9KX2jK9Wro2f/d83xr82IyCdOlDJ+BNPyomRBGNHRo7tKdcy/vTX4vk7do0AjpeMNuOrlemf/beJUESPOKVCZXwNqBHBKvHUo7d0Cgvjp32tnv6UXMzw0yFqUvAU9+QfzwsI+Ibj5/0tZfzZXpVsYI2ATjUgHgvL6X0J/6WMPppLcXt5yT3/so0NjIARuBkCzgC4GU62MgJGwAgYASNgBIyAETACRsAIGAEjcK8RKGM37/VQHbwROD4Cv/rV5/8y7+Wtiyffmcv8m3vqFSPE9kq+a8afexpH7PFP8a/inkoQYE2qCYA9jA0Y+uRfXCDjL8ynoujcMqhalOmXZuiVP+7RJ6OvolftqVf+SvVk9Nn+y0PcEnrZxZoAtKc/VTMgZQRgDzD32HL2KEaNGT2Ml/6oV3KLPdYyQwAM7LEZf8ZPxpb6o8sY/xiXr2m9wgRAQPp+xYyjidIPHpgRkE5VCdZTSfMxpiAwo4SMdSm+9I/um8TIc76pmi9wyJo6UCdR1WDg+JOD2gt4v+F2TiUM4v1ld0ePjx1CLu2f6xncSXG7vQoZANOef+7xpw9mcFJPWT+CbGHZCDwQBJwB8EBupIdhBIyAETACRsAIGAEjYASMgBEwAkbgEALOADiEjnUPEQF+8QVnk4bML8rhFAB+kd5eXf1H8nDgAhl/lRFARl8xgge6vpWK8fZ9ZJTolKcIUE+ZDBIJoRX2GK8KGfgBGQKljKVi0DmeWrk0w0DFp/wpxp8Mfq09/dXiRYaeDP5lFw7taJS90jPePQg8Mv60R1HutGeb9komg6/slZ7+uP6UPj+qv3PTs6aBim/YR8Z+RcZ3iK+TdijjYAZUkZ+OhQkhtW3sPygnYeQmeRgszvhzAUd/FBPDjAyLhuOHg3Z1+H0EcykyQyLL0QXj75Chlhn/eP9L3+epPy4oMbx7J3388ScfiaDjAyWMrTYCRuA/EYirz39e919GwAgYASNgBIyAETACRsAIGAEjYASMwANCwBkAD+hmeignQYBfnJ/Me/3lp5/+61x+++1v/Olc5gd6xfjP277+u5ghAOVABo/+lcx4lb3Sk8EY+p1qEvQjGZbCjIDg7Bphg4wD1ghQ6SPXuKy6VMvwV3U+NVaMP/Vk+CnXxsP23OP/K3ziZkYAZfpTetorxr/hfAVDnP1Fhpd67tFPjD2qntfqBzDYiW/Fnm7Gm2TER4Y12S98gRlHdM9TRWifGGesP6s2IjRgPW4K8WqRv8Y9/2l9RoYAjz1ne45fyWpPv2qf9GD80/sOeHH+N+J5Yn98/yg9a9qwfWb84wJUuiee/ikzXsq05/uD9ucmf/jh+6EGwALxMUUm3qCmwRO2QI92YQTOFAFO/jMN02EZASNgBIyAETACRsAIGAEjYASMgBEwAjUIOAOgBj23NQJA4OXLj38SL/1JFFkFv5CxUAx+Ykxi72cnrcS52UqvBpRKFGAP6QqME/2R8aeect/FnIAONQdoXyqrPfzKHxl4xdgrverv2Hru0ecef/WCY/tSxr9Zx/vNL+pjqyKICJU+v2T0o7cpYwiMqdJzfUmMP6rIv95VPv+XGOG58pq/md9Ahp2EOV0UZwwkPBhB7IHxTGX0o4HY474Xz//Fk6vg79VVSCibplecX/Sn7u+qwSkXzAjA+2gYgQcyNNi8NINhOjYljJc1JPj8cD6WPh+hs0lQ84X6LEeP1Eft1B9T/mDA8ZCxh3kSO+E/NRAXWsw3YX509YsXL3529E7cgRF4pAhwvX2kMHjYRsAIGAEjYASMgBEwAkbACBgBI2AEHjYCZfTEw8bCo3ucCERKommqnolPfvGLg1VryZAlRqXwHpBBKGx+dPMOhNIeBJoKgOc8D2SQCCgpQzJ2qkPoBzCG3OO9ww3cgPFTjDu6S+Kp25PxZ0DUq/hK9ezvaf9ZuJQY/hUYzmDdyCr/ivEfwLDxFAzu6WbVd4STRPX8kgGlA9We9pTpn7JiwFOGDTuQstpye5ijSPHJ/uKCxOUiMdpcP1ADQUXfDIdrmry6jPE0TbRnwZnsLxxK03B+jqipMo7x9cYENK5vrBkwDIw3Aq5PETjcPnrTUqrBgCaSoccEIAOvZHQ3ZRgcnq+lzyv7X5zxF/FyfKeWnz9//gH6jCkzUF4jpkfoGhtfMgKPEoHDq9WjhMSDNgJGwAgYASNgBIyAETACRsAIGAEj8PAQiJ+DH974PCIjUIoAvxgXPSP8Yv397/8l+o97OqEsFsnYFTtYuMEKjHk9Q3g4QDJeqQo2mhMv3g0y/mjeKH3KCBDnbtO/YsxL7cnYl7ZnPGxP/8pe6emfjD/1ag+/YvjpjzL3YPdgzBSfWcr45QyZwz0wAYbx8xx7zn/aJ0YdDHh6nqGnvySDcWV/ZGxHrCfJn7hARpb+GD7xEu6Prn6DNQKQMTD0W8QgMgKw57/p4wo4qiLoqKGiGH/eXwSbRGbUJAM8f8xY4PxhezLqWR/xoH5s4vPIjJ+kpwPIKh6YS3Fpf7LDhQ16ZNA9e/ZMnQLADE7+flMRMomnMEdRubfeCJwvAs4AON9748iMgBEwAkbACBgBI2AEjIARMAJGwAgshkD8nLmYWzsyAmeLAOc8P4KRAniKkTAjIFAuFxcXX5nb/+3fv/jpXH6yjpvGucedjDblua/r/ibjqBg/+hhR1ZqMGRl2MjxjHz/AkzGkPav89zhHm/GxBkDW4wM+KFLiMZIRg8OWFCH01eI+EhiKUade9U/GXbVX9tSr/kv1itEv9SftWcUfDCPbMwOAej5/1PPccsWgpj3pdFgp83lklXs+v9RXdv+6LPtBF4rRPdj4FkqVAYCSENMW+UggpgwhUQPgjadxvco1AW4xiFmTtguvp5nmf//ZxdcfGW1W6c8Oyq6k+YbmvN+K0aZetVd6vo+7hE+cr+n9jKIK1HN9YPyUAU+jagCwfYv46Y8ZFOoUAPrvxKkBPAWB42c8l68uP5xfe/sP3/6zubzdbmNRmKZhCszl3H76O/4gYVGLJqW8xAcSziwagYeEQFz9H9LIPBYjYASMgBEwAkbACBgBI2AEjIARMAJG4HcIkM38ncJ/GAEj8BsE+AW56JnZX23/bY7jk/UbfzSX+fcK52pPFBlNDspkuA8a34EyMf6iqrQKkRkJtK/Fg3tSazMCNshI2IGB3iEjgOOhTEaeDD/lWnv2v7Rcu2dfxdNuDp8aMGKPc/KHjBAyWpxviUFFFXnuGVb9JX3tBYxHMf5kcMmopnAEw5/sKy9wz79yxwynlJEAfJAgpSt8tJhvyAhYmvHneJmRQMY320cCNEqvEzbK3kecL+wv+QOD3uB9SAaa7Wv1hcPjcNIpDMlg4Qscr3Kv7j/b0z8ZfdorPe0p73a79+bXJsafDP9c7b+NgBGoQMAZABXguakRMAJGwAgYASNgBIyAETACRsAIGIH7gkARm3lfBuU4jcC5IDDtaXs5j+VLXzqcAYAtpRPDFDkYtadw3td1f5OhvM6m5Fraw8w91JVVvNWef8aqqlirPf9k+Oi/dg806Qz1BVYx+Ck+XFCMP8wbnmKQdkgiY6EpzFhgf8eWaxl/Pi9k/FPVfjDG8emdRgs9Gc4xccpxhqzSDQGCyT/0EBXjD/PMAJMyLeyfDC77U3Ip409/ZDipz1uEo8UAxrqNJQHyqSEiIyB619LVKnb4ZIzzpUHGwZD6h/2KMzbKA/3rEIMFp0tQ3kLg/SudT6Xzh+/f0valQ1R7/ulP7fnP9ix5RIvTytPvpY8Ke2SGpmoeHxhlbb0ReMAIYPV/wCP10IyAETACRsAIGAEjYASMgBEwAkbACDxiBJwB8Ihvvof+GwT4RVh9EucX51BmmXvWtvv9vwDn70AOIov2Rv4lmP5GUAxlbnH4CvfwjUxJQHMyMCkjAPa1otrzX+o/1uDPFRdIeO2wh79rMF2wx1vtueR4WjDsqiaAyhBQ+oQXnobUfmHGf+mq/4yX91fu8QcgZPz5vJHR555n7vHXBHm8AfSH8MpFHcBhn4rChV4xskszqGRoDw8maxPeyGjKNVqiD57yQIaFe/Jj63KJjD8zAroGP/HSqSfY0z/E9Szfvzg/S+8fM1xke+KP+aUQ4/spj0d5sP6UCGx32x+J/vj7S5hbbQSMwBchwPfTF9n5uhEwAkbACBgBI2AEjIARMAJGwAgYASNwjxHA5+F7PBKHbgTuBgF+kX4yD+OXL1/+ai6//fU/mItT1eAg8ljpZr0GQxPNF5dGuQledNkBjgEDFM2VmgwfGXS2xxbdZtgznshokfFXNQj6JnLMkT+bdhCjBkJxRgAHBJl7/KFulJ6MOe0p03+tXF31nxkTCIh3G2opktEdkOFBxhgJIlOCQJwfssO7NihkWBWjKhleMV6Go/oT7qRa+WcCBTMCmEGVllOsBw325PeYLx3sezDifTrmXA4xGIw93y9xPeQLivjwFIV0vxFv6PwGQsvwbtDmmCZ837Qo+rBKNTyOGc3D8/03P/zh0necCzBfCZjwDw9Tj8gIfBECfBi+yM7XjYARMAJGwAgYASNgBIyAETACRsAIGIF7jIAzAO7xzXPoR0GAX4yrnpGXLz/+SYzyT4JIhrnFOeSZcVj6A3kIRwpk4PtURly6iAbdLsqJoY9q4iGr/uNuyirqsbuJAIucPu8XzJteUVagBJkRwIyBAbd7jaIQA1JIWvhvwJCzan8twy/7J0ALy5wP6y7eLzKm6ot3Yvz7mNGiGX/cII43Uci4wbRfWialDv9keKFOYmJ8YZGeN8EIZ3hOjA/ip0h8SjOmuD50TBlBh5y/UDepBgBqkmD5Y/NJxnzFekd1hwt8Xtbi/l4TQLjE9wvxbjC+FH/wdnyB85/rEcdz7IgG1OwpPRXg2PEp/3/9gx/8WNlYbwSMwDIIqN9Dy/RiL0bACBgBI2AEjIARMAJGwAgYASNgBIzAnSJQxW7eaeTu3AjcAwT4Rfu7/+2/32nUqqp528UtcSBAU+zD6ipdm19oN9HfcNh83nSRv1kDYB8J3UX6KHFCBrAhYy+clTLuiZGC/2J/KuMA/k8tMoOiASOZqvgjwNI9/miuRcHAawd3a0HGUzH8Kz6AIvxzg4cZH8xQoMzhJT2KjPTYQ46SInTX9GPMiOhXcX1txpgBM8KeDkvxJuNPf0pmVX4y5CqBqm2RsYAO6R/qxUUQ7iyZUNyfOkWnw/pL+1OPv3iAaMCaGe+8886nMOEvBpXUcsdveERv0QicMQLOADjjm+PQjIARMAJGwAgYASNgBIyAETACRsAILIWAMwCWQtJ+HioC/KKsnhl+sY64oKpz3tMYzY8tMSOg7/lNEAwTAtpgz/WuP/yBvl8RTjhsUBMAVZXJGJGBobdaWe35L/XPPb/MCKCeNQLYHxl86pXMmgHKn7Knfh8Jy4Y1DFR8pe1zxkOcbz0YaTK8jEds0Z6q/B9mJOmvVM57oKOH0v6Vv+i9acj4U89z3bme0P6+yer2Ep+0XsBBul+owj8Ixp74jUM4dGZKKOJ6HednKUNMhpb9K5n9cf1m+zw/Y0YD9/xne3o8rcxTIaYnKASQMpBwv4hXvp/BXcOMgKjNUsqQGrswQaYMjBgwXJQ+38p+e3kZepjG/ypc0EJc4LX94QdEt7eFEXgwCPDX/oMZmAdiBIyAETACRsAIGAEjYASMgBEwAkbACPwnAge/9v2nmf8yAg8WAT4D/ChGCoIZAE+BzMVcvri4+Mpc/tHfvftPc/npky70x6rzZEwoz329/lt9cac95REMfKoBsIoMvWL86X+3O5wgMVwFQqJpn8Tbs7ss/eAfI9jtD2coROssJYYvmyx6pTQjYNHOJ2dkjMjgk6Fn/8e2lxkSqAHA+AYQXomhZYNK+dwYS46X8ZHhLh2+qhFQ7K9w0zrHB0K+tPsp44MEYnTB/qYGwSDpg/b18xbXN7WHnwwxGWS4T2K6vzgFQGUArGHPDhgP31/c88/5xww51gCgPfvL8fB1Hi04vVYYH+ONradokRFHvcKT72+OhzIzAKhXpwC03SZMUGYA0B/jZ7xKTzwuLy9/Or/29ttvf28ub7fbz+by9PcWckwhaJr4ADWpqgYf4DB++LZoBB40AuF/Ph70SD04I2AEjIARMAJGwAgYASNgBIyAETACjxgBspmPGAoP3QgcH4FXn//q3+a9PH3y5jfnsvo77XFWlIRyWKofN6HFBoRKaUZAcDYJvwWyzwAAQABJREFUZPyZEUD7Y8tHZ/yZYtHFJZk1AtSpAYoRJ15k+KmnTMZf9bdvSLhEj9UZAvCv4um5qT8mmMTgbiGRkSx1QUZWMc70X9pexVvaP+NJ9BoYdMbL9pQVg077Y8sqHqXnnnDF+HM8ZGgHzmcUSRk3sWbAGnvQ6Z/ymhQ5DRaWyfiXuuf85nxW8497+lmjga/fnjVwUHOkw3o1IgMJJW8olg4/2av1MTU48oXd1e6TeRcT40+Gf65+/TcZfuopH34B0dqyEXhECDgD4BHdbA/VCBgBI2AEjIARMAJGwAgYASNgBB4vApFuerw4eORG4CgI8Iv2tOftvXlHw/DVkAGAY37npr/5m3sok8HCF0BIN30fawCUdrcBA6VqAtD/Zh0prt0+cYxsUiWzJkN1RoBg/BuhZ0YAawSUMvoEhwwR/ZXq6Z8yMwqop3wJTmwNRu0KfM86nTqBlBXs0Z7K3scuhZ4MY2ysq+i3qzh/o6TbN2DUS9uTEWX8x5bZv2Jka+Ph7aU/3m7GR3slc370hYy78q9qAAyxxExDxn+PF85j/0HI+098eT9G4Ef9ClvQ+yGuP12bnli6uFO5dE9/abD//tHHn5a2sb0RMALLIOAMgGVwtBcjYASMgBEwAkbACBgBI2AEjIARMAJnjcBj/+B71jfHwd0JAuAQm/jJPofEPWnhFACab3e7j3ltLmeG+XD3tRkBrPo/j+X13ySkuWWR9kouZfz7IVbtx5ZW1V1TW/VfdlBqwJQKtld63JCe9kP8ptuBgWJ3lDODHi126ZxyzM8x3q/9GOOJ3ppmveLjRoso077H47lexf6pV3uywf/Hzq+RyFiz6v2Km4STj9hjlJJxvoA9xqo9a4gwfsV4K/wYIBlw6imr/lW81Gf/vFIml46/1F5FkxhpMPw8Y+UCGTOJ8WdGAqreq3hK9Zx/07E1wUXbREY8jTdYTzVjwMDTnno0n8TD6w8zAji/eryQmGGiThFgDYCODnLAi16Z4A83QOO1aPfN+++/94/CI39fCXOrjYARuCkCh3+d3dSL7YyAETACRsAIGAEjYASMgBEwAkbACBiBs0bAGQBnfXsc3D1EgF+sQ9nl3eXnP7tPYyLBzCLHxx7L5mlconaXhPdwBE+fREapH6K/YU/GOPpjRsbiNQFid1riDUELxfj3qzj+DueOK4Yd3TUdGDzucW3A6NG/yhAgg6zGx/iqGdhCRo7xMp67lpkxNCLDJj4tOdq7Hp/KEFB6MrgcIdvL+UOKmA6FzP5oTkabVfxXu8j5X2B9IOO/GuL6uW/jehAlRjM9zag5Eejj1+aryCkx/nUXWzABIfPxcUammhnAn/OT94/6UsZ7RIYT/an5lRF9XFdYU+DFixdL/x6KL/QpSQQI5ykGA4tG4LEgwIfjsYzb4zQCRsAIGAEjYASMgBEwAkbACBgBI/CoEFAffB8VGB6sEbgGAX5Rrnpm+MX7W9/6duhywB5ubHGcjoGPjEhoPAktGBbqee60Ijix5XxieOixTK49BaBrI4NFRp/6y6sY8AZ3jzeXoyHjT/25yTwloMF8IuPP+LlnnnrKPeYjGbdkjz37KkNAtWdGQI7/8PNCBo/9KZmMaDpFQDmAvpSRRPMkkoGlQdqjDEYVIpsnRjgZiAtqvojmE9zx+Vb26X6pBoV6jofrrXI3YDicDwMYf2YExNWxaTos4KwZsi5c0HvYYzmdHmcQrCIjoEENC+IzImOB63Wa3/C37uLzz/nC+8X+ac/7odrT30OTyeir8Y1I+Xj+/PkHqo3Qc8oLc6uNgBH4LQLOAPgtEv6vETACRsAIGAEjYASMgBEwAkbACBiBB4xA+oD7gMfqoRmBO0eAX7z//C/+KlAU0zH3gQNSe9A5oFLGaRzjErDCuenYUtqcugYAx9c+eRou9aImwGYdq9QzQ6BZx/GrmgCh82sEZgyo+0f9NS7rLrWRkSODN/ZhutUS2ImRLWXIdE2ACEe2D49TNL5GYnxk/K5pEi6hBELaAx2MrxHIKF5jEi+BUU2Ma7SWVdJhLsVaBp14yw6FwdL+RHevN8EHE/av1l/il/eMx+eVDDcZ/zUYVe75Z0aBYvwVo9uhpodMCUNGABKGpuZxvAHc10KEO6txP+hvjy3gzKjjKQClz2PpeoFDG6aSCXH9TQPEhS4PABbHFTm/W8w/NX8Y3bNnz36Oa7GoxXQQEfS1ophRte7d3gjcHwScAXB/7pUjNQJGwAgYASNgBIyAETACRsAIGAEjcGsEyj4/3robNzQCZ4uAegb4kSxSyk0TKenpaHOM9GIubzabb8zl5+/+5O/n8rqNm1pZtZsMc9bH4cBdwy/4ZPznsVz3d9/vrrv8hdd2ImVghz2tX+jotwpU7R+GGE9pTQDezN9280X/VXREKaPP+1na/ovivPF11AhokDHAGgI39ruQ4WYVCZvdGOc3GdjabiWjFx/PiWGPM4jPI+MpZRjZnlXWVQYA25NRpp7+hoS3YGyTw3hh6fsVvR9BAsPcg/Gs7bHHOfQjqvJzDz8ZfdqrjACVAaDGI0rMpAygnOGgeijUIyOGpwSk5y3Zx/5WOCWlbeP603Xxeed6wfG2sGdGGhlzvq8Z/wYZazH6qQYQMgRapBy0HD/iY3sVH/tX9vu+/2Te5mtvvfXdubzdbj+by9PfW8iXkFkDgK9oLljxhsKZRSPwmBDg/9w8prF7rEbACBgBI2AEjIARMAJGwAgYASNgBB4NAmQrH83APVAj8L8R4BfhSDFqmPgF+uAzNX1hfxVc9vvPg9xuvhzkSoGMPxkGDl7VBMgJD/GDvGL8OZziUwGwp7+5ihkA3OPPjICnT+Lt2uGUgBWqSPPc58j/5A2KZIQ5XsXwq/b0p2TVX2L8lUOhJ8NLRig1B8NK/QDGigzkvucMpocok7GL2mskMP5sD/U1DsQljI9bghNjjz3Vwnu5GvFwzzsZTu5pL+/wzFqI+TgdsxICbnE/uN4G40kg4w+COW14TniD4WWGwB5FW5gRwPgZH2U+b9RT5vPP9ZPPD9un8QJf6sn40x8zWPh80Z5yabxcD0r7Y/9k5Km/a5nrO39fML7t5SX3/NPEshEwAidCwBkAJwLa3RgBI2AEjIARMAJGwAgYASNgBIyAEbhLBA6ylXcZmPs2Ao8Bgc9//erT+Tjf/GoXMgAG7tFOHBEYqULKYdVs5t1PVZkjox6V07nSXdxixy3+G+wpZHtmCNTWAGjbGP9uX8YIt8woQMBjTBiAdsIDVyI6UE5iKcM/YtPvCpQhGX76p0z7ZuQIEHNhPgwZqxZlvId4yMVEMB++X4rgVwyd8o/RSpEMpOpfOgTDOaBqee2ef9n/iQ2IF++P0qtw2V7Zs/90DIaYn4rxTxkSeJ56wdizqv8KNVP6zZMwRJkREKwfnsD1h6cO8PXI+cI9/+qUg8T4I0Pj2AhzvMfur9b/dnsVMgCmPf/c488uxBuY5iiqkdS+YASMwG8RcAbAb5Hwf42AETACRsAIGAEjYASMgBEwAkbACDxgBJwB8IBvroe2CAKsIiso04ZfrMMpAPzifXl5+d48yje/+uVvzuXSvwdQHqoqOf3rGgCxRddFBp6nBJDxj62bproGAE4F2KxBsaHD3b5syVM1AUYwtivBGI6oao/wkkjGnwardOhEZNSZQcCMAOqTf2QcJD2rxHN8fHqSg8P3i+ZJJgUH/MnwsT0ZYO5ppT3l1J7xoKYEHk+6S+fMlz6/2WHZFY6HrROjTYNCWd0fpS/srto87fkXHpHAM2UMRc6FLwvWCOj6+ACR8ece/1QDABk3ItymdM+/8qfuHzNq6I/69DyI5yvZs4NCWY1nBN5c3ZT+6Ix+28UXhEpxKMRHmX/88ScfCRs+EsLcaiNgBG6LQHwb3daL2xkBI2AEjIARMAJGwAgYASNgBIyAETACZ41AGR121kNxcEbgLBHgF+2wafOXn376r/Oov/b1r//3uTwR2iQR5ur09wp7rvmBn3tWWbV3tYrhqoyAPpqzu4Y1AVRGQDfGJalHPGnAR74Agmkq4x0TQHhzIl/3mtCNhMtKMObUc3jMIFAZAmxfKzO+xGhjfHk+xgjIOLJGAGsIxNbT7YjwTvOPd4QtoqwYPaXH7Z2ePvSPPf6x93xuN/U5o6fumz23KPOUgTxeAlzW/9IZA8Tn2HLP+8n5NR6u+tGhRgnjjatdPlWktEbAmvGywzuWyejLcHAqBdebNTKUVNX/PL+xnuPxrWbk0wskjpjv36g9vVSaAVUa4Ycfvh9qAJS2v8aeDyAXKL6Sr3HhS0bgcSLAh+VxouBRGwEjYASMgBEwAkbACBgBI2AEjIAReOAI8AP0Ax+uh2cEzguBly8//sk8ov/a/PFcLP4bW0anHaeRwVN7InkqQNfFb4T9ECl/FLFu+n2sCcBTBVRGQPsE/fFQAuz5b1DFf3cZ42NNAMqlpwYIQqdp+0ghkX4gQxytp9udLnAKkPCIejL0DRiyFc4tAMEWnV0jjavYfysDjgOKd/d1B9CDUk8M/zUxzS+R4WMGxtz29d+0J0N5bAabDLxiHHNGQMSP42tEBkKyxwXio/A8Nl4Ir1hU8dMhM1TSfGwjg9wM8fmgP8p7rM9Ne/gnWcY3P1Hs45DM8dG29P6zfbGMBallTZHkMM5/9X5j89Kq/2x/bnKL93ULPBmvWm+WzlB48eLFzxiDZSNgBO4Ggbq3x93E7F6NgBEwAkbACBgBI2AEjIARMAJGwAgYgUIEDn9uLnRmcyPwCBAgxVP1DH3yi1+oqrgB0swABvXiAhlK1UHKCCBaygH0qSaAQJsMP9w1w8BvnjHADoxeX8joMUOAGQEpHjDgZGxzxgAYRzqMhBi907pYZgbBEToIMXVgsLkHOBhfI5DBJMN/TZNwSdkP2OM99Feh/RrzKSivEdTzRsaO60F2GSeEZlSzh0NXMiN9yDrXxDhsfX5aMuYyI0AMQe7Zx/pTW+VfhJMyYpS90qvnpxEMNf2n+Y+aI22c7teMh+tnzJBjf5Q5HsbDEhHtKuaAtcjwSDV52OHC8oQPECrroLZGwPPnzz8o6zFZxxS/pPYFI2AEbooAfw3ftJ3tjIARMAJGwAgYASNgBIyAETACRsAIGIF7hIDg0+7RSByqEbgbBPhFuuiZ4hfxP/+LvyoaxQCGiOe8Fzm7xpgMR9NcBKt+2AY5CePhmgDJHhdkTQDYK7FtychEQmTAeLqW440ZAyuc6z020T8zAhhfSwoRFBKr4pfuYWZ/x5ZT1f9Cho8ZEA0YdPJ3mgEvHHFhvIrxHzABlH1htCc35x7rcYwcAko4TKdg4HnA/D75ANDhqZ8nxaCOQ8SL81+9XOif/pjBoN4XvH+AL4nMuMkGcb5Qv0aAA+YXT/kg40+Z/peWmbGzwnp1eLTTKSBn9jwsjU/fx/fls2fPeApATJnKB2GUhoQHiEU2St3Z3gg8XATU+vRwR+6RGQEjYASMgBEwAkbACBgBI2AEjIAReEQIRPrrEQ3cQzUCN0SAzwg/mpGUfAq/JG0CpXxxcfGVuf2P/u7df5rLT9Y8uXuufX2OeAyPjE7Ww14wEMwAIOMRo5n2+DYs2x8t+v6wfgfGILZumuEqfuDvV0zAQAueGgA1awJwzz9rApQyzikjABkDCGfCL44v6SOhQnWSucc0GfBCIQPO5nctk9EdBd6Ml/N9jz3GtFcyGf+2exKatDhHfs+MHmHPeIPzawSuB8kEjL183kH5Q8zu4Z8GvH/UK1ky0HBQ2x8TeOA+iWTok8HCF1rgzfeD6o4ZZsqe/vl6SfcHGTLaP99fsUXyH9WTFF/XPAWg66Ke/lgDII0PGQAbnFLD+88MAD7PlDv8HKBenQLQYnxsr+KjnvByPJevLj+c27z9h2//2VzebrefzeXpb6YUXkLPFz7fiHyBlhV5QGcWjcBDRoD/M/OQx+qxGQEjYASMgBEwAkbACBgBI2AEjIAReLQIkJ18tEB44EZgIQT4hbroGdvvtv8xj+PJ+o3fn8v8m4w0CIKp6n38AC4ZQHYAeRMJkqbHHs2e39/RvhE1ATZgKMZd/MC/f4JvllcR3pQRAAamQUYAawKQISIh0aImAIdHvFkjgPgre/pvOgVwbNGh6DMI52j8GynOFxJ044gJcI2HJS/9f+2da6xt13XX12Pvc+491497fa9z3TTGJSl+tIUISiCtnVhJqYG+SEIoElLSQhshldcHpH5CCiWxgCKkhCalBYRSBIjQFkxMaxpSWw5u49TEqW0luE7b2E4a5CSOX9e+556991rMQ9Nqjt/c3uPMs9Z+/8+Xu8Yac44552+99l3/MefyFGi2VWKVbc6BZnkqYPQPAaAt7OXtZQhQ8Wd8rhFAhY7lc22eb7n1u5b35pB3VeDZP8ajgks/6+famLKeVG/mnFFDhZ/j5Zz5pIMddyTtIR79vN7weELtNMMtKeDsYHuHOWrxH/sX+6ZtU/FnfPqnxdikfVT8ObaQUfd0vC8o/lT4Y7e2RUAEFkgAv6YX2LKaEgEREAEREAEREAEREAEREAEREAERWBgBK58trFk1JAIiMI3Awf6lJ+P9eydnZwDEZQ+3OWeTczKpOFeehIUGIMiHNQhsgbKwq/5zTYAad5zJ2JYvSrtGQJmkHNiMANt60O9b20CSEcAKsNOMAPQPc/S5hkCu4uqV5/FiRoG3ZgCGV0DQptu1+R35puMcee874JzTyq8EsP2UJ05QZ4RlzeNtK/D8HmIS+MhPsTABvTUCTOEjGOn4j1ApLgLFmtc3MzKooFJh55xpLyMg7spRttm+Vye3vBfP89dQnL3yuX6uOp9b3yvP5wePH48vr2eej7xe2T7LJ367BEBIcLA7qMizft92iwwr25u+W1u/eC9deOmxzF7bFCu/cl5KnB9PJURgawjk/TraGiwaqAiIgAiIgAiIgAiIgAiIgAiIgAhsFgErl23W2DQaEZgHAb5x9iZF8422+QoA58Q9/+KLn4s7ffrM6dfGNrc9xYTlaXOVeG9On6ew0N80VlHNzQiYYFGBIe5YEPyLg7E9PENrhkml9nCNsOo6+Szb5vFlRkBu/xqugcA1EhCQGQdwhwyQBb9DpkKNDnFOMfmheNGWOKFYwLORQTMsbLzRmJe/DeitEWBLH86JzuONj4QwXGKTX1IgcwcV48zqSXEqvkmBjjsSRbtjvN6r4/zPjV8igyn3qw0eH56f3vnE69M7X7sef9Znf8nTGy/Lr5vNVf35/Kc/d3wPPfKQlwEw+waZ26DKi4AIHJlA3q+JI4dVQREQAREQAREQAREQAREQAREQAREQgVUiYOWKVeqZ+iICm0mAb7zNh8GffOJ3fyMe9h951Te+Pba5TUWYigrLL9qmwpKbEVCUs+f8czw1VoFHAkFRtRb/EBkBjOdlCKRrBtgIXCPAen0rjW/f2XJ8jFhXdrzFoN9bflXaFIsW39nmnHD2L9emgpfUh6TZWeFHA+n5bMeP4sWQvDPXDBiUWLUcaxSUOJ9rT0JFB/s+Pggf5mjb85UZAd7x7No/ts/+dbYxPirsHJ83B75zfxAgXbPDHo+wagxqZJoYP2t7zyPvdCU/fqWlquz1wfbT+ixhba88r/+k9a6LrNjudLeqml1c6DIFDz/88G91H4SJwB8EPZ/Qpi0ZIrDRBHjxbPRgNTgREAEREAEREAEREAEREAEREAER2FYC/cpB20pR495mAnwj3ema+k8f/rBZA+CWm9+w0mypiHCV8BoKMfUmrqrONQKK1q4h4H0loJ3Yw7HT2HecDb460LT2qwOE3XeGgJcRkCr+7JH9jHJth8fCqT2yGQG1WZEiLT5p7OnsrQnACJ6ixvK59ri1azqUhR1fbjyvPM9vlveuh8JZM4DxPJsZLyzvKaQcT4VV67053GwPi7IXSMhwMwLSeN0EyzTjIO+C4XjS89kRWKEIe4p3qtjnjZ/HM+EJxb+lgt/aO3SSQYHyA5zP4QCbJr3xpjxN9SlrXpC3LZ8fz9anheHQLdsh8OCDD37BKeK553tD91qXXwQ2mEDe03CDQWhoIiACIiACIiACIiACIiACIiACIrDJBOzr2k0eqcYmAscj4F0jfIlGTdVKqEVB/17creFweDq2H37kM78Z29x251hijjvLJzYkDyqaafscvi3hKVK2dFFMGqtwJ37qAaVV8NuRzQBgfdrj0QF3zbS9NQFmVu7B2TR2vD2ENCHaA3s8yx2rCKanr6meGFwTICmQvcMq/l71Bopl7vnoxZ+3n3P8uabBDqb4egoo59STh+dneW/8jMeMAK/+vP243SXNeTwLKPxJgDnvYMYAm3PXHGjt/ZLlkzUNsMZK+vywPXD52eJTFH8UgMn43vMK1UPCgn28M+MB7qR/bWHrVz0/bzmeGl8BoX+ANUeq2t4vWZ6r/Od+BYDl+dWTc2fP3hAzD189eiG2wzYf+HzC76O8PWHhDKbnT2tojwhsKQH7a29LIWjYIiACIiACIiACIiACIiACIiACIrDpBKhObvp4NT4RyCXASYf2lX8ajW+weY3xDbWRoMMb+otxyPFodCG2B8PhZbHddTv5igDndKIBKghwJ6ZXnooi1wRgwHpgFfAJ5vSXWDLAywgYDM1HGIpmbA831wjouiYAx+PZXRX/CfQVb85/qvjbHnKNAm9NA65R4H21wLZ2aFkFK/XbPVT8rbdIFDz6eT7Sv2ibij+vpxJrbCy6f7ntUVHNrd+1PBVfLx55UyH3FPgkPjJSEn/mjoYJOk599nfczH6clVD8GZ5z/FO+vH7t4498GT+NZ0t49W3pon/FH/h4vysJiB3qaFdssGM8rzoVf5afjMePcl+mzd9PXvXMK8ALJ78IbA8BZQBsz7HWSEVABERABERABERABERABERABLaYANXJLUahoYvAXAjwjXbWNXfhwou/E/fqqrNnXhvbXbc5h7NrvNz6VHD41QBvSn8NmuXIKk5IECi8jIBqYCUdTrmvkGLgZQgse82AYW0SSsIa4CezDlE5QUoFXhl3zQhoCxtw2Qo8z8d594dz/Gsohg3Ot0FpFdTJxJ6v3qr/WQe/h8KegttDE72G4PFn8PR+Cf7WLPgVBRxehk9s1meBtD+2BDO8kgyGzA5x1X8e35SfzagKOTi2g7AYD243g8er33mOP45v1zUgyIu2N+effJZtv/DcC4/EfQhz/pGDFnuPtS3F/1jYVEkEUgKz78Zpee0RAREQAREQAREQAREQAREQAREQARFYQwLQz9ZwBOqyCCyXAN9IWwk6s298Y/5/n3rqoTjE6TOnTQaApwB5/jh2H9tULLyYE0xiTec8W7xNYxXpqrUJFpOhfaeJJQOK3IwArhGQjAcBmRFQDHA6jK2Cm8TreUczsIr/TmPbHw9OmBYHY7vo8gG6763RYIIdw6ACVuC79OPWdmhY2fOjxnfOR409H3K7lPbHRuiaIVDWdg2KAt9hr1q75kWD8XPRa1xOiWLatb929GtgYZV+zoGnIs7zrXDm7HsCOjM6+FUEj6AXn/WZMeDd/5khwPJsnwq7d32wf7S7xmP9NL69/r01KJJV/an4s4EVs7nqP7vHVf9z/SxP+3987KMPcB9s+8AuCtooLlMERGBeBOzdcV6tKK4IiIAIiIAIiIAIiIAIiIAIiIAIiMBSCSgDYKn41fgWEuAbbytpFoWRBB/97CMfixnddOMN74xtfne4gcIblz3SdjWwkmphJdS2tB8eH5TtTI2krWz5srGTlnMzBtIxGFxFPTEfVSiYEVBMrAJeDi3+0gquQU+25dk+1wxgisEwWUTAtuetEVBVNuOBXwWgon+A71Czv4kf/WNGAN8Qs332j+3xfOT5yu+Mc00AxuMc+LCutykS5syb8xefwQ6Hf8IhmfqeQQW9qwLK8Yzx1YOKU6i9DsLP/sLtm1DA8Rlyv/7SS8wGSMWb5xO7T0Wcfl+Rtucr63s2MwjYXoWMGS9eha++MB6/wuGtMZHWn92D3OuH8Tmnn615ij/bn322hOjIKGF7zCDodrQZPayggFX/K1yfrMHxJX4PECs49q/cdZeXAeBESNx8APP+be73SW3tEAEReFkCvJhetqAcIiACIiACIiACIiACIiACIiACIiAC60ug7xeU60tCPReBoxHwrhm+VNtFWGbd0L8Xlx8Oh6dj++FHPvObsU0FiwprXPZwm+UHVBQg8VFByFXsWZ/9oe0pllwzgKuoM174LrHZNSmtxlOO8gSEoCCbeF6GwMHYxueaBSbYFMPLEGAVT6HnHP9y55QJ0R68aGxmBBjnsQyb0eCGcBQ3rz4zArzyXTMEvPj0MwOACicVX+/6YHzPZnyvvOfnHHSvfLYfayR4c/S9+F0Vfcbn/Y7Hi37WZ3n6qchzDYi0/Ow9Xn9Ym+cn/bnxWN+LT8U/V8Bm/6jYe+cDMwDw+Az5F/bnAZ+3FTrM/tAeDuzPBS8DgGsAMB7XAGB/6OfxYXk+D6+66qob4jpc0yj4Xor9YZtfCbAP7NSP6lj0JVnEg8Vli4AI/AEB/mflD/brXxEQAREQAREQAREQAREQAREQAREQgQ0iYF8vbtDANBQRWBECfKOddc2FN/jmQ+4vXbz4TDyuy07tnYnt3O1Ji2XU8Z3mssQk0cLO+ecc/xp+9ocKBv35tl0DgBkCNWkjI4Cr9HsZAi0E7BJzttuRzRDYGfAdq+2vlyEwdOb0M0PAm5NPRR/DKRI/2md7/vFiC06Njoo/Fa+qsHP+E4W13rEpIUVtUzac7vadMcCvFjBDwOlOtttTXBnQyxhwFVQGzLWdOc+54Vg+lwfr0+b5SD9tvzxOV9yvGY921/H5/WOL1vbaz1X4vf5QkU/oWcHeneOfq/jb0ftWdoYdFzlBE56ij+LZ5mg0etSpZBfl8VNW+HvJCZ+56IUXTX4R2CIC/HW6RUPXUEVABERABERABERABERABERABERgewhQH9uekWukInA8AhQRqCF4UfmGm9cg35ibNQJeuPDiPXEDIQPgbbHNbc5BLLHKfw1Jw1VUsKo/26Nd1nZV9mYyMi8dmRFAu5lYQdbrH9ufNBYvMwK4RkBtExwKZgjUUCC5hsDYEby9DIExvkLA8dD2MgQ4p59z/nPjee3Zo3sY3R4/tleMzelQ1JnL3rfIwGB8Ko6DgT1AY1zN4XrJvJ5txgDrh++smxaqloRsc8PS8rK1OTrfzr1ekgwJNEGecG+cmctv2QC84zPv8XjtU+EnL159Xn+p8DOem5GCjCM8DhkumeOfFHB2cDy0nepF11X/GR+3J+duGu7muCE9/viTn2DMTJu/h7zq9gbplZZfBETgZQnYX18vW0wOERABERABERABERABERABERABERCBdSZg5bF1Hon6LgLrSYBvwI3iz1V0P/fY/7k/Hub5q8+ZDAAs4h8XPdI2FcDamWPIOf9U/NnoAKsa05/YdbKn4w47B98L5mUITIZ4h4qvBDB+ObQDYkbAYJjXv/GICSO2xfJyLBFxaXZ5Wzu1qF/b/I6ioM3yScSBFXQmyBio2/k+ogYlUj6Q4TGm5IUBUPGHOzH5VYI0IyCpYnbkKoaeImuCB8O73r01ABhv22yPd9/82F7u+cHjw3ipH/c7FoBNRR/u8FWa2fE6K/xsEIp/hYyb8J0c1phpM8OOhb2EIs75Jw9mxDH+vG2v/7/8y//dZCTy98qU/vH3zpQiZpd9QBiXDBEQgS4E8u52XVpSXREQAREQAREQAREQAREQAREQAREQgaURmK+8srRhqWERWBoBvrG2kq/fLb4hN5Lwr9x11wNxiFtufkNsFk1jV6GvKnuJ12WDl372KwBBAbSTkk30w69QW8WUCkVVtib+pE0kFhvRWVOg4qzEZjKzfyxuGwtzGBt7eKra4C3crwgwIL4qUFeIV5op4MVkYif5MyOA4ZkhQL+bMWCHW/DkYn1MyS+qA9v/XWsWY4yvam0LVPDpb0p7fnJ8nj1ExkD4KoWp0kDRoyKJKa2m7qExgATmZQQwQJIhMDkw5y8VP9bn+Uo/FWUquF3jsz3Gp7+r7Y2na/xl12eGhXd8vf56x9c7Xrlz8lteT7j+2F/2j4o+biesHua8YxcUfHjDGiJ2D5aQSfxU/Nk/Gy2UTjpkS+B2kWQ4kIetvXqWkwBVvPvd737Y6bV9IDiF5RYBEVgcAdwuF9ewWhIBERABERABERABERABERABERABEVgcAb5fXVzLakkENoOAdw3xJZuZ4x8QUAKlfy/GtBv+Yvv+Tz7wq7G9t3fyVbHNOfcDSiTJVwFsBgC/EsAMAM75r5EBUDgKf9zXadtBcJnJt2nxHWBkCDStlcD5VQG26SlyzSRvDj3XEJhAMWf7zBCgv6vtZRR0jc8MAkzx56L/SYZBs4PDjTULmLHQYBl/ZgBwPC3m+Cd+KJz0e3Z2hkA7xoBtC975aEsfKpT2duMpwKxPm4o8/Z6d238vHsfnlV91P/nkjo/H11P0C5z/ZaaC7/Gkgu4I5l641I8MAD7O2H4aYPYeT+FnbSr+iR/XI49v7hoAVT00SRN8PjJ+ifbZP2ak0M/x7e/vPxqXOX/+/F+K7bAGwAuxHbaZAXAJ/n3YNoURzmDaB3qB539aXntEQARehoD9tfAyhbRbBERABERABERABERABERABERABERgvQlQfVzv0aj3IrD6BPhGnNcg34BTcjYZAJ9//Im74yHfdOMN74zt8B3y2CwmlZ3zP3QUAlM5GFT86fcUfyoWSX1nBxWOMKnfKqj8agFoVgP7zpMZAlzTIM0YsHP82V1mCNT86gHWDGD9orbfqa/tkgtJce44aHi62BLemgO2dL41GAE4QjAjoBjYw2fPzlDZ+SpChfoUiCZc8gL9Sc4n+DmHGO7E5JoBSQFkpIQLyhRp4ff65ynAXPPANHYEw1sjgSHYfyqMXTMK2F5Xm/xy43E8jEc/4/d9fL05+vZpkGaMsH/eeOgvkhPGXt+Mn9i4HtJV+ikA2/t5CUHYU8CT9p0dVMS940c/FX+nubAGgh1f7vOzxZo3uTy4BsB99933i16f4ecDgb9/UDwxecB5CicVtEMEROBoBOzd5Wh1VEoEREAEREAEREAEREAEREAEREAERGDNCFB9XLPuq7sisHQCfCOdKXkkc+RmXpP8zu4Hf/qn/mtM4H3v+6l3xHYIltufuLq7ncz5x5w8T7GgQuI2iAJU2DnnvmJGAOoX1CfotwJtSHCw70y9DAE3IwDtoTl4i4LjY4EdfIWAfq5B4GUYsLy3RkFuhkHfaxLwqxFpgou9XD05KndOMDNuyJ9zsOnnxeop+J6f8Xm9cQ46y+fG5xx0CsK58difVbO98dDPDAlvPOTJNVk45z05f7CmRVIeX81I+tPaGyTbZ/mmtXewVMFnDWuTF1fpt6VTy1O4qeBT4R54z4u0SbOH15dxTjFYns8XVuGaN97z1ePB+J79oQ996GNxGf4eiX1f3/ZusVOqaJcIiMAiCNhfs4toUW2IgAiIgAiIgAiIgAiIgAiIgAiIgAgsnABfGC+8A2pQBDaMgHdN2Une6VcAmAFg5vwHVuarAMPh8HTM7/7f+N/3xPZlp/bOxPZw54SZUzcc2uYGA/sVgEE9MJJpovhjlX9PkaDiEfftcDtXsfAUHc6BZHueAtpMrAKW1Mec7TQjwNbw25s9h99Gy7e8DAIvIjMCvPL0lyNz+oWvAszmW45shJZXj3UXLA+3a04qez2wApbUoDvb5pxl7/xgA+71lEqqDJFle3PavWAcH/vf1e+1v2g/x5PbfgmFPtxfzR/PxwrlTeFDA4o+16CgYt+0szUilmfGQtK+syP3/s9wfB7Q79k8H1ne87M85/yzvqf4cw0ArrHD5y3jkyf5eP79i/tfjMd0/prz3xXbUzIAXor9YZur/jMjgDbu+IiGDMPEqx0iIAJHJjD77n7kMCooAiIgAiIgAiIgAiIgAiIgAiIgAiKwygRmyx2r3HP1TQTWkwAlz07XYHjjfzHG8MQTT94Z2/wqQNvM/lL7BMueD+yUzjAlszYZAXFb07Y9RYJ1qFBwjib9Xv0CczqTeAwAm/2nosc5501pD2/uVwaq6gR6YM3xyH42uartVwm45oCtXRRcM4F+1/a+YuAFwAk1oP6D+uOh5TloeEKiAtzMMPDqt1DMB8jwGGNRAW/OPxVa9DasWW4lXU/xZf3ExirqiT9zBxXe7nO87XjZHX41INfP8su2eTy9/lDhp0LP+kl5FoBd1fZxwwwUzrm3+WAI9v9NqyFRUZ5Wo8s+7/7P2Lx/00+7Kex3SMJHY+wAUYEK/7gtzfMxrCEy84RnxliSEYDnLRV/dKezyefjfb923885QZmyZm/YaWXe8VmevG3KWBpPe0RABI5JgBfbMcOomgiIgAiIgAiIgAiIgAiIgAiIgAiIwCoTmPl2cpU7rr6JwIoS8K4pvnSDZllYiaYoKAmbNQF2d3cvjznccuut3xLb4asA/yW2T+wMTf92dnfNG/agwBn/7s6OUTTCKtDGT0WCiounCDkCSdz1qduMz3hUNLw1AdjIxFkDgOWZIZD4EY8ZAiyfa1NRYn94fOhne15GAcvn2t6aBFxzwPtqAdtvxhSYWMLaY3x323rDGhWO31vlnXOqqbB78dmfJIMA30Gnn/VTBZglFmsjAWmxjc+jNc6599pwMjhyv0pRVgNzf2fzZTExz6O2qGeW9+rz/svy2Tb7jww23s/c+IhXFY0ZP+u35eyMN++rCIxXF619fuKrMlU9NM9b1veetyzvZdTw+cn6V1x++XfG+8bj8ZdiO2wzA4Bz/m3KWpF89Yg3aJ5/M3mgLzJFQAQyCMy8+WXEUVEREAEREAEREAEREAEREAEREAEREIEVJkC1cYW7qq6JwFoQ4Btr88Y/jIBvuJkBwEFyzpwpz1V477v33s/GAS4899zDsb1z9sxrY7uAohLmzLP/pvi8DU+RyFXwE0XKWROA4zOw6ezB5hoCDJn7FQLOIaXN+L5t1xhg+a4ZAu6aBB3XHKiw5gD7Tzt85YK7jF3zw/bGeyhvzX6k8o175Si+CF8wo4U3F64azvKMF3Ia0l1L3BPmXJs/b40FU3gFjDSjYvYdhBkPAwDwxu8p/ERSl1bxnrRW8fcUbSrmTTHk88w2yecLFHhb+HAFAtu/8Li0ZwTW4GD9AvEZj531FH7G9/iwPL+iE/zmBhPuz+YCDGvwGD8z7hg/1+bzlc/TJ7/wew/GMUMGwdOxHTIAYvNwmztoszxtHhL6ZYuACMyJgL25zqkRhRUBERABERABERABERABERABERABEVguAfP2cbldUesisJEEvGuML+HMHP9AhJLiTD/XBPje7//+PxdTvf097/1gbIc1AGKz2D1xkgqE9e9YRdibk0jFIVHkTfSgRzoKD+t7CifLMz4VEHTHNdm+F89bU4Bz8jnHlX52MDdjgPU9m2sMuOUbK/B0zRjw2qOfawww44B+1qc9LqyiOyg4hdXWaEp7+fJ40uYq5EmGjg2fbfH8YfsMyPL0d7V5/XSNt+71eb8in9w5+rmKdXK+QVH3+KaKu11VPznfkCGQnP9Og0k8lC/DH3Z1MrN5orUpGQGmhKf4z/t5+853vONNcYfuvPNOM+c/ZBy+EPvDNhV/rgHg+REuyZA0v0dYWLYIiMDxCfA/H8ePpJoiIAIiIAIiIAIiIAIiIAIiIAIiIAIrS6DXt6MrO0p1TASWR8C7xvgSzkqMaQbAzK8ChGEaiX44HJ6Oh/6/7vvEnbF99uxV18Y2vwqwg68GUMEYDqxkT0WGijsVLvrjvkzbZv1pZWbty23PU/TZFhU7+r14rO+VZ/xcxdbLGEjit1T0rc3ytL3+LTpDgP2jnWQI1ObyKjgenv+MR3/uHGTGo122E+9+Y6qw/8a5ggb7S565/r6HyOs3+36Vqbjn9r9tJkahLavapICF54VReEejkfnKTG57zAjgHH0vHut7GQIsn3t9dVX4OR4+L+n3FH+WH2DNAPppe18B2N/ffyiuc8011/xQbIfj/2xsh+15r/qP5uyaCXTKFgEROD4B/ufj+JFUUwREQAREQAREQAREQAREQAREQAREYGUJZKkFKzsKdUwE1oeAd80NMRQ7iTjNCDAKTqi7F9cPawIYP9cE+Il/dPsH4vKn9vbMnDtmAJzYqU3/+d1iKhRU3KmIef64b0fZZjwq6PQPBxbvKF3l+CjN/mEZtveHjq9vUCGk36vP8rS9NQZYnopp4p/MnuOelEeGAP20vTUFvP4xHu15ZxRM2tnv0KlQU5EclPa74OPWfoaAimTZWIWf5b149JMX4zGjgP3P9bM92l2PN+Pl2onCjDnqiYJNPxtcsqLP7qy6zeula3+7rgHA6y+3P31nALD94XBgnsd8fvB5x/pvedvb3hrv41eE+JWhUPaluHzYNhklweacf9oj1Kdpfn/QKVsERKA/ArN/vfTXjiKJgAiIgAiIgAiIgAiIgAiIgAiIgAgskYB5e7jEfqhpEdgWAt41x5dyXBOAawBYCbsojOLPrwIEhe1kDPo//8Id74/tb7np+ltje+/UZWaS92BgMwB28VUALwPAm5PIDIG4L0fZpuJBRYQxWL7vjAC25/WH5ZkxsPD6WMWf/aPtrSnQ9JwhkLSf3V87pbXCHP9JZjz2h3Y1GK6VwsWMA46HGQP059rMKMitz/KJos8CuTYUfy8+56R75XO7s+7lqfjnZoCwvscjNyMgNwPAU/zZv9w1AFifz1v6+Xz74he/9GBc5k/88W/9G7EdeF6M7Smr/nuK/35cP2x7KWTm90Uov1b3R4xVpgisFQH+Z2OtOq/OioAIiIAIiIAIiIAIiIAIiIAIiIAIHI2Ap0YeLYpKiYAIHJcAr0G+lGMGABV/2iYDIHRq5poAYc742bjjn3zgU78U26evvOJMbO+dOmXe0FPx8L4KwAwAKhRxW4fbzAhgeSri9DNers2MANbnmgEsTz/re/2nnxkBjEc7tz7LM16uQpfUX/KaAuwP7a7jYzzabVWb64f+TbO9DAKOt++MAsan3Ya/eF+uYk9FmfHi2Ifb8z6/2B5X9d/fv2RW9T9xYnfmqv/eVwPYXm551vfsXMXfi0c/jyf9uRkBrM/nJf3zzgh469vfvug5/8wAkOLPgy5bBJZEgP/ZWFI31KwIiIAIiIAIiIAIiIAIiIAIiIAIiMA8CVA9nGdbii0CIuAT4Bty1vCuWb5xt5OcsUZAUDzMqr7/4d//x38dN/iud/3Ij8f2YDgyilmxU5gMhrreMf7whtH4qWAzvSFua9o2Feq+FX+26Sn4Xct7/ae/cuakky/rkzd5NtXsd8KzvaSRKp5VbXvQtJjziVXuqzKzRRs+6ZD31YG+FcZJYVf1Z4eoCI4nY3O9sPy62bkZDzx8zCBgvMnE3i75lQMvo4CKb1vYDI1wMMzxoMJPu6vCz/FQcaa/KlszJ7usapMBRsWf8ehPz6/SxCvA2yvP48n+sz77R39Xvt71zePJ84P99/rL/vdtcw0A8vmXP/tv/lXc5sfvuefzsT2FB38v8PdEXP1wm6v8e+VZX7YIiMCSCGT+ulpSL9WsCIiACIiACIiACIiACIiACIiACIhAJwLm7XanSKosAiJwHALeNciXdBRVmBFA2yo4zpoAQUHo9JWAE7u2Oa4JUA9s97w5/p6fCjYVb89/nAM2q05ueyzP2FT06e9qe+0zvtef7HhORkPa/uyMAZan3fUrBFVtL0cvo4Dtl/XQStYosGkZABhetknF1QtABdcr7/mpqHrlPX/ueLx4VKD7ju+1v2p+8vD6N0UBN1WYAWCcU4zc9pkBxJAhnvf7wFR54oknfy/e8Wde9+3fF9thvFzl32SQhLImIzDY9FPxp80MgJn3uxDfZAzGfdW2CIjAfAnYXzPzbUvRRUAEREAEREAEREAEREAEREAEREAElkQg6+3ikvqoZkVgmwh41yRf2jEj4ARgWckdawCEsjtx+d3wF9v8SsB9v3b/h2P/2avPvTK2T544Yfq3d9J2h3MWu34VIG572jYzAliGirVXnvU9m/G98p7C7tWnv+/2GY+86Gd/aFNhpSJHP+vTTtYUSApMZl5fuRkCDB/mkHuKl6mybYr/vBVqLwMg93wyB2uKMe/xTGnS7OIaAKNJY+7ndVWb833STIzi6vlNY8EY1pWZI960do0AL35uf9l+rp2ryDM+70f0exkCXvtuBkDZmucp23/+wotG0f/m17z6u+Iy4fn6dGxfCn+xHbbN8Qw2/VT491Gfij/cBe+H5vxjYdkiIAKLIzDz5rK4bqglERABERABERABERABERABERABERCBeRKgOjjPthRbBERg/gT4xp4tMmOAfpMBEBQOMyfwlptf/1fjCp984FO/FNuDqjgT25fqyr7x37FfCQhljULFzlFhjmNP2+67PBXt3PjT+tjnPvbPiz3vDAMqrFTkw4xWc7zZ3/Ek1Mj4YzzabL+o7CrvBb86UA9N+0HhNXYNRW7SllS4TO/LYmJeso8m9rvzpvACDCrWVChz/Qvosmli0xR+TzGn38AIRpBfjeKf+KH4U8EfTSYz6zMeMwyC31wfLM/+5/aX8Tw7yWhwvlrA85/xeT9jRgDPR2YE8Hpi/LCI/sz7YVEPzP3l0sGBKf8j7/rRvxbH5PM69n19m4o/fy949pSQZpfpr/HIEAERWCkC5sfJSvVMnREBERABERABERABERABERABERABEeiNgHmb2FtUBRIBEeiLgHeN8iUeRXRm+dA2in/o9F7cca4JEBQR85WAN77pTX80Lv+T//Sf/dvYPn3lFSYjYO/UKaMY7e5YAWo4HJjxel8BoD9ue9o2FXwq6PRPixHv8+rTH9c9yran2HvxWZ+86Gc88qDfU8io6HsKvec/CrNeyyBDgLG9DACWn/ecfyqOVDjpZ/+62lREu8bz6vP888rT3zcPKt5sb9Xs8dhO4R4M+PiY3WOvvuefHb1/LzMEclvg9cT6zBBgRgDLe/ECP/O8/OG//kN/OY7x8Xvu+Xxsh/bNmgBT5vybjL5Q15vzz4wAe8LEjf/+NjMATP/T4tojAiKwLAL8z8Oy+qF2RUAEREAEREAEREAEREAEREAEREAE5kjAqG1zbEehRUAEjkcg9xodohkq/p7NjAAj0XsZAUEBNBkE/GrA1VefvTbuX98ZAXHsw20q3vRT4aafNhVwrz7LMx5tKvL058Zj/dz4HJ/XPhV/fvWBfvYv1w4K7kopTJ7Cz1W/vfLkQcWaCiL9rO/Z66bge+PpyoPx563we4o5/ewfFXyvPOsv2vb6m+vv2v9FZwhURWNEuNF4YhR8zvE/huLPOf+5ij8zAEaZjFfq/pzZdxUXgY0mYG4+Gz1SDU4EREAEREAEREAEREAEREAEREAEtphArrq4xag0dBFYCQLeNcuXepzU6WUA0N9rRsDP/+J/+8cxxW/71ptuje2Te3tGMdg7eSJ2F1SUw3eOjZ+KtXEGw8sIYPll255iz/5NJt4UTdaYbecq/n3P4afCHxRvc/7TP3s0vpeKfFfF3m+xW4m+Fe7cDICuc/A5+r7Hw/i0563osz1PkfcUb8aTnUeAfPNqh+dH0ZoHTlnZVfq9eHVpFf+vPfPcF+M6r/vTf+oHY5ur+h9hjj8Vfyr4uRkA3gNFc/7jA6ZtEVgjAvzPwhp1XV0VAREQAREQAREQAREQAREQAREQARE4KgGjphy1ksqJgAisDAHvGuZLPqNghFFQ8afNDAD6O60R8J7b/8kPxyS/53u/70dj+6ozV8ZmcWLXdmc4qMz46oHtHhV/ZgjQbxqbYrC+p5B75anwsz/0s0te+yxPu+/4VITDDH2T0cH2c+2+FX+v/UVnBOQq4LmKvTdeHj+vfN9+jj93jQMq+pzTTX/X/nuKvhefvLmKPOt3Lc94tL32Wd7rz7L97K9nD6rSPB+bxgrgVWXcXriC9T/z2Uf/XVzpB9/+1p+JbSn+MQ1ti4AIzJOA+fE8z4YUWwREQAREQAREQAREQAREQAREQAREYHkEPPVweT1TyyIgAsch4F3TfOlHScNK6GmGgJXgU3+njIBb3/zm8/Gg3/sT7/2HsX3u/Cu+LbYvO7VnxrO7Y5pf+TUDqMDnZgDELA63mRHA+Czv2YzH8t6q/lwTYDAcfiWOcXAwOhfb81b4F63ox2M73KbCTb+neOcq/lRg2V7ftje+ru31reCzP10VfcZbNH+2v2124G2eB13HHzIizBx3rgEwxg324NL+s3Gbt9/+nn8Q2x+5445Px/YaKv5x9w+3e83wYnDZIiAC8yPQ681yft1UZBEQAREQAREQAREQAREQAREQAREQgS4EPLWwS2zVFQERWDyB3GuaLwGXmhFAXEHx3Iv3cc2A22777r8S+8+dO2cWDdjZGRoezBAYDgfGTwU+jj1tm3P8WYbxIBixeKLgJwU67vDaZ3gvA4AKJ+cQexkCbI9214yAXMV/3qv+ewp5rsJPXrR5fOinzf55GQms39Wet8LP/vWt+DN+Ln/W79sO4zX3u7AqvlFw6e+7/VWLV+EGPRqNDR/2dzKyi+h/6sFP3xGX+ds/9jd/Mrap8Me+w+1wv7wY77sU/mI7bPe9qj/CFyPucGxzvjhl5RYBEVhhAvzxv8JdVddEQAREQAREQAREQAREQAREQAREQASOS2Dm287jBlU9ERCBlSGQe43zpWBuRgDXEOCaAWaS/m74m0UqKGgnYz8zAmLf4fYHfvpnfzze9x3fefMPxLb3VQF+Z36A785T8YeAVNBPBZ3+uG/Ttll/WpmcfV4GANtjf4NCZhQg8pm34n/y5O7T8XgvXrx0Nra5nZsBwPqeTcU8KHrPx3XC+XtFbHvbXgZA34oy++/1L9dfla1RNEeTxlz/ufG88n0r+n3z9vof2jP33wYX7KYr9pPJxIzf40U/jxczkugfjccmRDOxgvuXn/rqvXGBD3zw/e+L7Xvvvvup2F4Dxd9+1iDu/O9vmzUPwi5zv0+La48IiMC6Euh0s13XQavfIiACIiACIiACIiACIiACIiACIrBtBHLVwW3jo/GKwKYRyL3m+ZKwa0aAV98ohMwQCAqOyQjgwWGGAL8q8Ld+7O+8Na7zx17z6rfF9hWnT5s1B7w1BKiAUzGPYx9uM2OA/r5tCIjZ4ZkR4AXomgHA+EGhnqlAzVvhn9Ifsyt3jvyiFf62mRgFvmnLmRk3ZnBTDCr6jLfuc/ipEE9BMHNXqM/7pVO+Nec356QzA2BmsAU4cxX6rjzdIU0MvmLUWoGbiv7+2FwOxfNPvzBXhZ/9P8Icf9vBorApCr7NJi0QeotCin/KRHtEYCsIZD2stoKIBikCIiACIiACIiACIiACIiACIiACG0ggVw3cQAQakghsNYHcewBfGnqKPtcEIGwqkiw/MyOAwXIzBFifXxn4C3/+tu+Jy1x5+swrY3tKhoCRpLw1BeJYh9teBgHLd7VzFX62l6v4B15fjWMcHIzOxban+Mdlp20zI4BlLr/slGn/2edeMO1T0Q/6rJnTX5aFmdNPf1BIL2ebs+yuCmnfc/ip8K/bnH2y7sqXq+JToU/bs4o+/X3bVTt5KY7ZlLXJYKJiP6yKC3H5UVNcFttdecWxDrc5x35Y2scFFXvWp01Ff9RYgfvgghle8buPf8Gs0v/zv/Dhn4tjcg5/7DvcXsCcfjsAX+FnRgDrcwi0pfiTiGwR2FIC/DG/pRg0bBEQAREQAREQAREQAREQAREQARHYbAK56t9m09DoREAEcu8JfIloJZ6ioKJPwvTT9uItNEPg7/69v/+WeABveOMb/2xsX3/D9bfE9skTJwzPsIq9sfndeWYMxLEOt5tfB9gAAAuQSURBVKuyMPW5yjXLezYzDua9yj/7k6v4U+EnP8Znefo921PYvTn9jJ+rsHrtM75nb/scffIJx4P3L1Nk0XPwqehToTedm2Lknl8MkSj2A3s7pp/1PZsKPjOQxlh0ftJYwftrTz3zaNzGJ3791z8S2//i/f/cKP6xb9p2Dwo/w9rPCKSKvjfHn/EsgKKQ4k9CskVABI5FYObD71gRVUkEREAEREAEREAEREAEREAEREAERGDlCBg1aeV6pw6JgAgsm0DuPYIvFangczxWYvIzBrLWDGBj/KoA/UFBy/rKAOvT5poC3/H6198Yl7n6/PmbY5sZA8OhxRO+A26Oh6eAM6OAijv9cV/62GZ7fcSMY+Qq/LmK+qor/Os2Z7+rQh0f+8PtZSv43hz7S+PGzLFnxk5XHlTkh1DsyYvl6c+1qehzTj4V/OLSyDTxtWcvGEX/t3/7cx+PC3zsVz96V2x3nbMfxzrcDsfjYrxvyir9sftw21P4qdBTwfdstsd49HNOP/1mTRo6ZYuACGwvAf5Y314SGrkIiIAIiIAIiIAIiIAIiIAIiIAIbDABoyZt8Dg1NBEQgfkQyL2H8KUjMwSs5J32mX7aXjyzZgDDd80QYLygIJtVuemnzYyBG2+86Ya4zDe/+pu+PbZPnDxpFMZ6MDSKEDMIwpxXc7y46n0ce9q2l3EwrU68j4q9F4/l41hH2fYUf0/h76rQeu1zDJyjX1c244N+1s+1wyr3uVVM+a58TLBgUNHnHHyuwk8/43k2FXyvfN/jLZzv2HddNd8dz8QK2lTwWZ+K/uSlfVPkuRcu9qrom+DByJ2zz/pLUPjZBWYA5F6A5v7O4MGW4j8FinaJgAikBPhjPC2hPSIgAiIgAiIgAiIgAiIgAiIgAiIgAmtPwKhBaz8aDUAERGDZBHLvKXwJSQWf46Hi7/lZnvHpZzyTMbDsDAF27gfe8pY/Ge+77ba/+N2xfd1115mMgW84f/7a2D88ccIoUDuD2vBhRgEzBjinmRkGcVuH257i7tXPjde3Ypur6LO/fSv4jN9V0We8XH5U8BmP9qIV/LR9T1Bljdn2vOfYs/VcxZ71vTn5Tz/9VaPof/Sjd/3POMZH7rjj07Gdu+0p+ozHOfv0r6DCzy6a+y2dU2yeoFL4p0DSLhEQgXwC/PGdH0E1REAEREAEREAEREAEREAEREAEREAEVp5Arlq38gNSB0VABFaKQNd7DF9SGoV6ykg9RZ9+2rnxTYYA+9N3xgDj564xwPrMIHjd617/2rgM1yD4hmvOvzr2D3Z2bUbB0K5BEL5aYBSrQVUYvlVlzLAq9+zTpawGVMTi7nTebpsxz7eZMZuizFP0msYOGNGbxoZrisruQHmaVOyrojXtef3NVfDT9ltzvOn3bG9OPsfnxeMc+8IuqRC+qm67O2qzcCfNc1V8Fuiq2HPO/f6+nYN/adQaxZ6r6j/wwP0PxX3qquDHsQ63V0DRZ5e8Off0e/W98rknkHc/sycoeydbBERABI5JIOvHzjHbUDUREAEREAEREAEREAEREAEREAEREIElE5gttyy5c2peBERg4wl0vQfxJaZRPKfQo+LPIrl+tufV75QxwM4GRfQk982yu2YMzIp96PMyCs6cOfOKOMbZs+dujO1rX/WNp2K7reprY9v7zjnXJKjr2ihsXEU+jr2J24ueY5+t0AN61zn0ngKP5gpPkWd5roLPOfQhg8FUGY/td+9fvDg2Cj3n2D/zzDNfjgNQsX/u+ee/FPvvvfvup2K77+01VPSJgIo9ba88/fYA05va5v6TurVq/xQm2iUCIrAAAvzxvIAm1YQIiIAIiIAIiIAIiIAIiIAIiIAIiMCiCXRV3xbdX7UnAiKwXQS63qP4kpOKPWl6Cn5XP9v34i01Y4Bw5p1BwPZoM8OA/m+67jXXxfuuv+H662P7/PlrzsT2eDw+H9uXXXbKZCDsnNoxGQh1ZQ/XEGsYxLGOsl3VMw/vUUJklclVyL3guQo64yWKOgvQvmQVdSruLD5pqy/E+y5cePHF2KYCH/sOtx/7rccei/c9/sTvPBHb3O57Tj3je3auYs943ir7LH+MVfcZwlPkqbh75bv62R77S9tT+Flec/pJRLYIiMBSCPDH8VI6oUZFQAREQAREQAREQAREQAREQAREQATmS6Crujbf3im6CIiACFgCfd+zvJegVOxtb4rCSsL0dvezfa899mCmxOx9pYDBaOeuQcD6tJedYcD+ePatb36zySBg+SuvuOKV3DfLZgbDrLJH8V39iqtPH6Xcy5X5ype/8uzL+Y6y31PMvRic887y854Dz/a62l0Verafq9iz/hEUfFY54A7YVOA9RZ3lEa7o6mc8rz8sL4WfRGSLgAhsBAHvx+9GDFKDEAEREAEREAEREAEREAEREAEREIFtJ9C3mrbtPDV+ERCB1SLQ9R7nvSSlQu+N3lPwPT/js7zXH5ZnPNozMwhYuGtGAePRnnfGARXadctIIC/ZloB3fOm3tfOtrgo9W5yDYs8mPMXdU9C9+p6/7/4wHm1P4decfRKTLQIisBEEvB+3GzFIDUIEREAEREAEREAEREAEREAEREAEtp1AV3Vs2/lp/CIgAutNoO97oPdS1VPoSTNXse+7vNff3PayMgoIY9EZBlRwmYHg+dn/VbOZ4dC3Aj7v8Xr8c/25/T2GIu814c2xZ/1cRX3VFHyOx+sfy9P2FH2Wl8JPIrJFQAS2goD3Y3UrIGiQIiACIiACIiACIiACIiACIiACIrDpBPpWvzadl8YnAiKw3QT6vmd6L2E9Bd47GrkKfW55tu/Vzx2PF4/t0+6UccBgtJmR4CnCXnn62d6m2135zYFPriLPLvSt0HeNn9ufru11VfTZvqfwS9EnMdkiIAIiMIWA9+NzShXtEgEREAEREAEREAEREAEREAEREAERWDcCfatZ6zZ+9VcEREAE5kmg73ts7kvbXMWdLLoq8F3rd+1P1/F3bZ/1u9rMaKBCTT/b88rTz/rrZndVvLsq2F3bX3b9ruPn+eIp+CxPWwo/icgWAREQgWMQyP0xeYwmVEUEREAEREAEREAEREAEREAEREAERGDZBPpWp5Y9HrUvAiIgAutMYNH35NyXwF0V9b4zAnis5x2f7dFedvvsT1ebx7tvRbhr/7oq5Lntz7u9rvEXfXxyFX0p+LlnnMqLgAiIwBwI5P74m0MXFFIEREAEREAEREAEREAEREAEREAERGDeBBatNs17PIovAiIgAttMYNn39K4vlak4930sV02hZ3+oAOf6++aVG4/9z62/7PLz7v+iFXryzFXsWZ+2FH0SkS0CIiACa0Cg64+1NRiiuigCIiACIiACIiACIiACIiACIiACIrBstUhHQAREQAREYHUIrPozYd4vreedgbA6R3o9e0IFPfd4sf6qU5Biv+pHSP0TAREQgTUkMO8fU2uIRF0WAREQAREQAREQAREQAREQAREQgc0jsOpqz+YR14hEQAREYHsJbNszRy/ZN+tc71uR9+hojr1HSH4REAEREIFsAvpxko1MFURABERABERABERABERABERABERg/QhsmxqzfkdIPRYBERABETguAT3jjktO9Q4JSIHXeSACIiACIrBxBJQBsHGHVAMSAREQAREQAREQAREQAREQAREQgZSA1JGUifaIgAiIgAiIwDoQWPYzXAr5Opwl6qMIiIAIiIAIRASUARDB0KYIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIzCbw/wBPb/pby0u4OwAAAABJRU5ErkJggg=="/> +</defs> +</svg> diff --git a/src/assets/icons/music.svg b/src/assets/icons/music.svg new file mode 100644 index 00000000..a95e0cb6 --- /dev/null +++ b/src/assets/icons/music.svg @@ -0,0 +1,29 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" id="apple-music"> + <defs> + <radialGradient id="b" cx="-400.171" cy="-984.067" r="1.587" gradientTransform="matrix(0 31.8923 31.8923 0 31421.78 12852.762)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#7470f9"></stop> + <stop offset="1" stop-color="#7a70fe" stop-opacity="0"></stop> + </radialGradient> + <radialGradient id="c" cx="-489.182" cy="-1019.984" r="1.281" gradientTransform="scale(-31.8923 31.8923)rotate(-74.462 426.955 -829.031)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#4ca3f8"></stop> + <stop offset=".362" stop-color="#4ca4f7"></stop> + <stop offset="1" stop-color="#4aa2f9" stop-opacity="0"></stop> + </radialGradient> + <linearGradient id="a" x1="-392" x2="-272" y1="-764" y2="-764" gradientTransform="rotate(-90 276 -548)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#fd355a"></stop> + <stop offset="1" stop-color="#fd5163"></stop> + </linearGradient> + <linearGradient id="d" x1="-418.42" x2="-418.034" y1="-1040.402" y2="-1041.278" gradientTransform="matrix(31.8923 0 0 -39.8681 13392.678 -41444.467)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#ff6380"></stop> + <stop offset="1" stop-color="#f65e79" stop-opacity="0"></stop> + </linearGradient> + </defs> + <path fill="url(#a)" fill-rule="evenodd" d="M94,120H26A25.9482,25.9482,0,0,1,0,94V26A25.9482,25.9482,0,0,1,26,0H94a25.9482,25.9482,0,0,1,26,26V94A25.9482,25.9482,0,0,1,94,120Z"></path> + <g> + <path fill="#bb58bb" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="url(#b)" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="url(#c)" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="url(#d)" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="#fff" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + </g> +</svg> diff --git a/src/assets/icons/notch/caps-lock.svg b/src/assets/icons/notch/caps-lock.svg new file mode 100644 index 00000000..976875c9 --- /dev/null +++ b/src/assets/icons/notch/caps-lock.svg @@ -0,0 +1,4 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3 1C1.892 1 1 1.892 1 3V10C1 11.108 1.892 12 3 12H13C14.108 12 15 11.108 15 10V3C15 1.892 14.108 1 13 1H3ZM3 2H13C13.554 2 14 2.446 14 3V10C14 10.554 13.554 11 13 11H3C2.446 11 2 10.554 2 10V3C2 2.446 2.446 2 3 2ZM1 11V13C1 14.108 1.892 15 3 15H13C14.108 15 15 14.108 15 13V11C15 12.108 14.108 13 13 13H3C1.892 13 1 12.108 1 11Z" fill="white"/> +<path d="M8 3.933L6.715 7.417H9.29L8 3.933ZM7.466 3H8.539L11.207 10H10.222L9.585 8.204H6.429L5.792 10H4.793L7.466 3Z" fill="white"/> +</svg> diff --git a/src/assets/icons/notch/charging.svg b/src/assets/icons/notch/charging.svg new file mode 100644 index 00000000..408b6c4d --- /dev/null +++ b/src/assets/icons/notch/charging.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24" width="24" height="24"> + <defs> + <style type="text/css" id="current-color-scheme"> + .ColorScheme-Text { + color:#f2f2f2; + } + </style> + </defs> + <g transform="translate(1,1)"> + <path style="fill:currentColor;fill-opacity:1;stroke:none" class="ColorScheme-Text" d="M 10.519531,3.014183 6,12.428989 v 0.550781 h 5 v 5.995277 h 0.480469 L 16,9.5305512 V 8.97977 H 11 V 3.014183 Z M 10,6.5989106 V 9.97977 h 4.449219 L 12,15.360629 V 11.97977 H 7.5507812 Z"/> + </g> +</svg> diff --git a/src/assets/icons/notch/indicator.svg b/src/assets/icons/notch/indicator.svg new file mode 100644 index 00000000..db3bede5 --- /dev/null +++ b/src/assets/icons/notch/indicator.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <circle cx="12" cy="12" r="6" fill="#aaa" /> +</svg> diff --git a/src/assets/icons/notch/keyboard-layout.svg b/src/assets/icons/notch/keyboard-layout.svg new file mode 100644 index 00000000..b20cca72 --- /dev/null +++ b/src/assets/icons/notch/keyboard-layout.svg @@ -0,0 +1,16 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text { + color:#f2f2f2; + } + .ColorScheme-Background { + color:#eff0f1; + } + .ColorScheme-Highlight { + color:#3daee9; + } + .ColorScheme-ButtonText { + color:#f2f2f2; + }</style> + <path class="ColorScheme-Text" d="m25 4c0 0.83929-0.09344 1.4399-0.24805 1.8457-0.1546 0.40583-0.34789 0.62123-0.62891 0.78516-0.56203 0.32785-1.623 0.36914-3.123 0.36914s-2.9356-0.0273-4.0996 0.55469c-0.58199 0.29099-1.0806 0.75295-1.4082 1.4082-0.27288 0.54576-0.4251 1.2217-0.4707 2.0371h1c0.04382-0.68545 0.17093-1.2071 0.36133-1.5879 0.23487-0.46974 0.54879-0.75779 0.9668-0.9668 0.83602-0.41801 2.1504-0.44531 3.6504-0.44531s2.689 0.04129 3.627-0.50586c0.46899-0.27358 0.8382-0.71443 1.0586-1.293s0.31445-1.2905 0.31445-2.2012z" fill="currentColor"/> + <path class="ColorScheme-Text" d="m5 10c-2.216 0-4 1.784-4 4v10c0 2.216 1.784 4 4 4h22c2.216 0 4-1.784 4-4v-10c0-2.216-1.784-4-4-4zm0 1.5h22c1.385 0 2.5 1.115 2.5 2.5v10c0 1.385-1.115 2.5-2.5 2.5h-22c-1.385 0-2.5-1.115-2.5-2.5v-10c0-1.385 1.115-2.5 2.5-2.5zm1.5 2.5c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h3c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm-16 4c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h3c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm6 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h5c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm-14 4c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h11c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm14 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5z" fill="currentColor"/> +</svg> diff --git a/src/assets/icons/notch/lock.svg b/src/assets/icons/notch/lock.svg new file mode 100644 index 00000000..8a26ad3a --- /dev/null +++ b/src/assets/icons/notch/lock.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fff" class="bi bi-lock-fill" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M8 0a4 4 0 0 1 4 4v2.05a2.5 2.5 0 0 1 2 2.45v5a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 2 13.5v-5a2.5 2.5 0 0 1 2-2.45V4a4 4 0 0 1 4-4m0 1a3 3 0 0 0-3 3v2h6V4a3 3 0 0 0-3-3"/> +</svg> diff --git a/config/assets/icons/misc/media-record.svg b/src/assets/icons/notch/media-record.svg similarity index 100% rename from config/assets/icons/misc/media-record.svg rename to src/assets/icons/notch/media-record.svg diff --git a/src/assets/icons/notch/mic-off.svg b/src/assets/icons/notch/mic-off.svg new file mode 100644 index 00000000..5f559843 --- /dev/null +++ b/src/assets/icons/notch/mic-off.svg @@ -0,0 +1,10 @@ +<svg width="16" height="16" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <defs> + <style id="current-color-scheme" type="text/css"> + .ColorScheme-Text { color:#ffffff; } + </style> + </defs> + <path class="ColorScheme-Text" d="m8 1.002c-1.662 0-3 1.338-3 3v4c0 1.662 1.338 3 3 3s3-1.338 3-3v-4c0-1.662-1.338-3-3-3zm0 1c1.108 0 2 0.892 2 2v4c0 1.108-0.892 2-2 2s-2-0.892-2-2v-4c0-1.108 0.892-2 2-2z" fill="currentColor"/> + <path class="ColorScheme-Text" d="m3.5 6.001c-0.277 0-0.5 0.223-0.5 0.5v1.5c0 2.6008 1.967 4.7248 4.5 4.9746v1.0254h-2c-0.277 0-0.5 0.223-0.5 0.5s0.223 0.5 0.5 0.5h5c0.277 0 0.5-0.223 0.5-0.5s-0.223-0.5-0.5-0.5h-2v-1.0254c2.533-0.24983 4.5-2.3738 4.5-4.9746v-1.5c0-0.277-0.223-0.5-0.5-0.5s-0.5 0.223-0.5 0.5v1.5c0 2.216-1.784 4-4 4s-4-1.784-4-4v-1.5c0-0.277-0.223-0.5-0.5-0.5z" fill="currentColor"/> + <line x1="1.5" y1="1.5" x2="14.5" y2="14.5" stroke="currentColor" stroke-width="2" class="ColorScheme-Text"/> +</svg> diff --git a/src/assets/icons/notch/mic-on.svg b/src/assets/icons/notch/mic-on.svg new file mode 100644 index 00000000..9cebfca4 --- /dev/null +++ b/src/assets/icons/notch/mic-on.svg @@ -0,0 +1,9 @@ +<svg width="16" height="16" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <defs> + <style id="current-color-scheme" type="text/css"> + .ColorScheme-Text { color:#ffffff; } + </style> + </defs> + <path class="ColorScheme-Text" d="m8 1.002c-1.662 0-3 1.338-3 3v4c0 1.662 1.338 3 3 3s3-1.338 3-3v-4c0-1.662-1.338-3-3-3zm0 1c1.108 0 2 0.892 2 2v4c0 1.108-0.892 2-2 2s-2-0.892-2-2v-4c0-1.108 0.892-2 2-2z" fill="currentColor"/> + <path class="ColorScheme-Text" d="m3.5 6.001c-0.277 0-0.5 0.223-0.5 0.5v1.5c0 2.6008 1.967 4.7248 4.5 4.9746v1.0254h-2c-0.277 0-0.5 0.223-0.5 0.5s0.223 0.5 0.5 0.5h5c0.277 0 0.5-0.223 0.5-0.5s-0.223-0.5-0.5-0.5h-2v-1.0254c2.533-0.24983 4.5-2.3738 4.5-4.9746v-1.5c0-0.277-0.223-0.5-0.5-0.5s-0.5 0.223-0.5 0.5v1.5c0 2.216-1.784 4-4 4s-4-1.784-4-4v-1.5c0-0.277-0.223-0.5-0.5-0.5z" fill="currentColor"/> +</svg> diff --git a/src/assets/icons/notch/num-lock.svg b/src/assets/icons/notch/num-lock.svg new file mode 100644 index 00000000..84b46920 --- /dev/null +++ b/src/assets/icons/notch/num-lock.svg @@ -0,0 +1,16 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text { + color:#f2f2f2; + } + .ColorScheme-Background { + color:#eff0f1; + } + .ColorScheme-Highlight { + color:#3daee9; + } + .ColorScheme-ButtonText { + color:#f2f2f2; + }</style> + <path class="ColorScheme-Text" d="m10.995 5.496c-2.77 0-5 2.23-5 5v11c0 2.77 3 4.2 5 5l5 2 5-2c2-0.8 5-2.23 5-5v-11c0-2.77-2.23-5-5-5zm-0.02539 1.5h10.051c1.9251 0 3.4746 1.5496 3.4746 3.4746v9.0508c0 1.9251-1.5496 3.4746-3.4746 3.4746h-10.051c-1.9251 0-3.4746-1.5496-3.4746-3.4746v-9.0508c0-1.9251 1.5496-3.4746 3.4746-3.4746z" fill="currentColor"/> + <path class="ColorScheme-Text" d="m14.995 9.996v2h-2v2h2v4h-2v2h6v-2h-2v-8z" fill="currentColor"/> +</svg> diff --git a/config/assets/icons/notification.png b/src/assets/icons/notification.png similarity index 100% rename from config/assets/icons/notification.png rename to src/assets/icons/notification.png diff --git a/config/assets/icons/notifications/notification-active.svg b/src/assets/icons/notifications/notification-active.svg similarity index 100% rename from config/assets/icons/notifications/notification-active.svg rename to src/assets/icons/notifications/notification-active.svg diff --git a/config/assets/icons/notifications/notification-disabled.svg b/src/assets/icons/notifications/notification-disabled.svg similarity index 100% rename from config/assets/icons/notifications/notification-disabled.svg rename to src/assets/icons/notifications/notification-disabled.svg diff --git a/config/assets/icons/notifications/notification-inactive.svg b/src/assets/icons/notifications/notification-inactive.svg similarity index 100% rename from config/assets/icons/notifications/notification-inactive.svg rename to src/assets/icons/notifications/notification-inactive.svg diff --git a/config/assets/icons/player/audio-switcher.svg b/src/assets/icons/player/audio-switcher.svg similarity index 94% rename from config/assets/icons/player/audio-switcher.svg rename to src/assets/icons/player/audio-switcher.svg index d50264f9..06679c69 100644 --- a/config/assets/icons/player/audio-switcher.svg +++ b/src/assets/icons/player/audio-switcher.svg @@ -1,6 +1,6 @@ <svg width="47" height="47" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M14.262 34.3273L21.1818 26.7156C22.0034 25.8118 23.4362 25.8491 24.2096 26.7944L30.4051 34.3668C30.9393 35.0197 30.4748 36 29.6312 36H15.002C14.1338 36 13.6781 34.9697 14.262 34.3273Z" fill="white" fill-opacity="0.4"/> -<path d="M25.9946 26.593C26.3392 26.898 26.872 26.8686 27.1215 26.482C27.5563 25.808 27.8404 25.0442 27.9496 24.2428C28.0965 23.165 27.9205 22.0678 27.4439 21.09C26.9672 20.1122 26.2113 19.2977 25.2717 18.7495C24.3322 18.2013 23.2511 17.944 22.1654 18.0102C21.0796 18.0764 20.0378 18.4631 19.1718 19.1213C18.3058 19.7796 17.6544 20.6799 17.3 21.7083C16.9457 22.7368 16.9042 23.8472 17.181 24.8992C17.3867 25.6814 17.7615 26.4051 18.275 27.0212C18.5696 27.3747 19.102 27.3392 19.407 26.9946C19.7119 26.65 19.6706 26.1274 19.3999 25.7553C19.1211 25.372 18.9143 24.9383 18.7925 24.4753C18.5996 23.742 18.6285 22.968 18.8755 22.2512C19.1225 21.5343 19.5765 20.9068 20.1801 20.4479C20.7838 19.9891 21.5099 19.7196 22.2667 19.6735C23.0236 19.6273 23.7771 19.8067 24.432 20.1888C25.0869 20.5709 25.6138 21.1386 25.946 21.8202C26.2782 22.5017 26.4009 23.2665 26.2985 24.0178C26.2339 24.4922 26.0813 24.9477 25.8511 25.3621C25.6276 25.7643 25.65 26.2881 25.9946 26.593Z" fill="white" fill-opacity="0.4"/> -<path d="M31.2195 31.2175C31.5734 31.5307 32.1168 31.4994 32.4051 31.1249C33.7327 29.4003 34.5902 27.3545 34.8855 25.1882C35.2194 22.7386 34.8194 20.2449 33.736 18.0227C32.6527 15.8004 30.9348 13.9493 28.7994 12.7033C26.664 11.4574 24.2071 10.8727 21.7394 11.0232C19.2718 11.1736 16.9041 12.0524 14.9359 13.5484C12.9676 15.0445 11.4872 17.0906 10.6819 19.428C9.87648 21.7654 9.78232 24.2891 10.4113 26.6801C10.9675 28.7945 12.0671 30.721 13.5944 32.2716C13.9261 32.6083 14.4693 32.5734 14.7825 32.2195C15.0957 31.8656 15.06 31.327 14.732 30.9867C13.4563 29.6631 12.5365 28.0316 12.0665 26.2446C11.5236 24.1811 11.6049 22.0029 12.3 19.9855C12.9951 17.9681 14.2728 16.2022 15.9715 14.911C17.6703 13.6198 19.7138 12.8613 21.8436 12.7315C23.9734 12.6017 26.0939 13.1063 27.9369 14.1816C29.7799 15.2569 31.2626 16.8546 32.1976 18.7726C33.1326 20.6906 33.4778 22.8428 33.1897 24.957C32.9401 26.7878 32.2253 28.519 31.1199 29.9877C30.8356 30.3653 30.8656 30.9043 31.2195 31.2175Z" fill="white" fill-opacity="0.4"/> -<path d="M28.8328 29.1051C29.2641 29.4869 29.9286 29.4496 30.2609 28.979C31.1374 27.7373 31.7063 26.299 31.913 24.783C32.1667 22.9213 31.8627 21.0262 31.0394 19.3372C30.2161 17.6483 28.9104 16.2414 27.2875 15.2945C25.6646 14.3477 23.7974 13.9033 21.922 14.0176C20.0465 14.1319 18.2471 14.7998 16.7513 15.9368C15.2554 17.0738 14.1303 18.6288 13.5182 20.4053C12.9061 22.1817 12.8346 24.0997 13.3126 25.9168C13.7018 27.3965 14.4412 28.7551 15.4621 29.8811C15.849 30.3078 16.5132 30.2641 16.8949 29.8328C17.2767 29.4014 17.2298 28.7464 16.8563 28.3079C16.1376 27.4642 15.6142 26.4665 15.33 25.3862C14.9569 23.9681 15.0128 22.4712 15.4905 21.0848C15.9681 19.6985 16.8462 18.4849 18.0136 17.5976C19.181 16.7102 20.5853 16.189 22.0489 16.0998C23.5125 16.0106 24.9697 16.3574 26.2363 17.0963C27.5028 17.8353 28.5218 18.9332 29.1643 20.2513C29.8068 21.5694 30.0441 23.0484 29.846 24.5013C29.6952 25.6082 29.2968 26.662 28.6859 27.5868C28.3684 28.0674 28.4014 28.7233 28.8328 29.1051Z" fill="white" fill-opacity="0.4"/> +<path d="M14.262 34.3273L21.1818 26.7156C22.0034 25.8118 23.4362 25.8491 24.2096 26.7944L30.4051 34.3668C30.9393 35.0197 30.4748 36 29.6312 36H15.002C14.1338 36 13.6781 34.9697 14.262 34.3273Z" fill="white"/> +<path d="M25.9946 26.593C26.3392 26.898 26.872 26.8686 27.1215 26.482C27.5563 25.808 27.8404 25.0442 27.9496 24.2428C28.0965 23.165 27.9205 22.0678 27.4439 21.09C26.9672 20.1122 26.2113 19.2977 25.2717 18.7495C24.3322 18.2013 23.2511 17.944 22.1654 18.0102C21.0796 18.0764 20.0378 18.4631 19.1718 19.1213C18.3058 19.7796 17.6544 20.6799 17.3 21.7083C16.9457 22.7368 16.9042 23.8472 17.181 24.8992C17.3867 25.6814 17.7615 26.4051 18.275 27.0212C18.5696 27.3747 19.102 27.3392 19.407 26.9946C19.7119 26.65 19.6706 26.1274 19.3999 25.7553C19.1211 25.372 18.9143 24.9383 18.7925 24.4753C18.5996 23.742 18.6285 22.968 18.8755 22.2512C19.1225 21.5343 19.5765 20.9068 20.1801 20.4479C20.7838 19.9891 21.5099 19.7196 22.2667 19.6735C23.0236 19.6273 23.7771 19.8067 24.432 20.1888C25.0869 20.5709 25.6138 21.1386 25.946 21.8202C26.2782 22.5017 26.4009 23.2665 26.2985 24.0178C26.2339 24.4922 26.0813 24.9477 25.8511 25.3621C25.6276 25.7643 25.65 26.2881 25.9946 26.593Z" fill="white"/> +<path d="M31.2195 31.2175C31.5734 31.5307 32.1168 31.4994 32.4051 31.1249C33.7327 29.4003 34.5902 27.3545 34.8855 25.1882C35.2194 22.7386 34.8194 20.2449 33.736 18.0227C32.6527 15.8004 30.9348 13.9493 28.7994 12.7033C26.664 11.4574 24.2071 10.8727 21.7394 11.0232C19.2718 11.1736 16.9041 12.0524 14.9359 13.5484C12.9676 15.0445 11.4872 17.0906 10.6819 19.428C9.87648 21.7654 9.78232 24.2891 10.4113 26.6801C10.9675 28.7945 12.0671 30.721 13.5944 32.2716C13.9261 32.6083 14.4693 32.5734 14.7825 32.2195C15.0957 31.8656 15.06 31.327 14.732 30.9867C13.4563 29.6631 12.5365 28.0316 12.0665 26.2446C11.5236 24.1811 11.6049 22.0029 12.3 19.9855C12.9951 17.9681 14.2728 16.2022 15.9715 14.911C17.6703 13.6198 19.7138 12.8613 21.8436 12.7315C23.9734 12.6017 26.0939 13.1063 27.9369 14.1816C29.7799 15.2569 31.2626 16.8546 32.1976 18.7726C33.1326 20.6906 33.4778 22.8428 33.1897 24.957C32.9401 26.7878 32.2253 28.519 31.1199 29.9877C30.8356 30.3653 30.8656 30.9043 31.2195 31.2175Z" fill="white"/> +<path d="M28.8328 29.1051C29.2641 29.4869 29.9286 29.4496 30.2609 28.979C31.1374 27.7373 31.7063 26.299 31.913 24.783C32.1667 22.9213 31.8627 21.0262 31.0394 19.3372C30.2161 17.6483 28.9104 16.2414 27.2875 15.2945C25.6646 14.3477 23.7974 13.9033 21.922 14.0176C20.0465 14.1319 18.2471 14.7998 16.7513 15.9368C15.2554 17.0738 14.1303 18.6288 13.5182 20.4053C12.9061 22.1817 12.8346 24.0997 13.3126 25.9168C13.7018 27.3965 14.4412 28.7551 15.4621 29.8811C15.849 30.3078 16.5132 30.2641 16.8949 29.8328C17.2767 29.4014 17.2298 28.7464 16.8563 28.3079C16.1376 27.4642 15.6142 26.4665 15.33 25.3862C14.9569 23.9681 15.0128 22.4712 15.4905 21.0848C15.9681 19.6985 16.8462 18.4849 18.0136 17.5976C19.181 16.7102 20.5853 16.189 22.0489 16.0998C23.5125 16.0106 24.9697 16.3574 26.2363 17.0963C27.5028 17.8353 28.5218 18.9332 29.1643 20.2513C29.8068 21.5694 30.0441 23.0484 29.846 24.5013C29.6952 25.6082 29.2968 26.662 28.6859 27.5868C28.3684 28.0674 28.4014 28.7233 28.8328 29.1051Z" fill="white"/> </svg> diff --git a/config/assets/icons/player/fwd.svg b/src/assets/icons/player/fwd.svg similarity index 100% rename from config/assets/icons/player/fwd.svg rename to src/assets/icons/player/fwd.svg diff --git a/config/assets/icons/player/Pause.svg b/src/assets/icons/player/pause.svg similarity index 98% rename from config/assets/icons/player/Pause.svg rename to src/assets/icons/player/pause.svg index f95a5b88..367e8880 100644 --- a/config/assets/icons/player/Pause.svg +++ b/src/assets/icons/player/pause.svg @@ -4,4 +4,4 @@ <title>Layer 1 - \ No newline at end of file + diff --git a/config/assets/icons/player/play.svg b/src/assets/icons/player/play.svg similarity index 100% rename from config/assets/icons/player/play.svg rename to src/assets/icons/player/play.svg diff --git a/config/assets/icons/player/Rewind.svg b/src/assets/icons/player/rewind.svg similarity index 100% rename from config/assets/icons/player/Rewind.svg rename to src/assets/icons/player/rewind.svg diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/src/assets/icons/power_modes/battery-balanced.svg similarity index 100% rename from config/assets/icons/power_modes/battery-balanced.svg rename to src/assets/icons/power_modes/battery-balanced.svg diff --git a/config/assets/icons/power_modes/battery-performance.svg b/src/assets/icons/power_modes/battery-performance.svg similarity index 100% rename from config/assets/icons/power_modes/battery-performance.svg rename to src/assets/icons/power_modes/battery-performance.svg diff --git a/config/assets/icons/power_modes/battery-power.svg b/src/assets/icons/power_modes/battery-power.svg similarity index 100% rename from config/assets/icons/power_modes/battery-power.svg rename to src/assets/icons/power_modes/battery-power.svg diff --git a/src/assets/icons/screencapture/monitor-video.svg b/src/assets/icons/screencapture/monitor-video.svg new file mode 100644 index 00000000..9dd3ffa6 --- /dev/null +++ b/src/assets/icons/screencapture/monitor-video.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/screencapture/monitor.svg b/src/assets/icons/screencapture/monitor.svg new file mode 100644 index 00000000..0c3cc8f3 --- /dev/null +++ b/src/assets/icons/screencapture/monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/screencapture/region-video.svg b/src/assets/icons/screencapture/region-video.svg new file mode 100644 index 00000000..ade55f5f --- /dev/null +++ b/src/assets/icons/screencapture/region-video.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/screencapture/region.svg b/src/assets/icons/screencapture/region.svg new file mode 100644 index 00000000..553984a1 --- /dev/null +++ b/src/assets/icons/screencapture/region.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/screencapture/stop-circle.svg b/src/assets/icons/screencapture/stop-circle.svg new file mode 100644 index 00000000..da816376 --- /dev/null +++ b/src/assets/icons/screencapture/stop-circle.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/screencapture/window-video.svg b/src/assets/icons/screencapture/window-video.svg new file mode 100644 index 00000000..1b126af6 --- /dev/null +++ b/src/assets/icons/screencapture/window-video.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/screencapture/window.svg b/src/assets/icons/screencapture/window.svg new file mode 100644 index 00000000..281db04c --- /dev/null +++ b/src/assets/icons/screencapture/window.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/terminal.svg b/src/assets/icons/terminal.svg new file mode 100644 index 00000000..ee07116f --- /dev/null +++ b/src/assets/icons/terminal.svg @@ -0,0 +1 @@ + diff --git a/config/assets/icons/todo/checkbox-check.svg b/src/assets/icons/todo/checkbox-check.svg similarity index 100% rename from config/assets/icons/todo/checkbox-check.svg rename to src/assets/icons/todo/checkbox-check.svg diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/src/assets/icons/todo/checkbox-uncheck.svg similarity index 100% rename from config/assets/icons/todo/checkbox-uncheck.svg rename to src/assets/icons/todo/checkbox-uncheck.svg diff --git a/config/assets/icons/todo/delete-symbolic.svg b/src/assets/icons/todo/delete-symbolic.svg similarity index 100% rename from config/assets/icons/todo/delete-symbolic.svg rename to src/assets/icons/todo/delete-symbolic.svg diff --git a/config/assets/icons/todo/edit.svg b/src/assets/icons/todo/edit.svg similarity index 100% rename from config/assets/icons/todo/edit.svg rename to src/assets/icons/todo/edit.svg diff --git a/config/assets/icons/todo/plus-symbolic.svg b/src/assets/icons/todo/plus-symbolic.svg similarity index 100% rename from config/assets/icons/todo/plus-symbolic.svg rename to src/assets/icons/todo/plus-symbolic.svg diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/src/assets/icons/todo/to-do-app-symbolic.svg similarity index 98% rename from config/assets/icons/todo/to-do-app-symbolic.svg rename to src/assets/icons/todo/to-do-app-symbolic.svg index 17cbcdfe..d68e3471 100644 --- a/config/assets/icons/todo/to-do-app-symbolic.svg +++ b/src/assets/icons/todo/to-do-app-symbolic.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/config/assets/icons/volume/audio-volume-0.svg b/src/assets/icons/volume/audio-volume-0.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-0.svg rename to src/assets/icons/volume/audio-volume-0.svg diff --git a/config/assets/icons/volume/audio-volume-1.svg b/src/assets/icons/volume/audio-volume-1.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-1.svg rename to src/assets/icons/volume/audio-volume-1.svg diff --git a/config/assets/icons/volume/audio-volume-2.svg b/src/assets/icons/volume/audio-volume-2.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-2.svg rename to src/assets/icons/volume/audio-volume-2.svg diff --git a/config/assets/icons/volume/audio-volume-3.svg b/src/assets/icons/volume/audio-volume-3.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-3.svg rename to src/assets/icons/volume/audio-volume-3.svg diff --git a/config/assets/icons/volume/audio-volume.svg b/src/assets/icons/volume/audio-volume.svg similarity index 100% rename from config/assets/icons/volume/audio-volume.svg rename to src/assets/icons/volume/audio-volume.svg diff --git a/src/assets/icons/weather/weather-clear-night.svg b/src/assets/icons/weather/weather-clear-night.svg new file mode 100644 index 00000000..3b04a26b --- /dev/null +++ b/src/assets/icons/weather/weather-clear-night.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-clear.svg b/src/assets/icons/weather/weather-clear.svg new file mode 100644 index 00000000..735456c5 --- /dev/null +++ b/src/assets/icons/weather/weather-clear.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-clouds-night.svg b/src/assets/icons/weather/weather-clouds-night.svg new file mode 100644 index 00000000..2e83399d --- /dev/null +++ b/src/assets/icons/weather/weather-clouds-night.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-clouds.svg b/src/assets/icons/weather/weather-clouds.svg new file mode 100644 index 00000000..4bad7a93 --- /dev/null +++ b/src/assets/icons/weather/weather-clouds.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-few-clouds-night.svg b/src/assets/icons/weather/weather-few-clouds-night.svg new file mode 100644 index 00000000..0c63739b --- /dev/null +++ b/src/assets/icons/weather/weather-few-clouds-night.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-few-clouds.svg b/src/assets/icons/weather/weather-few-clouds.svg new file mode 100644 index 00000000..be0edf87 --- /dev/null +++ b/src/assets/icons/weather/weather-few-clouds.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-fog.svg b/src/assets/icons/weather/weather-fog.svg new file mode 100644 index 00000000..0838f14c --- /dev/null +++ b/src/assets/icons/weather/weather-fog.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-freezing-rain.svg b/src/assets/icons/weather/weather-freezing-rain.svg new file mode 100644 index 00000000..1d074b69 --- /dev/null +++ b/src/assets/icons/weather/weather-freezing-rain.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-hail.svg b/src/assets/icons/weather/weather-hail.svg new file mode 100644 index 00000000..0ee950bc --- /dev/null +++ b/src/assets/icons/weather/weather-hail.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-many-clouds.svg b/src/assets/icons/weather/weather-many-clouds.svg new file mode 100644 index 00000000..adf556d9 --- /dev/null +++ b/src/assets/icons/weather/weather-many-clouds.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-none-available.svg b/src/assets/icons/weather/weather-none-available.svg new file mode 100644 index 00000000..cc31231a --- /dev/null +++ b/src/assets/icons/weather/weather-none-available.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-overcast-night.svg b/src/assets/icons/weather/weather-overcast-night.svg new file mode 100644 index 00000000..e830d22f --- /dev/null +++ b/src/assets/icons/weather/weather-overcast-night.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-overcast.svg b/src/assets/icons/weather/weather-overcast.svg new file mode 100644 index 00000000..27d0593d --- /dev/null +++ b/src/assets/icons/weather/weather-overcast.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/weather/weather-severe-alert.svg b/src/assets/icons/weather/weather-severe-alert.svg new file mode 100644 index 00000000..9812e233 --- /dev/null +++ b/src/assets/icons/weather/weather-severe-alert.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-showers-day.svg b/src/assets/icons/weather/weather-showers-day.svg new file mode 100644 index 00000000..c540f6e0 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-day.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-showers-night.svg b/src/assets/icons/weather/weather-showers-night.svg new file mode 100644 index 00000000..e9fd0f71 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-night.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-showers-scattered-day.svg b/src/assets/icons/weather/weather-showers-scattered-day.svg new file mode 100644 index 00000000..45096b92 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered-day.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-showers-scattered-night.svg b/src/assets/icons/weather/weather-showers-scattered-night.svg new file mode 100644 index 00000000..40b7e30c --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered-night.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-showers-scattered.svg b/src/assets/icons/weather/weather-showers-scattered.svg new file mode 100644 index 00000000..8e6699bb --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-showers.svg b/src/assets/icons/weather/weather-showers.svg new file mode 100644 index 00000000..b84ac458 --- /dev/null +++ b/src/assets/icons/weather/weather-showers.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-snow-night.svg b/src/assets/icons/weather/weather-snow-night.svg new file mode 100644 index 00000000..d577ca24 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-night.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-snow-rain.svg b/src/assets/icons/weather/weather-snow-rain.svg new file mode 100644 index 00000000..b5c030a5 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-rain.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-snow-scattered-day.svg b/src/assets/icons/weather/weather-snow-scattered-day.svg new file mode 100644 index 00000000..cf2902e2 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered-day.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-snow-scattered-night.svg b/src/assets/icons/weather/weather-snow-scattered-night.svg new file mode 100644 index 00000000..b8a6225c --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered-night.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-snow-scattered.svg b/src/assets/icons/weather/weather-snow-scattered.svg new file mode 100644 index 00000000..beca0f75 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-snow.svg b/src/assets/icons/weather/weather-snow.svg new file mode 100644 index 00000000..44da0c2b --- /dev/null +++ b/src/assets/icons/weather/weather-snow.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-storm-night.svg b/src/assets/icons/weather/weather-storm-night.svg new file mode 100644 index 00000000..2231272f --- /dev/null +++ b/src/assets/icons/weather/weather-storm-night.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-storm-tornado.svg b/src/assets/icons/weather/weather-storm-tornado.svg new file mode 100644 index 00000000..41b7c6bc --- /dev/null +++ b/src/assets/icons/weather/weather-storm-tornado.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/weather/weather-storm.svg b/src/assets/icons/weather/weather-storm.svg new file mode 100644 index 00000000..3027b764 --- /dev/null +++ b/src/assets/icons/weather/weather-storm.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/weather/weather-windy.svg b/src/assets/icons/weather/weather-windy.svg new file mode 100644 index 00000000..e76d9daf --- /dev/null +++ b/src/assets/icons/weather/weather-windy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/src/assets/icons/wifi/network-wireless-0.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-0.svg rename to src/assets/icons/wifi/network-wireless-0.svg diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/src/assets/icons/wifi/network-wireless-100.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-100.svg rename to src/assets/icons/wifi/network-wireless-100.svg diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/src/assets/icons/wifi/network-wireless-20.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-20.svg rename to src/assets/icons/wifi/network-wireless-20.svg diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/src/assets/icons/wifi/network-wireless-40.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-40.svg rename to src/assets/icons/wifi/network-wireless-40.svg diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/src/assets/icons/wifi/network-wireless-60.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-60.svg rename to src/assets/icons/wifi/network-wireless-60.svg diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/src/assets/icons/wifi/network-wireless-80.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-80.svg rename to src/assets/icons/wifi/network-wireless-80.svg diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/src/assets/icons/wifi/wifi-connecting.svg similarity index 100% rename from config/assets/icons/wifi/wifi-connecting.svg rename to src/assets/icons/wifi/wifi-connecting.svg diff --git a/assets/modus.png b/src/assets/modus.png similarity index 100% rename from assets/modus.png rename to src/assets/modus.png diff --git a/assets/screenshots/home.png b/src/assets/screenshots/home.png similarity index 100% rename from assets/screenshots/home.png rename to src/assets/screenshots/home.png diff --git a/assets/screenshots/lock.png b/src/assets/screenshots/lock.png similarity index 100% rename from assets/screenshots/lock.png rename to src/assets/screenshots/lock.png diff --git a/src/assets/sounds/pomodoro/alert-long-break.mp3 b/src/assets/sounds/pomodoro/alert-long-break.mp3 new file mode 100644 index 00000000..03715c10 Binary files /dev/null and b/src/assets/sounds/pomodoro/alert-long-break.mp3 differ diff --git a/src/assets/sounds/pomodoro/alert-short-break.mp3 b/src/assets/sounds/pomodoro/alert-short-break.mp3 new file mode 100644 index 00000000..62fe1cd9 Binary files /dev/null and b/src/assets/sounds/pomodoro/alert-short-break.mp3 differ diff --git a/src/assets/sounds/pomodoro/alert-work.mp3 b/src/assets/sounds/pomodoro/alert-work.mp3 new file mode 100644 index 00000000..d88c10f7 Binary files /dev/null and b/src/assets/sounds/pomodoro/alert-work.mp3 differ diff --git a/src/assets/sounds/pomodoro/tick.mp3 b/src/assets/sounds/pomodoro/tick.mp3 new file mode 100644 index 00000000..49b6bd00 Binary files /dev/null and b/src/assets/sounds/pomodoro/tick.mp3 differ diff --git a/assets/wallpapers_example/example-1.png b/src/assets/wallpaper_example/example-1.png similarity index 100% rename from assets/wallpapers_example/example-1.png rename to src/assets/wallpaper_example/example-1.png diff --git a/assets/wallpapers_example/example-2.png b/src/assets/wallpaper_example/example-2.png similarity index 100% rename from assets/wallpapers_example/example-2.png rename to src/assets/wallpaper_example/example-2.png diff --git a/assets/wallpapers_example/example-3.jpg b/src/assets/wallpaper_example/example-3.jpg similarity index 100% rename from assets/wallpapers_example/example-3.jpg rename to src/assets/wallpaper_example/example-3.jpg diff --git a/assets/wallpapers_example/example-4.png b/src/assets/wallpaper_example/example-4.png similarity index 100% rename from assets/wallpapers_example/example-4.png rename to src/assets/wallpaper_example/example-4.png diff --git a/assets/wallpapers_example/example-5.png b/src/assets/wallpaper_example/example-5.png similarity index 100% rename from assets/wallpapers_example/example-5.png rename to src/assets/wallpaper_example/example-5.png diff --git a/src/main.py b/src/main.py new file mode 100644 index 00000000..81e917e8 --- /dev/null +++ b/src/main.py @@ -0,0 +1,122 @@ +import atexit +import os + +# App-capture typelib path (built via meson in src/window/switcher/app-capture/builddir) +# Must be set before gi is imported so the typelib search path includes it. +# Note: LD_LIBRARY_PATH does not take effect in the current process, so the +# .so is preloaded via ctypes in window/switcher/main.py instead. +_app_capture_builddir = os.path.join( + os.path.dirname(__file__), "window", "switcher", "app-capture", "builddir" +) +if os.path.isdir(_app_capture_builddir): + _cur = os.environ.get("GI_TYPELIB_PATH", "") + if _app_capture_builddir not in _cur.split(os.pathsep): + os.environ["GI_TYPELIB_PATH"] = _app_capture_builddir + ( + os.pathsep + _cur if _cur else "" + ) + +from fabric import Application +from fabric.utils import get_relative_path, logger, monitor_file + +for log in [ + "fabric", + "services", + "window", + "utils", + "window.globalmenu", +]: + logger.disable(log) + +from services.keyboard_layout import KeyboardLayout +from shared.data import APP_NAME, load_config +from utils.functions import set_process_name +from window.desktop.widget import Deskwidgets +from window.dock import Dock +from window.notification.notification import ModusNoti +from window.osd.main import OSDWindow +from window.panel.main import Panel +from window.screencapture import ScreenCaptureWindow +from window.spotlight.main import SpotlightWindow +from window.switcher import ApplicationSwitcher + + +def main(): + + # Generate colors.css if it doesn't exist + colors_css_path = get_relative_path("styles/colors.css") + if not os.path.exists(colors_css_path): + from utils.gtk_utils import generate_colors_from_wallpaper + + default_wallpaper = get_relative_path("assets/wallpaper_example/example-1.png") + if os.path.exists(default_wallpaper): + generate_colors_from_wallpaper(default_wallpaper) + + set_process_name(APP_NAME) + + load_config() + switcher = ApplicationSwitcher() + panel = Panel() + modusnoti = ModusNoti() + spotlight = SpotlightWindow() + panel.set_spotlight_toggle(spotlight.toggle) + deskwidget = Deskwidgets() + dock = Dock() + osd = OSDWindow() + screencapture = ScreenCaptureWindow() + + # Monitor CSS files and subdirectories for changes + css_monitors = [] + for root, dirs, files in os.walk(get_relative_path("styles/")): + monitor = monitor_file(root) + monitor.connect("changed", lambda *_: set_css()) + css_monitors.append(monitor) + + app = Application( + f"{APP_NAME}", + panel, + modusnoti, + deskwidget.top_left, + deskwidget.bottom_left, + osd, + switcher, + dock, + screencapture, + spotlight, + ) + + def set_css(): + app.set_stylesheet_from_file(get_relative_path("styles/main.css")) + + app.set_css = set_css + app.set_css() + + def cleanup_css_monitors(): + for m in css_monitors: + try: + m.cancel() + except Exception as e: + logger.warning(f"[main] m.cancel() failed: {e}") + + atexit.register(cleanup_css_monitors) + + # Inject into the executing module's namespace (__main__) + # to emulate what happened when this file was run directly. + # This allows fabric-cli exec to execute commands flawlessly. + import __main__ + + __main__.app = app + __main__.switcher = switcher + __main__.panel = panel + __main__.modusnoti = modusnoti + __main__.spotlight = spotlight + __main__.deskwidget = deskwidget + __main__.dock = dock + __main__.osd = osd + __main__.screencapture = screencapture + __main__.switch_keyboard_layout = KeyboardLayout.switch_keyboard_layout + + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 00000000..310bf3ae --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,15 @@ +from fabric.audio import Audio # noqa: F401 + +from .battery import Battery # noqa: F401 +from .brightness import Brightness # noqa: F401 +from .capslock import CapsLock # noqa: F401 +from .config import ConfigService, on_config_change, start_config_service # noqa: F401 +from .keyboard_layout import KeyboardLayout # noqa: F401 +from .modus import ModusService # noqa: F401 +from .mpris import PlayerManager, PlayerService # noqa: F401 +from .mpris import PlayerManager as MprisPlayerManager # noqa: F401 +from .mpris import PlayerService as MprisPlayer # noqa: F401 +from .network import Ethernet, NetworkClient, Wifi # noqa: F401 +from .numlock import NumLock # noqa: F401 +from .todo import TodoService # noqa: F401 +from .wallpaper import WallpaperService, create_thumbnail # noqa: F401 diff --git a/src/services/battery.py b/src/services/battery.py new file mode 100644 index 00000000..1d18b61a --- /dev/null +++ b/src/services/battery.py @@ -0,0 +1,221 @@ +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger + +BATTERY_BUS_NAME = "org.freedesktop.UPower" +BATTERY_BUS_PATH = "/org/freedesktop/UPower/devices/DisplayDevice" +BATTERY_INTERFACE = "org.freedesktop.UPower.Device" + +POWER_PROFILE_BUS_NAME = "net.hadess.PowerProfiles" +POWER_PROFILE_BUS_PATH = "/net/hadess/PowerProfiles" +POWER_PROFILE_INTERFACE = "net.hadess.PowerProfiles" + +DEVICE_STATE = { + 0: "UNKNOWN", + 1: "CHARGING", + 2: "DISCHARGING", + 3: "EMPTY", + 4: "FULLY_CHARGED", + 5: "PENDING_CHARGE", + 6: "PENDING_DISCHARGE", +} + + +class Battery(Service): + """A service for interacting with the battery's DBus and Power Profiles. + + Use Battery.get_initial() to get the shared singleton instance. + """ + + _instance = None + + @classmethod + def get_initial(cls) -> "Battery": + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @Signal + def changed(self) -> None: ... + + @Signal + def power_profile_changed(self) -> None: ... + + @Property(bool, "readable", default_value=False) + def available(self) -> bool: + return self.do_get_cached_property("IsPresent") or False + + @Property(str, "readable", default_value="") + def vendor(self) -> str: + return self.do_get_cached_property("Vendor") or "" + + @Property(int, "readable", default_value=0) + def percent(self) -> int: + return self.do_get_cached_property("Percentage") or 0 + + @Property(bool, "readable", default_value=False) + def charging(self) -> bool: + val = self.do_get_cached_property("State") + if val is None: + return False + return val == DEVICE_STATE.get("CHARGING", 1) + + @Property(bool, "readable", default_value=False) + def discharging(self) -> bool: + val = self.do_get_cached_property("State") + if val is None: + return False + return val == DEVICE_STATE.get("DISCHARGING", 2) + + @Property(bool, "readable", default_value=False) + def charged(self) -> bool: + val = self.do_get_cached_property("State") + if val is None: + return False + return val == DEVICE_STATE.get("FULLY_CHARGED", 4) + + @Property(str, "readable", default_value="") + def icon_name(self) -> str: + return self.do_get_cached_property("IconName") or "" + + @Property(int, "readable", default_value=0) + def time_remaining(self) -> int: + return self.do_get_cached_property("TimeToEmpty") or 0 + + @Property(int, "readable", default_value=0) + def time_to_full(self) -> int: + return self.do_get_cached_property("TimeToFull") or 0 + + @Property(float, "readable", default_value=0.0) + def temperature(self) -> float: + return self.do_get_cached_property("Temperature") or 0.0 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._bus: Gio.DBusConnection | None = None + self._proxy = None + self._power_profile_proxy = None + # _prop_cache stores ONLY properties received via PropertiesChanged. + # For fresh reads we always fall through to the GDBus proxy cache. + self._prop_cache: dict[str, object] = {} + self.do_register() + + def do_register(self) -> None: + self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM) + self._proxy = Gio.DBusProxy.new_sync( + self._bus, + Gio.DBusProxyFlags.NONE, + None, + BATTERY_BUS_NAME, + BATTERY_BUS_PATH, + BATTERY_INTERFACE, + None, + ) + + logger.info("[Battery] Proxy initialized") + + self._bus.signal_subscribe( + BATTERY_BUS_NAME, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + BATTERY_BUS_PATH, + None, + Gio.DBusSignalFlags.NONE, + self.do_handle_property_change, + ) + + try: + self._power_profile_proxy = Gio.DBusProxy.new_sync( + self._bus, + Gio.DBusProxyFlags.NONE, + None, + POWER_PROFILE_BUS_NAME, + POWER_PROFILE_BUS_PATH, + POWER_PROFILE_INTERFACE, + None, + ) + self._bus.signal_subscribe( + POWER_PROFILE_BUS_NAME, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + POWER_PROFILE_BUS_PATH, + None, + Gio.DBusSignalFlags.NONE, + self.do_handle_power_profile_change, + ) + logger.info("[Battery] Power Profiles Proxy initialized") + except Exception as e: + logger.warning(f"[Battery] Power Profiles daemon not available: {e}") + + def do_handle_property_change( + self, + _connection: Gio.DBusConnection, + _sender_name: str, + _object_path: str, + _interface_name: str, + _signal_name: str, + parameters: GLib.Variant, + ): + try: + _iface, changed_props, _invalidated = parameters.unpack() + for prop_name, variant in changed_props.items(): + self._prop_cache[prop_name] = variant.unpack() + except Exception as e: + logger.warning(f"[Battery] Failed to parse PropertiesChanged: {e}") + self.emit("changed") + + def do_handle_power_profile_change(self, *_): + self.emit("power_profile_changed") + + def do_get_cached_property(self, property_name): + """Read from the GDBus proxy cache, which DBus keeps up-to-date.""" + result = self._proxy.get_cached_property(property_name) + if result is not None: + return result.unpack() + # Fallback: value not in proxy cache yet (e.g. very early in startup) + return self._prop_cache.get(property_name) + + def get_power_profile(self) -> str | None: + """Get the current active power profile.""" + if not self._power_profile_proxy: + return None + result = self._power_profile_proxy.get_cached_property("ActiveProfile") + return result.unpack() if result is not None else None + + def set_power_profile(self, profile: str) -> bool: + """Set the active power profile.""" + if not self._power_profile_proxy: + return False + try: + parameters = GLib.Variant( + "(ssv)", + (POWER_PROFILE_INTERFACE, "ActiveProfile", GLib.Variant("s", profile)), + ) + self._bus.call_sync( + POWER_PROFILE_BUS_NAME, + POWER_PROFILE_BUS_PATH, + "org.freedesktop.DBus.Properties", + "Set", + parameters, + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + return True + except Exception as e: + logger.error(f"[Battery] Failed to set power profile: {e}") + return False + + def get_available_power_profiles(self) -> list | None: + """Get list of available power profiles.""" + if not self._power_profile_proxy: + return None + result = self._power_profile_proxy.get_cached_property("Profiles") + if result is not None: + profiles_data = result.unpack() + profiles = [] + for profile_dict in profiles_data: + if "Profile" in profile_dict: + profiles.append(profile_dict["Profile"]) + return profiles + return None diff --git a/src/services/bluetooth.py b/src/services/bluetooth.py new file mode 100644 index 00000000..d86f1015 --- /dev/null +++ b/src/services/bluetooth.py @@ -0,0 +1,815 @@ +from collections.abc import Callable +from typing import Any, Concatenate, ParamSpec + +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, Gtk, logger + +P = ParamSpec("P") + +BLUEZ_SERVICE = "org.bluez" +BLUEZ_ADAPTER_IFACE = "org.bluez.Adapter1" +BLUEZ_DEVICE_IFACE = "org.bluez.Device1" +BLUEZ_BATTERY_IFACE = "org.bluez.Battery1" +DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager" +DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties" + + +def _unpack_variant(v): + if v is None: + return None + if isinstance(v, GLib.Variant): + return _unpack_variant(v.unpack()) + if isinstance(v, dict): + return {k: _unpack_variant(val) for k, val in v.items()} + if isinstance(v, (list, tuple)): + return [_unpack_variant(i) for i in v] + return v + + +def _make_proxy(bus: Gio.DBusConnection, path: str, iface: str) -> Gio.DBusProxy: + return Gio.DBusProxy.new_sync( + bus, + Gio.DBusProxyFlags.NONE, + None, + BLUEZ_SERVICE, + path, + iface, + None, + ) + + +class BluetoothDevice(Service): + @Signal + def changed(self) -> None: ... + + @Property(bool, "read-write", "is-connected", default_value=False) + def connected(self) -> bool: + return bool(self._get_prop("Connected")) + + @connected.setter + def connected(self, value: bool): + self.connecting = value + + @Property(bool, "read-write", "is-connecting", default_value=False) + def connecting(self) -> bool: + return self._connecting + + @connecting.setter + def connecting(self, value: bool): + self._connecting = True + self.notify("connecting") + + def _cb(proxy, res, _): + try: + proxy.call_finish(res) + logger.info( + f"[Bluetooth] {'Connected' if value else 'Disconnected'}: {self.address}" + ) + except Exception as e: + logger.warning( + f"[Bluetooth] connect_device failed for {self.address}: {e}" + ) + finally: + self._connecting = False + self.notify("connecting") + self.notify("connected") + self.emit("changed") + + method = "Connect" if value else "Disconnect" + self._proxy.call(method, None, Gio.DBusCallFlags.NONE, 30000, None, _cb, None) + + @Property(bool, "readable", "is-closed", default_value=False) + def closed(self) -> bool: + return self._closed + + @Property(bool, "read-write", "is-paired", default_value=False) + def paired(self) -> bool: + return bool(self._get_prop("Paired")) + + @Property(bool, "read-write", "is-trusted", default_value=False) + def trusted(self) -> bool: + return bool(self._get_prop("Trusted")) + + @trusted.setter + def trusted(self, value: bool): + self._set_prop("Trusted", GLib.Variant("b", value)) + + @Property(str, "readable") + def address(self) -> str: + return str(self._get_prop("Address") or "") + + @Property(str, "readable") + def name(self) -> str: + return str(self._get_prop("Name") or self._get_prop("Alias") or "") + + @Property(str, "readable") + def alias(self) -> str: + return str(self._get_prop("Alias") or "") + + @Property(str, "readable") + def icon_name(self) -> str: + return str(self._get_prop("Icon") or "bluetooth") + + @Property(str, "readable") + def type(self) -> str: + return _icon_to_type(str(self._get_prop("Icon") or "")) + + @Property(float, "readable") + def battery_percentage(self) -> float: + return float(self._get_battery_prop("Percentage") or 0.0) + + def __init__( + self, bus: Gio.DBusConnection, object_path: str, props: dict, **kwargs + ): + super().__init__(**kwargs) + self._bus = bus + self._object_path = object_path + self._props: dict = props + self._connecting = False + self._closed = False + self._prop_sub_id: int = 0 + self._battery_sub_id: int = 0 + self._battery_proxy: Gio.DBusProxy | None = None + + self._proxy = _make_proxy(bus, object_path, BLUEZ_DEVICE_IFACE) + + try: + self._battery_proxy = _make_proxy(bus, object_path, BLUEZ_BATTERY_IFACE) + except Exception as e: + # Battery1 is optional — not all devices expose it + logger.warning( + f"[bluetooth] self._battery_proxy = _make_proxy(bus, object_path, BLUEZ... failed: {e}" + ) + + # Subscribe to Device1 PropertiesChanged + self._prop_sub_id = bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_PROPS_IFACE, + "PropertiesChanged", + object_path, + BLUEZ_DEVICE_IFACE, # filter to Device1 only + Gio.DBusSignalFlags.NONE, + self._on_properties_changed, + None, + ) + + self._battery_sub_id = bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_PROPS_IFACE, + "PropertiesChanged", + object_path, + BLUEZ_BATTERY_IFACE, # filter to Battery1 only + Gio.DBusSignalFlags.NONE, + self._on_battery_properties_changed, + None, + ) + + def _get_prop(self, name: str): + try: + v = self._proxy.get_cached_property(name) + if v is not None: + return v.unpack() + except Exception as e: + logger.error(f"An error occurred: {e}") + return _unpack_variant(self._props.get(name)) + + def _set_prop(self, name: str, value: GLib.Variant): + """Set a property on the Device1 interface via D-Bus Properties.Set.""" + try: + self._proxy.call_sync( + "org.freedesktop.DBus.Properties.Set", + GLib.Variant("(ssv)", (BLUEZ_DEVICE_IFACE, name, value)), + Gio.DBusCallFlags.NONE, + 5000, + None, + ) + except Exception as e: + logger.warning(f"[Bluetooth] Set {name} on {self._object_path} failed: {e}") + + def _get_battery_prop(self, name: str): + if not self._battery_proxy: + return None + try: + v = self._battery_proxy.get_cached_property(name) + return v.unpack() if v else None + except Exception as e: + logger.warning( + f"[bluetooth] v = self._battery_proxy.get_cached_property(name) failed: {e}" + ) + return None + + def _on_properties_changed( + self, _conn, _sender, _path, _iface, _signal, params, _data + ): + changed = _unpack_variant(params)[1] if params else {} + self._props.update(changed) + prop_map = { + "Connected": "connected", + "Paired": "paired", + "Trusted": "trusted", + "Name": "name", + "Alias": "alias", + "Icon": "icon-name", + "RSSI": "rssi", + "UUIDs": "uuids", + "Appearance": "appearance", + "Class": "device-class", + } + for bluez_key, fabric_prop in prop_map.items(): + if bluez_key in changed: + self.notify(fabric_prop) + self.emit("changed") + + def _on_battery_properties_changed( + self, _conn, _sender, _path, _iface, _signal, params, _data + ): + changed = _unpack_variant(params)[1] if params else {} + if "Percentage" in changed: + self.notify("battery-level") + self.notify("battery-percentage") + self.emit("changed") + + def pair(self, callback: Callable | None = None): + """Initiate pairing with the device.""" + + def _cb(proxy, res, _): + try: + proxy.call_finish(res) + logger.info(f"[Bluetooth] Paired: {self.address}") + except Exception as e: + logger.warning(f"[Bluetooth] Pair failed for {self.address}: {e}") + finally: + self.notify("paired") + self.emit("changed") + if callback: + callback(self.paired) + + self._proxy.call("Pair", None, Gio.DBusCallFlags.NONE, 60000, None, _cb, None) + + def remove(self): + """Remove/forget the device (must be called on the adapter).""" + # Removal is done via Adapter1.RemoveDevice — expose a convenience + # flag so the adapter can act on it. + logger.info(f"[Bluetooth] Remove requested for: {self.address}") + self.emit("changed") + + def trust(self): + """Mark the device as trusted.""" + self.trusted = True + + def untrust(self): + """Mark the device as untrusted.""" + self._set_prop("Trusted", GLib.Variant("b", False)) + self.notify("trusted") + self.emit("changed") + + def close(self): + if self._prop_sub_id: + self._bus.signal_unsubscribe(self._prop_sub_id) + self._prop_sub_id = 0 + if self._battery_sub_id: + self._bus.signal_unsubscribe(self._battery_sub_id) + self._battery_sub_id = 0 + self._closed = True + self.notify("closed") + + def connect_device( + self, + connect: bool = True, + callback: Callable[Concatenate[bool, P], Any] | None = None, + *args: P.args, + **kwargs: P.kwargs, + ): + self.connecting = connect + if callback: + + def _once(*_): + callback(self.connected, *args, **kwargs) + self.disconnect(_id) + + _id = self.connect("changed", _once) + + def notifier(self, name: str, args=None): + self.notify(name) + self.emit("changed") + + +class BluetoothAdapter(Service): + @Signal + def changed(self) -> None: ... + + @Signal + def device_added(self, address: str) -> None: ... + + @Signal + def device_removed(self, address: str) -> None: ... + + @Property(list, "readable") + def devices(self) -> list: + return list(self._devices.values()) + + @Property(list, "readable") + def connected_devices(self) -> list: + return [d for d in self._devices.values() if d.connected] + + @Property(str, "readable") + def object_path(self) -> str: + return self._object_path + + @Property(str, "readable") + def address(self) -> str: + return str(self._get_prop("Address") or "") + + @Property(str, "readable") + def name(self) -> str: + return str( + self._get_prop("Name") or self._get_prop("Alias") or self._object_path + ) + + @Property(str, "readable") + def state(self) -> str: + if not self._get_prop("Powered"): + return "off" + if self._get_prop("Discovering"): + return "discovering" + return "on" + + @Property(bool, "read-write", default_value=False) + def powered(self) -> bool: + return bool(self._get_prop("Powered")) + + @powered.setter + def powered(self, value: bool): + self._set_prop("Powered", GLib.Variant("b", value)) + + @Property(bool, "read-write", default_value=False) + def enabled(self) -> bool: + return self.powered + + @enabled.setter + def enabled(self, value: bool): + self.powered = value + + @Property(bool, "read-write", default_value=False) + def scanning(self) -> bool: + return bool(self._get_prop("Discovering")) + + @scanning.setter + def scanning(self, value: bool): + if value == self.scanning: + return + method = "StartDiscovery" if value else "StopDiscovery" + self._proxy.call( + method, + None, + Gio.DBusCallFlags.NONE, + 10000, + None, + lambda p, r, _: self._finish_call(p, r, method), + None, + ) + + def __init__( + self, bus: Gio.DBusConnection, object_path: str, props: dict, **kwargs + ): + super().__init__(**kwargs) + self._bus = bus + self._object_path = object_path + self._props: dict = props + + self._devices: dict[str, BluetoothDevice] = {} + self._device_changed_ids: dict[str, int] = {} + self._prop_sub_id: int = 0 + self._scan_timeout_id: int = 0 + + self._proxy = _make_proxy(bus, object_path, BLUEZ_ADAPTER_IFACE) + + self._prop_sub_id = bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_PROPS_IFACE, + "PropertiesChanged", + object_path, + None, + Gio.DBusSignalFlags.NONE, + self._on_properties_changed, + None, + ) + + def _get_prop(self, name: str): + try: + v = self._proxy.get_cached_property(name) + if v is not None: + return v.unpack() + except Exception as e: + logger.error(f"An error occurred: {e}") + return _unpack_variant(self._props.get(name)) + + def _set_prop(self, name: str, value: GLib.Variant): + try: + self._proxy.call_sync( + "org.freedesktop.DBus.Properties.Set", + GLib.Variant("(ssv)", (BLUEZ_ADAPTER_IFACE, name, value)), + Gio.DBusCallFlags.NONE, + 5000, + None, + ) + except Exception as e: + logger.warning(f"[Bluetooth] Set {name} on {self._object_path} failed: {e}") + + def _finish_call(self, proxy, res, method: str): + try: + proxy.call_finish(res) + except Exception as e: + logger.warning(f"[Bluetooth] {method} on {self._object_path} failed: {e}") + + def _on_properties_changed( + self, _conn, _sender, _path, _iface, _signal, params, _data + ): + changed = _unpack_variant(params)[1] if params else {} + self._props.update(changed) + for key in ("Powered", "Discovering"): + if key in changed: + self.notify("powered" if key == "Powered" else "scanning") + self.notify("enabled") + self.notify("state") + self.emit("changed") + + def add_device(self, object_path: str, props: dict): + addr: str = props.get("Address", _path_to_addr(object_path)) + if addr in self._devices: + return + + device = BluetoothDevice( + self._bus, + object_path, + props, + on_changed=lambda *_: self.emit("changed"), + ) + self._devices[addr] = device + + handler_id = device.connect( + "changed", + lambda *_: self.notify("connected-devices"), + ) + self._device_changed_ids[addr] = handler_id + + logger.info(f"[Bluetooth:{self.name}] Adding device: {addr}") + self.emit("device-added", addr) + self.notify("devices") + self.notify("connected-devices") + self.emit("changed") + + def remove_device(self, object_path: str): + addr = _path_to_addr(object_path) + device = self._devices.pop(addr, None) + if not device: + return + + logger.info(f"[Bluetooth:{self.name}] Removing device: {addr}") + + handler_id = self._device_changed_ids.pop(addr, None) + if handler_id is not None: + try: + device.disconnect(handler_id) + except Exception as e: + logger.warning(f"[bluetooth] device.disconnect(handler_id) failed: {e}") + + self.emit("device-removed", addr) + if device.connected: + self.notify("connected-devices") + self.notify("devices") + self.emit("changed") + + device.close() + + def remove_device_from_bluez(self, device: "BluetoothDevice"): + """Call Adapter1.RemoveDevice to unpair/forget the device entirely.""" + try: + self._proxy.call_sync( + "RemoveDevice", + GLib.Variant("(o)", (device._object_path,)), + Gio.DBusCallFlags.NONE, + 10000, + None, + ) + logger.info(f"[Bluetooth:{self.name}] Removed device: {device.address}") + except Exception as e: + logger.warning(f"[Bluetooth] RemoveDevice failed for {device.address}: {e}") + + def get_device(self, address: str) -> BluetoothDevice | None: + return self._devices.get(address) + + def scan(self, duration_ms: int = 10_000): + """Start discovery and automatically stop after duration_ms.""" + if self._scan_timeout_id: + GLib.source_remove(self._scan_timeout_id) + self._scan_timeout_id = 0 + + self.scanning = True + + def _stop(): + self.scanning = False + self._scan_timeout_id = 0 + return False + + self._scan_timeout_id = GLib.timeout_add(duration_ms, _stop) + + def stop_scan(self): + if self._scan_timeout_id: + GLib.source_remove(self._scan_timeout_id) + self._scan_timeout_id = 0 + self.scanning = False + + def toggle_scan(self): + if self.scanning: + self.stop_scan() + else: + self.scan() + + def close(self): + if self._scan_timeout_id: + GLib.source_remove(self._scan_timeout_id) + self._scan_timeout_id = 0 + if self._prop_sub_id: + self._bus.signal_unsubscribe(self._prop_sub_id) + self._prop_sub_id = 0 + for device in list(self._devices.values()): + device.close() + self._devices.clear() + self._device_changed_ids.clear() + + def notifier(self, name: str, *args): + self.notify(name) + self.emit("changed") + + +class BluetoothClient(Service): + @Signal + def changed(self) -> None: ... + + @Signal + def closed(self) -> None: ... + + @Signal + def device_added(self, address: str) -> None: ... + + @Signal + def device_removed(self, address: str) -> None: ... + + @Property(list, "readable") + def adapters(self) -> list: + return list(self._adapters.values()) + + @Property(list, "readable") + def devices(self) -> list: + return [d for a in self._adapters.values() for d in a.devices] + + @Property(list, "readable") + def connected_devices(self) -> list: + return [d for d in self.devices if d.connected] + + @Property(str, "readable") + def state(self) -> str: + for a in self._adapters.values(): + if a.powered: + return a.state + return "absent" if not self._adapters else "off" + + @Property(bool, "read-write", default_value=False) + def enabled(self) -> bool: + return any(a.powered for a in self._adapters.values()) + + @enabled.setter + def enabled(self, value: bool): + for a in self._adapters.values(): + a.powered = value + + @Property(bool, "read-write", default_value=False) + def scanning(self) -> bool: + return any(a.scanning for a in self._adapters.values()) + + @scanning.setter + def scanning(self, value: bool): + for a in self._adapters.values(): + if a.powered: + a.scanning = value + + @Property(bool, "read-write", "is-powered", default_value=False) + def powered(self) -> bool: + return self.enabled + + @powered.setter + def powered(self, value: bool): + self.enabled = value + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._adapters: dict[str, BluetoothAdapter] = {} + self._bus: Gio.DBusConnection | None = None + self._om_proxy: Gio.DBusProxy | None = None + self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + self._om_proxy = Gio.DBusProxy.new_sync( + self._bus, + Gio.DBusProxyFlags.NONE, + None, + BLUEZ_SERVICE, + "/", + DBUS_OM_IFACE, + None, + ) + + self._bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_OM_IFACE, + "InterfacesAdded", + None, + None, + Gio.DBusSignalFlags.NONE, + self._on_interfaces_added, + None, + ) + self._bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_OM_IFACE, + "InterfacesRemoved", + None, + None, + Gio.DBusSignalFlags.NONE, + self._on_interfaces_removed, + None, + ) + + self._populate_from_object_manager() + + def _populate_from_object_manager(self): + try: + result = self._om_proxy.call_sync( + "GetManagedObjects", None, Gio.DBusCallFlags.NONE, 5000, None + ) + objects = _unpack_variant(result)[0] + except Exception as e: + logger.warning(f"[Bluetooth] GetManagedObjects failed: {e}") + return + + for path, ifaces in objects.items(): + if BLUEZ_ADAPTER_IFACE in ifaces: + self._add_adapter(path, ifaces[BLUEZ_ADAPTER_IFACE]) + + for path, ifaces in objects.items(): + if BLUEZ_DEVICE_IFACE in ifaces: + self._add_device(path, ifaces[BLUEZ_DEVICE_IFACE]) + + def _add_adapter(self, path: str, props: dict): + if path in self._adapters: + return + logger.info(f"[Bluetooth] Adding adapter: {path}") + adapter = BluetoothAdapter( + self._bus, + path, + props, + on_changed=lambda *_: self.emit("changed"), + ) + adapter.connect( + "device-added", + lambda a, addr: ( + self.emit("device-added", addr), + self.notify("devices"), + self.notify("connected-devices"), + self.emit("changed"), + ), + ) + adapter.connect( + "device-removed", + lambda a, addr: ( + self.emit("device-removed", addr), + self.notify("devices"), + self.notify("connected-devices"), + self.emit("changed"), + ), + ) + adapter.connect( + "notify::connected-devices", + lambda *_: self.notify("connected-devices"), + ) + self._adapters[path] = adapter + self.notify("adapters") + self.emit("changed") + + def _add_device(self, path: str, props: dict): + adapter_path = _device_path_to_adapter_path(path) + adapter = self._adapters.get(adapter_path) + if not adapter: + logger.warning(f"[Bluetooth] No adapter found for device at {path}") + return + + adapter.add_device(path, props) + + def _on_interfaces_added( + self, _conn, _sender, _path, _iface, _signal, params, _data + ): + unpacked = _unpack_variant(params) + obj_path: str = unpacked[0] + ifaces: dict = unpacked[1] + + if BLUEZ_ADAPTER_IFACE in ifaces: + self._add_adapter(obj_path, ifaces[BLUEZ_ADAPTER_IFACE]) + if BLUEZ_DEVICE_IFACE in ifaces: + self._add_device(obj_path, ifaces[BLUEZ_DEVICE_IFACE]) + + def _on_interfaces_removed( + self, _conn, _sender, _path, _iface, _signal, params, _data + ): + unpacked = _unpack_variant(params) + obj_path: str = unpacked[0] + ifaces: list = unpacked[1] + + if BLUEZ_ADAPTER_IFACE in ifaces: + adapter = self._adapters.pop(obj_path, None) + if adapter: + logger.info(f"[Bluetooth] Removing adapter: {obj_path}") + adapter.close() + self.notify("adapters") + self.emit("changed") + + if BLUEZ_DEVICE_IFACE in ifaces: + adapter_path = _device_path_to_adapter_path(obj_path) + adapter = self._adapters.get(adapter_path) + if adapter: + adapter.remove_device(obj_path) + + def scan(self): + for a in self._adapters.values(): + if a.powered: + a.scan() + + def toggle_scan(self): + for a in self._adapters.values(): + if a.powered: + a.toggle_scan() + + def get_device(self, address: str) -> BluetoothDevice | None: + for adapter in self._adapters.values(): + if d := adapter.get_device(address): + return d + return None + + def connect_device( + self, + device: BluetoothDevice, + connect: bool = True, + callback: Callable[Concatenate[bool, P], Any] | None = None, + *args: P.args, + **kwargs: P.kwargs, + ): + return device.connect_device(connect, callback, *args, **kwargs) + + def remove_device(self, device: BluetoothDevice): + """Unpair/forget a device via its adapter's RemoveDevice call.""" + adapter_path = _device_path_to_adapter_path(device._object_path) + adapter = self._adapters.get(adapter_path) + if adapter: + adapter.remove_device_from_bluez(device) + else: + logger.warning( + f"[Bluetooth] Cannot remove {device.address}: adapter not found" + ) + + def notifier(self, name: str, *args): + self.notify(name) + self.emit("changed") + + +def _device_path_to_adapter_path(device_path: str) -> str: + parts = device_path.rsplit("/", 1) + return parts[0] if len(parts) == 2 else device_path + + +def _path_to_addr(object_path: str) -> str: + return object_path.split("/")[-1][4:].replace("_", ":") + + +def _icon_to_type(icon: str) -> str: + """Resolve a Bluez icon name to a GTK symbolic icon via the current theme. + + Checks the icon theme for the icon (with ``-symbolic`` suffix) and falls + back to a hardcoded mapping when the theme doesn't provide it. + """ + if icon: + icon_theme = Gtk.IconTheme.get_default() + symbolic = f"{icon}-symbolic" + if icon_theme.has_icon(symbolic): + return symbolic + if icon_theme.has_icon(icon): + return icon + + return { + "audio-headset": "audio-headset-symbolic", + "audio-headphones": "audio-headphones-symbolic", + "audio-speakers": "audio-speakers-symbolic", + "audio-card": "audio-speakers-symbolic", + "input-keyboard": "input-keyboard-symbolic", + "input-mouse": "input-mouse-symbolic", + "input-gaming": "input-gaming-symbolic", + "phone": "phone-symbolic", + "printer": "printer-symbolic", + }.get(icon, "bluetooth-symbolic") diff --git a/src/services/brightness.py b/src/services/brightness.py new file mode 100644 index 00000000..78253200 --- /dev/null +++ b/src/services/brightness.py @@ -0,0 +1,342 @@ +import time +from pathlib import Path + +from fabric.core.service import Property, Service, Signal +from fabric.utils import GLib, exec_shell_command_async, logger, os, re + +from utils.functions import find_binary, run_command + + +class Brightness(Service): + """Service for controlling screen brightness using ddcutil or brightnessctl backends. + + The service works with RAW values (0 to max_screen) for both backends: + - brightnessctl: raw values are device-specific (e.g., 0-96000) + - ddcutil: raw values are percentages (0-100) + + The 'screen' signal emits percentage values (0-100) for UI display. + """ + + _instance = None + DDCUTIL_PARAMS = "--disable-dynamic-sleep --sleep-multiplier=0.05" + MIN_CHANGE_THRESHOLD = 2 + CACHE_INTERVAL = 3 + POLL_INTERVAL = 500 + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @Signal + def screen(self, value: int) -> None: + """Signal emitted when screen brightness changes (value: percentage from 0 to 100).""" + + def __init__(self, backend=None, **kwargs): + """Initialize service with automatic backend detection.""" + if getattr(self, "_initialized", False): + return + self._initialized = True + super().__init__(**kwargs) + self._pending_raw = None + self._timer_id = None + self._poll_timer_id = None + self._lock = GLib.Mutex() + self._last_percent = -1 + self._last_raw = -1 + self._last_update_time = 0 + self._screen_device = None + self._brightness_path = None + self._max_brightness_path = None + + self.backend = self._detect_backend(backend) + + self.max_screen = self._read_max_brightness() or 100 + + if self.backend: + if self.backend == "ddcutil": + GLib.timeout_add(100, lambda: self._update_brightness_cache()) + else: + self._setup_polling() + + def _setup_polling(self): + """Setup periodic polling of brightness file.""" + try: + if self._brightness_path and self._brightness_path.exists(): + self._last_raw = int(self._brightness_path.read_text().strip()) + self._last_percent = ( + int((self._last_raw / self.max_screen) * 100) + if self.max_screen > 0 + else 0 + ) + + self._poll_timer_id = GLib.timeout_add( + self.POLL_INTERVAL, self._check_brightness_file + ) + except Exception as e: + logger.error(f"Error setting up brightness polling: {e}") + + def _check_brightness_file(self): + """Periodically check brightness file for changes.""" + try: + if self._brightness_path and self._brightness_path.exists(): + raw = int(self._brightness_path.read_text().strip()) + + if raw != self._last_raw: + self._last_raw = raw + percent = ( + int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0 + ) + if abs(percent - self._last_percent) >= self.MIN_CHANGE_THRESHOLD: + self._last_percent = percent + self.emit("screen", percent) + return True + except Exception as e: + logger.error(f"Error checking brightness file: {e}") + return True + + def _detect_backend(self, backend): + """Detect appropriate backend for brightness control.""" + if backend: + logger.info(f"Using forced backend: {backend}") + return backend + + if find_binary("brightnessctl"): + device = self._get_screen_device() + if device: + logger.info(f"Using brightnessctl backend with device: {device}") + return "brightnessctl" + else: + logger.debug( + "brightnessctl is available but no backlight devices found in /sys/class/backlight/" + ) + + if find_binary("ddcutil"): + bus = self._detect_ddcutil_bus() + if bus != -1: + self.ddcutil_bus = bus + logger.info(f"Using ddcutil backend with I2C bus: {bus}") + return "ddcutil" + else: + logger.debug( + "ddcutil is available but no DDC/CI capable monitors detected" + ) + + logger.warning( + "No available backend for brightness control - no backlight devices or DDC/CI monitors found" + ) + return None + + def _get_screen_device(self): + """Return first backlight device from sysfs, cached.""" + if getattr(self, "_screen_device", None): + return self._screen_device + + try: + devices = os.listdir("/sys/class/backlight") + if devices: + self._screen_device = devices[0] + base_path = Path(f"/sys/class/backlight/{self._screen_device}") + self._brightness_path = base_path / "brightness" + self._max_brightness_path = base_path / "max_brightness" + return self._screen_device + except Exception as e: + logger.warning( + f"[brightness] devices = os.listdir('/sys/class/backlight') failed: {e}" + ) + return "" + + def _detect_ddcutil_bus(self): + """Detect I2C bus number for ddcutil.""" + try: + process = run_command(["ddcutil", "detect"], timeout=2) + if process.returncode == 0: + match = re.search(r"I2C bus:\s*/dev/i2c-(\d+)", process.stdout) + return int(match.group(1)) if match else -1 + return -1 + except Exception as e: + logger.warning( + f"[brightness] process = run_command(['ddcutil', 'detect'], timeout=2) failed: {e}" + ) + return -1 + + def _read_max_brightness(self): + """Read maximum brightness value""" + if self.backend: + if self.backend == "ddcutil": + try: + process = run_command( + [ + "ddcutil", + "--bus", + str(self.ddcutil_bus), + *self.DDCUTIL_PARAMS.split(), + "getvcp", + "10", + ], + timeout=2, + ) + + if process.returncode == 0: + match = re.search( + r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)", + process.stdout, + ) + if match: + return int(match.group(2)) + except Exception as e: + logger.error(f"Error executing ddcutil: {e}") + else: + try: + if self._max_brightness_path and self._max_brightness_path.exists(): + return int(self._max_brightness_path.read_text().strip()) + except Exception as e: + logger.warning( + f"[brightness] if self._max_brightness_path and self._max_brightness_pat... failed: {e}" + ) + return None + return None + + def _update_brightness_cache(self): + """Update brightness cache with current value.""" + if self.backend == "ddcutil": + self.screen_brightness + return False + + @Property(int, "read-write") + def screen_brightness(self): + """Getter returns current brightness in RAW value (0 to max_screen).""" + if not self.backend: + return -1 + + if self.backend == "brightnessctl": + if self._last_raw != -1: + return self._last_raw + + try: + if self._brightness_path and self._brightness_path.exists(): + raw = int(self._brightness_path.read_text().strip()) + self._last_raw = raw + return raw + except Exception as e: + logger.error(f"Error reading brightness file: {e}") + return -1 + elif self.backend == "ddcutil": + if ( + time.time() - self._last_update_time < self.CACHE_INTERVAL + and self._last_raw != -1 + ): + return self._last_raw + + def on_ddcutil_success(stdout): + match = re.search( + r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)", + stdout, + ) + if match: + current = int(match.group(1)) + if current != self._last_raw: + self._last_raw = current + percent = ( + int((current / self.max_screen) * 100) + if self.max_screen > 0 + else 0 + ) + # Emit to update UI when background fetch completes + self.emit("screen", percent) + + # Update cache timestamp immediately to prevent spamming async commands + self._last_update_time = time.time() + + try: + exec_shell_command_async( + f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} getvcp 10", + lambda exit_code, stdout, stderr: ( + on_ddcutil_success(stdout) + if exit_code == 0 + else logger.error(f"ddcutil error (code {exit_code}): {stderr}") + ), + ) + except Exception as e: + logger.error(f"Error executing ddcutil async: {e}") + + return self._last_raw if self._last_raw != -1 else 0 + return None + + @screen_brightness.setter + def screen_brightness(self, value: int): + """Setter accepts brightness value in RAW (0 to max_screen).""" + self._lock.lock() + try: + value = max(0, min(value, self.max_screen)) + + current_percent = ( + int((self._last_raw / self.max_screen) * 100) + if self._last_raw != -1 and self.max_screen > 0 + else -1 + ) + new_percent = ( + int((value / self.max_screen) * 100) if self.max_screen > 0 else 0 + ) + + if ( + abs(new_percent - current_percent) < self.MIN_CHANGE_THRESHOLD + and self._last_raw != -1 + ): + return + + self._pending_raw = value + self._last_raw = value + + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = GLib.timeout_add(50, self._apply_brightness) + finally: + self._lock.unlock() + + def _apply_brightness(self): + """Apply pending brightness change with optimized debouncing.""" + self._lock.lock() + try: + if self._pending_raw is None: + self._timer_id = None + return False + + raw = self._pending_raw + self._pending_raw = None + self._timer_id = None + finally: + self._lock.unlock() + + try: + percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0 + + if self.backend == "brightnessctl": + self.emit("screen", percent) + exec_shell_command_async( + f"brightnessctl --device '{self._screen_device}' set {raw}" + ) + elif self.backend == "ddcutil": + self._last_update_time = time.time() + self.emit("screen", percent) + exec_shell_command_async( + f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} --terse setvcp 10 {raw}", + lambda exit_code, stdout, stderr: ( + logger.error(f"ddcutil error (code {exit_code}): {stderr}") + if exit_code != 0 + else None + ), + ) + except Exception as e: + logger.error(f"Error setting brightness: {e}") + return False + + def cleanup(self): + """Clean up resources when service is stopped.""" + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = None + + if self._poll_timer_id: + GLib.source_remove(self._poll_timer_id) + self._poll_timer_id = None diff --git a/src/services/capslock.py b/src/services/capslock.py new file mode 100644 index 00000000..f72b0526 --- /dev/null +++ b/src/services/capslock.py @@ -0,0 +1,17 @@ +from utils.evdev_lock import EvdevLockService + + +class CapsLock(EvdevLockService): + """Service for monitoring CapsLock LED state via evdev.""" + + _LED_GLOB = "input*::capslock" + _LED_INDEX = 1 # LED_CAPSL + _SERVICE_NAME = "CapsLock" + + _instance = None + + @staticmethod + def get_initial(): + if CapsLock._instance is None: + CapsLock._instance = CapsLock() + return CapsLock._instance diff --git a/src/services/config.py b/src/services/config.py new file mode 100644 index 00000000..da37bf1b --- /dev/null +++ b/src/services/config.py @@ -0,0 +1,194 @@ +""" +Centralized configuration service with file watching and dynamic reloads. + +This service encapsulates the logic for loading the application's JSON config, +watching for file changes, and notifying registered listeners when the config +changes. It is implemented as a singleton and intended to be reused by any +module that needs dynamic configuration. +""" + +from typing import Any, Callable, Dict, List, Optional + +from fabric.utils import Gio, GLib, logger, os +from tomlkit import document as toml_document +from tomlkit import dump as toml_dump +from tomlkit import load as toml_load +from tomlkit.items import Array, Bool, Integer, String + +from utils.gtk_utils import toml_file + + +class ConfigService: + """Singleton service handling config state and reload notifications.""" + + _instance: Optional["ConfigService"] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if getattr(self, "_initialized", False): + return + + self._initialized = True + self._config: Any = {} + self._reload_callbacks: List[ + Callable[[Dict[str, Any], Dict[str, Any]], None] + ] = [] + self._config_file: str = toml_file("config.toml") + self._monitors: List[Gio.FileMonitor] = [] + self._reload_pending: bool = False + self.RELOAD_DELAY_MS: int = 100 + + self._load_config() + self._last_notified_config = self._config.copy() + self._setup_monitors() + + def get(self, key: str, default: Any = None) -> Any: + return self._config.get(key, default) + + def get_all(self) -> Dict[str, Any]: + return self._config.copy() + + def has_changed(self, key: str, old_config: Dict[str, Any]) -> bool: + return self._config.get(key) != old_config.get(key) + + def set(self, key: str, value: Any) -> None: + """Set a config value (local state only).""" + self._config[key] = value + + def register_reload_callback( + self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None] + ) -> None: + if callback not in self._reload_callbacks: + self._reload_callbacks.append(callback) + + @staticmethod + def _dict_to_toml(d: dict) -> Any: + doc = toml_document() + for k, v in d.items(): + if isinstance(v, bool): + doc[k] = Bool(v) + elif isinstance(v, int): + doc[k] = Integer(v) + elif isinstance(v, str): + doc[k] = String(v, String.STANDARD) + elif isinstance(v, list): + arr = Array() + for item in v: + if isinstance(item, bool): + arr.append(Bool(item)) + elif isinstance(item, int): + arr.append(Integer(item)) + elif isinstance(item, str): + arr.append(String(item, String.STANDARD)) + else: + arr.append(item) + arr.set_multiline(True) if len(v) > 1 else None + doc[k] = arr + else: + doc[k] = v + return doc + + def _load_config(self) -> None: + try: + if os.path.exists(self._config_file): + with open(self._config_file) as f: + self._config = toml_load(f) + else: + try: + from utils.constants import DEFAULT + + os.makedirs(os.path.dirname(self._config_file), exist_ok=True) + self._config = self._dict_to_toml(DEFAULT) + with open(self._config_file, "w") as f: + toml_dump(self._config, f) + logger.info( + f"[ConfigService] Generated default config at {self._config_file}" + ) + except ImportError as e: + logger.error(f"[ConfigService] Failed to import defaults: {e}") + self._config = {} + except Exception as e: + logger.error(f"[ConfigService] Failed to load config: {e}") + self._config = {} + + def _setup_monitors(self) -> None: + files_to_watch = [self._config_file, os.path.dirname(self._config_file)] + for file_path in files_to_watch: + if not os.path.exists(file_path): + continue + try: + gio_file = Gio.File.new_for_path(file_path) + monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None) + monitor.connect("changed", self._on_file_changed, file_path) + self._monitors.append(monitor) + except Exception as e: + logger.error(f"[ConfigService] Failed to monitor {file_path}: {e}") + + def _on_file_changed(self, monitor, file, _other_file, event_type, file_path: str): + # Trigger on various change events to be more robust across editors/OSs + valid_events = [ + Gio.FileMonitorEvent.CHANGES_DONE_HINT, + Gio.FileMonitorEvent.CHANGED, + Gio.FileMonitorEvent.CREATED, + Gio.FileMonitorEvent.DELETED, + ] + if event_type in valid_events and not self._reload_pending: + self._reload_pending = True + GLib.timeout_add(self.RELOAD_DELAY_MS, self._reload_config) + + def _reload_config(self) -> bool: + try: + self._reload_pending = False + old_config = self._last_notified_config.copy() + self._load_config() + + # Always notify listeners on reload request to be safe + for callback in list(self._reload_callbacks): + try: + callback(self._config, old_config) + except Exception as e: + logger.error(f"[ConfigService] Callback failed: {e}") + + self._last_notified_config = self._config.copy() + return False + except Exception as e: + logger.error(f"[ConfigService] Failed to reload configuration: {e}") + self._reload_pending = False + return False + + +_service: Optional[ConfigService] = None + + +def start_config_service() -> ConfigService: + global _service + if _service is None: + _service = ConfigService() + return _service + + +# Convenience helpers for concise usage +def config() -> ConfigService: + """Get the singleton service (concise alias).""" + return start_config_service() + + +def get_config(key: str, default: Any = None) -> Any: + """Get a single config value from the singleton service.""" + return start_config_service().get(key, default) + + +def get_config_all() -> Dict[str, Any]: + """Get the entire current configuration state.""" + return start_config_service().get_all() + + +def on_config_change( + callback: Callable[[Dict[str, Any], Dict[str, Any]], None], +) -> None: + """Register a reload callback on the singleton service.""" + start_config_service().register_reload_callback(callback) diff --git a/services/custom_notification.py b/src/services/custom_notification.py similarity index 59% rename from services/custom_notification.py rename to src/services/custom_notification.py index 76198875..8a620ab9 100644 --- a/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -1,18 +1,6 @@ -# Standard library imports import json -import os -import time from typing import List -# Fabric imports -import gi - -gi.require_version("Gtk", "3.0") -gi.require_version("GdkPixbuf", "2.0") - -from gi.repository import GdkPixbuf - -import config.data as data from fabric.core.service import Property, Service, Signal from fabric.notifications import ( Notification, @@ -20,9 +8,10 @@ NotificationImagePixmap, Notifications, ) +from fabric.utils import GdkPixbuf, logger, os, time -gi.require_version("Gtk", "3.0") -gi.require_version("GdkPixbuf", "2.0") +import shared.data as data +from services.config import config, on_config_change NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json" @@ -32,15 +21,16 @@ class CachedNotification(Service): def create_from_dict(cls, data, **kwargs): """Create CachedNotification from enhanced JSON data""" data["timeout"] = 0 + data.setdefault("time", float(time.time())) self = cls.__new__(cls) Service.__init__(self, **kwargs) self._notification = Notification.deserialize(data) self._cache_id = data["cached-id"] # Set directly to private var - + # Store cache metadata for cleanup self.cache_metadata = data.get("cache_metadata", {}) self.timestamp = data.get("timestamp", int(time.time())) - + return self @Signal @@ -92,58 +82,75 @@ def image_file(self) -> str: @Property(object, "readable") def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None: + if hasattr(self, "_cached_image_pixbuf"): + return self._cached_image_pixbuf try: if self.image_pixmap: - return self.image_pixmap.as_pixbuf() + self._cached_image_pixbuf = self.image_pixmap.as_pixbuf() + return self._cached_image_pixbuf if self.image_file and os.path.exists(self.image_file): try: - return GdkPixbuf.Pixbuf.new_from_file(self.image_file) - except Exception: - # If file can't be loaded, return None - pass - except Exception: - # If any error occurs (including temp file gone), return None safely - pass + self._cached_image_pixbuf = GdkPixbuf.Pixbuf.new_from_file( + self.image_file + ) + return self._cached_image_pixbuf + except Exception as e: + logger.warning( + f"[custom_notification] self._cached_image_pixbuf = GdkPixbuf.Pixbuf.new_from_fil... failed: {e}" + ) + except Exception as e: + logger.warning( + f"[custom_notification] if self.image_pixmap: self._cached_image_pixbuf = self.im... failed: {e}" + ) + self._cached_image_pixbuf = None return None @Property(dict, "readable") def serialized(self) -> dict: """Enhanced serialization with cache metadata - stores only cache keys""" - from modules.notification.notification import ( + from window.notification.notification import ( get_cache_key, get_notification_image_cache_key, ) - + # Get better cache keys for icons app_icon_cache_key = None notification_image_cache_key = None - + if self.app_icon: - app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name) - + app_icon_cache_key = get_cache_key(self.app_icon, (64, 64), self.app_name) + # Only try to get notification image cache key if we can safely access image_pixbuf if self.id: try: # First check if we already have the cache key stored - if hasattr(self, 'cache_metadata') and self.cache_metadata: - notification_image_cache_key = self.cache_metadata.get('notification_image_cache_key') - + if hasattr(self, "cache_metadata") and self.cache_metadata: + notification_image_cache_key = self.cache_metadata.get( + "notification_image_cache_key" + ) + # If not, try to generate it safely - if not notification_image_cache_key and hasattr(self._notification, 'image_pixbuf'): + if not notification_image_cache_key and hasattr( + self._notification, "image_pixbuf" + ): try: # Check if image_pixbuf exists and can be accessed without loading from file - image_pixbuf = getattr(self._notification, 'image_pixbuf', None) + image_pixbuf = getattr(self._notification, "image_pixbuf", None) if image_pixbuf: - notification_image_cache_key = get_notification_image_cache_key( - self.id, image_pixbuf + notification_image_cache_key = ( + get_notification_image_cache_key(self.id, image_pixbuf) ) - except (AttributeError, OSError, Exception): + except Exception as e: # If temp file is gone or any other error, just mark as None - pass - except Exception: + logger.warning( + f"[custom_notification] image_pixbuf = getattr(self._notification, 'image_pixbuf'... failed: {e}" + ) + except Exception as e: # If any error occurs during cache key generation, skip it - pass - + logger.warning( + f"[custom_notification] if hasattr(self, 'cache_metadata') and self.cache_metadat... failed: {e}" + ) + return { "cached-id": self.cache_id, "id": self.id, @@ -157,6 +164,7 @@ def serialized(self) -> dict: "image-file": self.image_file, # Only store image-pixmap if no cache key is available (fallback) "image-pixmap": None, # Don't store image data, only cache key + "time": float(self._notification.time), "timestamp": int(time.time()), "group": self.app_name, # Group notifications by app name # Enhanced cache metadata - store only cache keys @@ -164,8 +172,8 @@ def serialized(self) -> dict: "app_icon_cache_key": app_icon_cache_key, "notification_image_cache_key": notification_image_cache_key, "has_cached_image": notification_image_cache_key is not None, - "cache_timestamp": int(time.time()) - } + "cache_timestamp": int(time.time()), + }, } def __init__(self, notification: Notification, cache_id: int, **kwargs): @@ -175,9 +183,6 @@ def __init__(self, notification: Notification, cache_id: int, **kwargs): self.cache_metadata = {} self.timestamp = int(time.time()) - def remove_from_cache(self): - self.removed_from_cache.emit() - class CachedNotifications(Notifications): """A service to manage the cached notifications.""" @@ -185,17 +190,14 @@ class CachedNotifications(Notifications): @Signal def clear_all(self) -> None: """Signal emitted when notifications are emptied.""" - pass @Signal def cached_notification_added(self, notification: CachedNotification) -> None: """Signal emitted when a notification is cached.""" - pass @Signal def cached_notification_removed(self, notification: CachedNotification) -> None: """Signal emitted when a notification is removed from cache.""" - pass @Property(List[CachedNotification], "readable") def cached_notifications(self) -> List[CachedNotification]: @@ -211,7 +213,7 @@ def count(self) -> int: def dont_disturb(self) -> bool: """Return the pause status.""" return self._dont_disturb - + def set_dont_disturb(self, value: bool): """Set the pause status.""" self._dont_disturb = value @@ -224,39 +226,75 @@ def __init__(self, **kwargs): self._dont_disturb = False self._count = 0 self._next_cache_id = 1 # Track next available cache ID - self._session_start_time = int(time.time()) # Track session start time for deduplication + self._session_start_time = int( + time.time() + ) # Track session start time for deduplication self.load_cached_notifications() - - # Connect to the notification_added signal to cache new notifications - # Note: self here refers to the CachedNotifications service, which inherits from Notifications - # So we connect to our own notification_added signal + super().notification_added.connect(self.on_notification_added) + # Listen for config changes to prune history if needed + on_config_change(self._on_config_changed) + + def _on_config_changed(self, new_config, old_config): + # If ignored apps changed, remove them from history + if config().has_changed("notification_ignored_apps", old_config): + ignored_apps = new_config.get("notification_ignored_apps", []) + ids_to_remove = [ + cid + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name in ignored_apps + ] + for cid in ids_to_remove: + self.remove_cached_notification(cid) + if ids_to_remove: + logger.debug( + f"Hot-reload: Removed {len(ids_to_remove)} notifications from ignored apps" + ) + + # If limited apps changed, prune duplicates + if config().has_changed("notification_limited_apps_history", old_config): + limited_apps = new_config.get("notification_limited_apps_history", []) + for app in limited_apps: + app_notifs = [ + (cid, cnotif.timestamp) + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name == app + ] + if len(app_notifs) > 1: + # Sort by timestamp descending and keep only the first (latest) + app_notifs.sort(key=lambda x: x[1], reverse=True) + for cid, _ in app_notifs[1:]: + self.remove_cached_notification(cid) + logger.debug(f"Hot-reload: Pruned history for limited app {app}") + def load_cached_notifications(self) -> dict[int, CachedNotification]: """Load cached notifications from a JSON file (deserialization).""" - try: - with open(NOTIFICATION_CACHE_FILE, "r") as file: - data = json.load(file) # Load list of serialized notifications - except (FileNotFoundError, json.JSONDecodeError): - # If file doesn't exist or is corrupted, start with empty list - data = [] + from utils.functions import read_json_file + + data = read_json_file(NOTIFICATION_CACHE_FILE) or [] max_cache_id = 0 for notification in data: - cached_notification = CachedNotification.create_from_dict(notification) - cache_id = cached_notification.cache_id - max_cache_id = max(max_cache_id, cache_id) - - handler_id = cached_notification.connect( - "removed-from-cache", - lambda *args: self.remove_cached_notification( - notification_id=cache_id - ), - ) - self._signal_handlers[cache_id] = handler_id - self._cached_notifications[cache_id] = cached_notification - self._count += 1 + try: + cached_notification = CachedNotification.create_from_dict(notification) + cache_id = cached_notification.cache_id + max_cache_id = max(max_cache_id, cache_id) + + handler_id = cached_notification.connect( + "removed-from-cache", + lambda *args, cid=cache_id: self.remove_cached_notification( + notification_id=cid + ), + ) + self._signal_handlers[cache_id] = handler_id + self._cached_notifications[cache_id] = cached_notification + self._count += 1 + except Exception as e: + logger.warning( + f"Failed to load a cached notification (might be from an old schema): {e}" + ) # Set next cache ID to be higher than any existing ID self._next_cache_id = max_cache_id + 1 @@ -277,16 +315,16 @@ def cache_notifications(self) -> None: def clear_all_cached_notifications(self): """Empty the notifications with enhanced cache cleanup""" # Clean up all cached files before clearing notifications - from modules.notification.notification import cleanup_all_notification_caches - + from window.notification.notification import cleanup_all_notification_caches + for cached_notification in self._cached_notifications.values(): handler_id = self._signal_handlers.pop(cached_notification.cache_id, None) if handler_id: cached_notification.disconnect(handler_id) - + # Clear all notification caches (icons and images) cleanup_all_notification_caches() - + self._cached_notifications = {} self.cache_notifications() self._count = 0 @@ -297,55 +335,87 @@ def clear_all_cached_notifications(self): def on_notification_added(self, service, notification_id: int) -> None: """Handle notification added and cache it with enhanced metadata - GUARANTEED STORAGE""" # Don't call super() - we're handling this ourselves - + # Import logger at the top of the function - from loguru import logger - notification = self.get_notification_from_id(notification_id) if not notification: - logger.error(f"CRITICAL: Failed to get notification with ID {notification_id}") + logger.error( + f"CRITICAL: Failed to get notification with ID {notification_id}" + ) return # Import here to avoid circular imports - from config import data - from modules.notification.notification import ( - preload_notification_assets, + from window.notification.notification import ( cache_notification_icon, cache_notification_image, get_cache_key, - get_notification_image_cache_key + get_notification_image_cache_key, + preload_notification_assets, ) # Check if this app should be ignored for history (don't cache) - if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: + ignored_apps = config().get("notification_ignored_apps", []) + if notification.app_name in ignored_apps: # Don't cache notifications from ignored apps, but still allow popup display - logger.debug(f"Ignoring notification from {notification.app_name} (in ignore list)") + logger.debug( + f"Ignoring notification from {notification.app_name} (in ignore list)" + ) return # Check for duplicates using both notification ID and timestamp to avoid session restart issues existing_notification = None current_time = int(time.time()) - + for cached_notif in self._cached_notifications.values(): # Only consider it a duplicate if: # 1. Same notification ID AND - # 2. Notification was cached in the current session (after session start time) AND + # 2. Notification was cached in the current session (after session start time) AND # 3. Notification was cached recently (within last 5 minutes) - cached_time = getattr(cached_notif, 'timestamp', 0) + cached_time = getattr(cached_notif, "timestamp", 0) is_recent = (current_time - cached_time) < 300 # 5 minutes is_current_session = cached_time >= self._session_start_time - - if (cached_notif._notification.id == notification.id and - is_current_session and is_recent): + + if ( + cached_notif._notification.id == notification.id + and is_current_session + and is_recent + ): existing_notification = cached_notif break - + if existing_notification: - logger.debug(f"Notification ID {notification.id} already cached in current session, skipping") + logger.debug( + f"Notification ID {notification.id} already cached in current session, skipping" + ) return - logger.debug(f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}...") + logger.debug( + f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}..." + ) + + # Handle limited history apps - remove previous notifications from same app + limited_apps = config().get("notification_limited_apps_history", []) + if notification.app_name in limited_apps: + ids_to_remove = [ + cid + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name == notification.app_name + ] + for cid in ids_to_remove: + self.remove_cached_notification(cid) + logger.debug( + f"Pruned {len(ids_to_remove)} old notifications for limited app {notification.app_name}" + ) + + # Enforce global max history size (50 notifications max) to prevent memory leak + MAX_HISTORY = 50 + while len(self._cached_notifications) >= MAX_HISTORY: + if not self._cached_notifications: + break + oldest_id = min(self._cached_notifications.keys()) + self.remove_cached_notification(oldest_id) + logger.debug(f"History full, pruned oldest notification {oldest_id}") # GUARANTEED STORAGE: Always create and store notification to history first cache_id = self._next_cache_id @@ -355,101 +425,130 @@ def on_notification_added(self, service, notification_id: int) -> None: cached_notification = CachedNotification( notification=notification, cache_id=cache_id ) - # Set cache_id directly since it's read-only property + # Set cache_id directly since it's read-only property cached_notification._cache_id = cache_id - + # Initialize cache metadata (will be populated below) cached_notification.cache_metadata = { "app_icon_cache_key": None, "notification_image_cache_key": None, "has_cached_image": False, - "cache_timestamp": int(time.time()) + "cache_timestamp": int(time.time()), } - + # IMMEDIATELY store to history before attempting any caching operations handler_id = cached_notification.connect( "removed-from-cache", - lambda *args: self.remove_cached_notification(notification_id=cache_id), + lambda *args, cid=cache_id: self.remove_cached_notification( + notification_id=cid + ), ) self._signal_handlers[cache_id] = handler_id self._cached_notifications[cache_id] = cached_notification - + # Save to JSON file immediately - GUARANTEED STORAGE try: self.cache_notifications() logger.debug(f"GUARANTEED: Notification {cache_id} stored to history") except Exception as e: - logger.error(f"CRITICAL: Failed to save notification {cache_id} to history: {e}") - + logger.error( + f"CRITICAL: Failed to save notification {cache_id} to history: {e}" + ) + # Now attempt asset caching (failures here won't affect history storage) try: # Preload assets and store cache metadata preload_notification_assets(notification) - + # Store enhanced cache metadata app_icon_cache_key = None notification_image_cache_key = None - + if notification.app_icon: try: # Only cache at 35x35 to reduce disk usage - headers will scale this down - app_icon_cache_key = get_cache_key(notification.app_icon, (35, 35), notification.app_name) - cache_notification_icon(notification.app_icon, (35, 35), notification.app_name) - cached_notification.cache_metadata["app_icon_cache_key"] = app_icon_cache_key + app_icon_cache_key = get_cache_key( + notification.app_icon, (64, 64), notification.app_name + ) + cache_notification_icon( + notification.app_icon, (64, 64), notification.app_name + ) + cached_notification.cache_metadata["app_icon_cache_key"] = ( + app_icon_cache_key + ) logger.debug(f"Cached app icon (35x35) for notification {cache_id}") except Exception as e: - logger.warning(f"Failed to cache app icon for notification {cache_id}: {e}") - - if hasattr(notification, 'image_pixbuf'): + logger.warning( + f"Failed to cache app icon for notification {cache_id}: {e}" + ) + + if hasattr(notification, "image_pixbuf"): try: # Safely try to access image_pixbuf - image_pixbuf = getattr(notification, 'image_pixbuf', None) + image_pixbuf = getattr(notification, "image_pixbuf", None) if image_pixbuf: notification_image_cache_key = get_notification_image_cache_key( notification.id, image_pixbuf ) - cache_notification_image(notification.id, image_pixbuf, (35, 35)) - cached_notification.cache_metadata["notification_image_cache_key"] = notification_image_cache_key + cache_notification_image( + notification.id, image_pixbuf, (128, 128) + ) + cached_notification.cache_metadata[ + "notification_image_cache_key" + ] = notification_image_cache_key cached_notification.cache_metadata["has_cached_image"] = True - logger.debug(f"Cached notification image for notification {cache_id}") - except (AttributeError, OSError, Exception) as e: - logger.warning(f"Failed to cache notification image for notification {cache_id}: {e}") - + logger.debug( + f"Cached notification image for notification {cache_id}" + ) + except Exception as e: + logger.warning( + f"Failed to cache notification image for notification {cache_id}: {e}" + ) + # Update cached notification with final metadata self._cached_notifications[cache_id] = cached_notification - + # Save updated metadata to JSON self.cache_notifications() - + except Exception as e: - logger.error(f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}") + logger.error( + f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}" + ) # Always emit signals regardless of caching success self.notify("count") self.emit("cached-notification-added", cached_notification) - - logger.debug(f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}") + + logger.debug( + f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}" + ) def remove_cached_notification(self, notification_id: int): """Remove the notification of given id with enhanced cache cleanup""" if notification_id in self._cached_notifications: cached_notification = self._cached_notifications.pop(notification_id) - + # Enhanced cache cleanup using stored metadata - if hasattr(cached_notification, 'cache_metadata'): + if hasattr(cached_notification, "cache_metadata"): cache_metadata = cached_notification.cache_metadata - + # Clean up specific cached files using stored keys - from modules.notification.notification import cleanup_notification_specific_caches + from window.notification.notification import ( + cleanup_notification_specific_caches, + ) + cleanup_notification_specific_caches( app_icon_source=cached_notification.app_icon, - notification_image_cache_key=cache_metadata.get('notification_image_cache_key') + notification_image_cache_key=cache_metadata.get( + "notification_image_cache_key" + ), ) - + self.cache_notifications() # Update JSON self._count -= 1 self.notify("count") - + # Get the stored signal handler ID and disconnect it handler_id = self._signal_handlers.pop(notification_id, None) if handler_id: diff --git a/src/services/gamemode.py b/src/services/gamemode.py new file mode 100644 index 00000000..fb57e9db --- /dev/null +++ b/src/services/gamemode.py @@ -0,0 +1,97 @@ +"""Hyprland Game Mode service.""" + +from fabric.core.service import Property, Service, Signal + +from utils.functions import run_command + +HYPRCTL = "hyprctl" + +_ENABLE_LUA = ( + "hl.config({" + " animations = { enabled = false }," + " decoration = { shadow = { enabled = false }, blur = { enabled = false }," + " rounding = 0 }," + " general = { gaps_in = 0, gaps_out = 0, border_size = 1 }" + "})" +) + + +class GameModeService(Service): + """Owns all Hyprland interaction for game mode and notifies observers.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @staticmethod + def get_initial(): + if GameModeService._instance is None: + GameModeService._instance = GameModeService() + return GameModeService._instance + + @Signal + def changed(self, enabled: bool) -> None: + """Emitted when game mode is enabled or disabled.""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + super().__init__(**kwargs) + self._initialized = True + self._enabled = False + self.sync_state() + + @Property(bool, "readable", default_value=False) + def enabled(self) -> bool: + """Whether game mode (visual effects off) is currently active.""" + return self._enabled + + def toggle(self) -> None: + if self._enabled: + self.disable() + else: + self.enable() + + def enable(self) -> None: + if self._enabled: + return + run_command([HYPRCTL, "eval", _ENABLE_LUA], timeout=5) + self._set_enabled(True) + + def disable(self) -> None: + if not self._enabled: + return + # A full reload restores the user's exact prior configuration (gaps, + # border, rounding, etc.) which per-option restores cannot reproduce, + # so it is kept instead of guessing default values. + run_command([HYPRCTL, "reload"], timeout=5) + self._set_enabled(False) + + def sync_state(self) -> None: + """Read the authoritative state from Hyprland (animations toggle).""" + result = run_command( + [HYPRCTL, "eval", "return hl.get_config('animations.enabled')"], timeout=5 + ) + if result.returncode != 0: + return + value = result.stdout.strip().lower() + if value in ("true", "1", "on"): + animations_on = True + elif value in ("false", "0", "off"): + animations_on = False + else: + # Unrecognized output (e.g. an error string): keep current state + # rather than falsely reporting game mode as active. + return + # Game mode is on when Hyprland animations are disabled. + self._set_enabled(not animations_on) + + def _set_enabled(self, enabled: bool) -> None: + if enabled == self._enabled: + return + self._enabled = enabled + self.notify("enabled") + self.emit("changed", enabled) diff --git a/src/services/google_lens.py b/src/services/google_lens.py new file mode 100644 index 00000000..a2be6029 --- /dev/null +++ b/src/services/google_lens.py @@ -0,0 +1,178 @@ +import contextlib +import tempfile +import webbrowser +from urllib.parse import quote + +from fabric.core.service import Service +from fabric.utils import logger, os + +from utils.functions import run_command + +DEFAULT_UPLOADER = "https://litterbox.catbox.moe/resources/internals/api.php" + + +class GoogleLens(Service): + """Service to capture region and search with Google Lens.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @staticmethod + def get_initial(): + if GoogleLens._instance is None: + GoogleLens._instance = GoogleLens() + return GoogleLens._instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def capture_region(self, dest_path): + from utils.functions import find_binary + + # Check for dependencies + if not find_binary("grim"): + raise RuntimeError("grim not found - please install it") + + if not find_binary("slurp"): + raise RuntimeError("slurp not found - please install it") + + try: + # Select region + result = run_command(["slurp"], timeout=60) + if result.returncode != 0: + if result.returncode == 1: + raise RuntimeError("Selection cancelled") + raise RuntimeError(f"Failed to select region: {result.stderr}") + + geom = ( + result.stdout.strip() + if isinstance(result.stdout, str) + else result.stdout.decode("utf-8").strip() + ) + + if not geom: + raise RuntimeError("No region selected") + + # Capture selected region + capture_result = run_command(["grim", "-g", geom, dest_path], timeout=60) + if capture_result.returncode != 0: + raise RuntimeError(f"Failed to capture region: {capture_result.stderr}") + + except Exception as e: + raise RuntimeError(f"Failed to capture region: {e}") from e + + def upload_file(self, uploader_url, file_path): + url_resp = None + + try: + import mimetypes + import uuid + from urllib.request import Request, urlopen + + boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}" + headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} + + data = [] + fields = { + "reqtype": "fileupload", + "time": "1h", + } + + for name, value in fields.items(): + data.append(f"--{boundary}".encode()) + data.append(f'Content-Disposition: form-data; name="{name}"'.encode()) + data.append(b"") + data.append(value.encode()) + + filename = os.path.basename(file_path) + mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + + data.append(f"--{boundary}".encode()) + data.append( + f'Content-Disposition: form-data; name="fileToUpload"; filename="{filename}"'.encode() + ) + data.append(f"Content-Type: {mime_type}".encode()) + data.append(b"") + with open(file_path, "rb") as f: + data.append(f.read()) + + data.append(f"--{boundary}--".encode()) + data.append(b"") + + body = b"\r\n".join(data) + req = Request(uploader_url, data=body, headers=headers) + + with urlopen(req, timeout=60) as response: + url_resp = response.read().decode("utf-8").strip() + except Exception as e: + logger.error(f"Upload with urllib failed: {e}") + + # Fallback to curl + if url_resp is None: + if run_command(["which", "curl"]).returncode != 0: + raise RuntimeError("Neither requests module nor curl available") + + cmd = [ + "curl", + "-sS", + "-F", + "reqtype=fileupload", + "-F", + "time=1h", + "-F", + f"fileToUpload=@{file_path}", + uploader_url, + ] + result = run_command(cmd) + if result.returncode != 0: + raise RuntimeError(f"Upload failed: {result.stderr}") + + url_resp = ( + result.stdout.strip() + if isinstance(result.stdout, str) + else result.stdout.decode("utf-8").strip() + ) + + # catbox.moe returns the URL directly + if ( + not url_resp + or not isinstance(url_resp, str) + or not url_resp.startswith("http") + ): + raise RuntimeError(f"Invalid response from uploader: {url_resp}") + + return url_resp + + def search(self): + tmp = tempfile.NamedTemporaryFile( + prefix="google_lens_", suffix=".png", delete=False + ) + tmp_path = tmp.name + tmp.close() + + try: + # Capture region + logger.info("Select a region...") + self.capture_region(tmp_path) + + # Upload + logger.info("Uploading...") + uploaded_url = self.upload_file(DEFAULT_UPLOADER, tmp_path) + logger.info(f"Uploaded: {uploaded_url}") + + # Open in Lens + lens_url = f"https://lens.google.com/uploadbyurl?url={quote(uploaded_url, safe='')}" + logger.info("Opening Google Lens...") + webbrowser.open(lens_url) + + except Exception as e: + logger.error(f"Error: {e}") + finally: + # Cleanup + if os.path.exists(tmp_path): + with contextlib.suppress(OSError): + os.unlink(tmp_path) diff --git a/src/services/inhibit.py b/src/services/inhibit.py new file mode 100644 index 00000000..15487eed --- /dev/null +++ b/src/services/inhibit.py @@ -0,0 +1,130 @@ +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger + +_BUS_NAME = "org.freedesktop.ScreenSaver" +_OBJECT_PATH = "/org/freedesktop/ScreenSaver" +_INTERFACE = "org.freedesktop.ScreenSaver" + + +class InhibitService(Service): + """Service for managing idle inhibition via org.freedesktop.ScreenSaver D-Bus.""" + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @Signal + def inhibit_changed(self, active: bool) -> None: + """Signal emitted when inhibit state changes.""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + self._initialized = True + super().__init__(**kwargs) + self._cookie: int | None = None + self._proxy: Gio.DBusProxy | None = None + self._timer_source: int | None = None + + @Property(bool, "readable", default_value=False) + def active(self) -> bool: + return self._cookie is not None + + def _get_proxy(self) -> Gio.DBusProxy | None: + if self._proxy is not None: + return self._proxy + try: + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + if bus is None: + return None + self._proxy = Gio.DBusProxy.new_sync( + bus, + Gio.DBusProxyFlags.NONE, + None, + _BUS_NAME, + _OBJECT_PATH, + _INTERFACE, + None, + ) + return self._proxy + except Exception as e: + logger.error(f"[InhibitService] Failed to create D-Bus proxy: {e}") + return None + + def enable(self) -> bool: + if self.active: + return True + proxy = self._get_proxy() + if proxy is None: + return False + try: + result = proxy.call_sync( + "Inhibit", + GLib.Variant("(ss)", ("Modus", "Caffeine mode")), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + if result is not None: + self._cookie = result.unpack()[0] + self.emit("inhibit-changed", True) + return True + return False + except Exception as e: + logger.error(f"[InhibitService] Failed to inhibit: {e}") + return False + + def enable_timed(self, seconds: int) -> bool: + """Enable inhibition for *seconds*, then auto-disable.""" + self._cancel_timer() + if not self.enable(): + return False + self._timer_source = GLib.timeout_add_seconds(seconds, self._on_timer_done) + return True + + def _on_timer_done(self) -> bool: + self._timer_source = None + self.disable() + return False # remove the source + + def _cancel_timer(self) -> None: + if self._timer_source is not None: + GLib.source_remove(self._timer_source) + self._timer_source = None + + def disable(self) -> bool: + if not self.active: + return True + self._cancel_timer() + proxy = self._get_proxy() + if proxy is None or self._cookie is None: + return False + try: + proxy.call_sync( + "Uninhibit", + GLib.Variant("(u)", (self._cookie,)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + self._cookie = None + self.emit("inhibit-changed", False) + return True + except Exception as e: + logger.error(f"[InhibitService] Failed to uninhibit: {e}") + return False + + def toggle(self) -> bool: + if self.active: + self.disable() + return False + else: + self.enable() + return True + + +def get_inhibit_service() -> InhibitService: + return InhibitService() diff --git a/src/services/keyboard_layout.py b/src/services/keyboard_layout.py new file mode 100644 index 00000000..c0221d77 --- /dev/null +++ b/src/services/keyboard_layout.py @@ -0,0 +1,235 @@ +import json + +from fabric.core.service import Property, Service, Signal +from fabric.hyprland.service import Hyprland +from fabric.utils import Gio, GLib, logger + +from services.config import get_config, on_config_change + +HYPRCTL_BIN = "hyprctl" +DEFAULT_LAYOUTS = ["us", "np"] + + +class KeyboardLayout(Service): + """In-memory service that treats Hyprland as the source of truth for the + active keyboard layout. All runtime state lives in memory; Hyprland is + queried/synced via hyprctl and kept in sync through its event socket.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @staticmethod + def get_initial(): + if KeyboardLayout._instance is None: + KeyboardLayout._instance = KeyboardLayout() + return KeyboardLayout._instance + + @Signal + def layout_changed(self, layout: str) -> None: + """Signal emitted when the active keyboard layout changes.""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + super().__init__(**kwargs) + self._initialized = True + + self._layouts: list[str] = [] + self._current_index: int = 0 + self._current_layout: str | None = None + self._sync_pending: bool = False + + self._init_layout_config() + self._connect_hyprland_events() + on_config_change(self._on_config_change) + + # ── hyprctl wrapper ──────────────────────────────────────────────────────── + + def _hyprctl(self, *args, on_reply=None): + """Execute a hyprctl command via Gio.Subprocess. + + Every hyprctl invocation goes through here. The call is asynchronous and + never blocks the UI. ``on_reply`` (optional) receives the stripped stdout + string, or ``None`` on failure. + """ + argv = [HYPRCTL_BIN, *[str(arg) for arg in args]] + try: + proc = Gio.Subprocess.new( + argv, + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE, + ) + except GLib.Error as error: + logger.error(f"[KeyboardLayout] Failed to run {argv}: {error}") + if on_reply is not None: + on_reply(None) + return + + def on_done(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, _ = process.communicate_utf8_finish(task) + except GLib.Error: + stdout = None + if on_reply is not None: + on_reply(stdout.strip() if stdout else None) + + proc.communicate_utf8_async(None, None, on_done) + + # ── initialization ───────────────────────────────────────────────────────── + + def _init_layout_config(self): + self._layouts = self._load_layouts_from_config() + + if not self._layouts: + logger.warning("[KeyboardLayout] No keyboard layouts configured.") + return + + self._apply_layouts_to_hyprland() + self._sync_from_hyprland() + + if self._current_layout is None: + self._set_current(0, emit=False) + + def _load_layouts_from_config(self) -> list[str]: + layouts = get_config("keyboard_layouts", DEFAULT_LAYOUTS) + if not isinstance(layouts, list) or not layouts: + return list(DEFAULT_LAYOUTS) + return [str(layout).strip() for layout in layouts if str(layout).strip()] + + # ── Hyprland synchronization ─────────────────────────────────────────────── + + def _sync_from_hyprland(self): + """Query Hyprland for the current layout and update in-memory state. + + Prefers the ``active_layout_index`` reported by ``hyprctl -j devices``, + falling back to index 0 when the information is unavailable. + """ + if self._sync_pending: + return + self._sync_pending = True + + def on_reply(stdout: str | None): + self._sync_pending = False + if not stdout: + return + try: + devices = json.loads(stdout) + except json.JSONDecodeError as error: + logger.error(f"[KeyboardLayout] Failed to parse devices: {error}") + return + + keyboard = self._pick_keyboard(devices.get("keyboards", [])) + if keyboard is None: + return + + index = keyboard.get("active_layout_index", 0) + self._set_current(index, emit=True) + + self._hyprctl("-j", "devices", on_reply=on_reply) + + @staticmethod + def _pick_keyboard(keyboards: list[dict]) -> dict | None: + if not keyboards: + return None + for kb in keyboards: + if kb.get("main"): + return kb + return keyboards[0] + + def _connect_hyprland_events(self): + try: + Hyprland().connect("event::activelayout", self._on_hyprland_layout_event) + except Exception as error: + logger.warning( + f"[KeyboardLayout] Could not subscribe to Hyprland layout " + f"events (external changes won't be tracked): {error}" + ) + + def _on_hyprland_layout_event(self, _hyprland, _event): + self._sync_from_hyprland() + + # ── state management ─────────────────────────────────────────────────────── + + def _set_current(self, index: int, emit: bool) -> bool: + if not self._layouts: + return False + + index = max(0, min(index, len(self._layouts) - 1)) + layout = self._layouts[index] + + if index == self._current_index and layout == self._current_layout: + return False + + self._current_index = index + self._current_layout = layout + self.notify("current_layout", "current_index", "layouts") + + if emit: + self.emit("layout_changed", layout) + return True + + # ── Hyprland writes ──────────────────────────────────────────────────────── + + def _apply_layouts_to_hyprland(self): + if not self._layouts: + return + layouts_str = ",".join(self._layouts) + lua_eval_cmd = f"hl.config({{ input = {{ kb_layout = '{layouts_str}' }} }})" + self._hyprctl("eval", lua_eval_cmd) + + # ── public API ───────────────────────────────────────────────────────────── + + def switch_to_next(self) -> bool: + if not self._layouts: + logger.warning("[KeyboardLayout] No layouts configured, cannot cycle.") + return False + + next_index = (self._current_index + 1) % len(self._layouts) + self._hyprctl("switchxkblayout", "all", str(next_index)) + self._set_current(next_index, emit=True) + return True + + @staticmethod + def switch_keyboard_layout(): + KeyboardLayout.get_initial().switch_to_next() + + # ── config changes ───────────────────────────────────────────────────────── + + def _on_config_change(self, new_config, old_config): + new_layouts = new_config.get("keyboard_layouts") + if not isinstance(new_layouts, list) or not new_layouts: + return + new_layouts = [str(item).strip() for item in new_layouts if str(item).strip()] + if new_layouts == self._layouts: + return + + previous_layout = self._current_layout + self._layouts = new_layouts + self._apply_layouts_to_hyprland() + + preserved_index = ( + self._layouts.index(previous_layout) + if previous_layout in self._layouts + else 0 + ) + self._hyprctl("switchxkblayout", "all", str(preserved_index)) + self._set_current(preserved_index, emit=True) + + logger.info(f"[KeyboardLayout] Layouts updated from config: {self._layouts}") + + # ── properties ───────────────────────────────────────────────────────────── + + @Property(str, "readable", default_value="") + def current_layout(self) -> str: + return self._current_layout or "" + + @Property(list, "readable") + def layouts(self) -> list[str]: + return self._layouts + + @Property(int, "readable", default_value=0) + def current_index(self) -> int: + return self._current_index diff --git a/src/services/modus.py b/src/services/modus.py new file mode 100644 index 00000000..1d3b1958 --- /dev/null +++ b/src/services/modus.py @@ -0,0 +1,623 @@ +import json +import os +import sys + +from fabric.core.service import Property, Service, Signal +from fabric.hyprland.service import Hyprland +from fabric.utils import exec_shell_command_async, logger + +from services.custom_notification import CachedNotifications +from services.inhibit import get_inhibit_service +from utils.app_name_resolver import app_name_resolver +from utils.functions import ( + find_binary, + find_process_pid, + spawn_detached, +) +from utils.functions import ( + kill_process as _kill_process, +) + + +def __getattr__(name): + mod = sys.modules[__name__] + if name == "notification_service": + _inst = CachedNotifications() + setattr(mod, name, _inst) + return _inst + if name == "modus_service": + return get_modus_service() + if name == "audio_service": + try: + from fabric.audio import Audio + + _inst = Audio() + except Exception as e: + logger.error(f"[Main] Failed to create AudioService: {e}") + _inst = None + setattr(mod, name, _inst) + return _inst + if name == "screenshot_service": + from services.screenshot import Screenshot + + _inst = Screenshot() + setattr(mod, name, _inst) + return _inst + if name == "screen_recorder_service": + from services.screenrecorder import ScreenRecorder + + _inst = ScreenRecorder() + setattr(mod, name, _inst) + return _inst + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def get_notification_service(): + import sys + + return sys.modules[__name__].notification_service + + +class ModusService(Service): + @Signal + def bluetooth_changed(self, new_bluetooth: str) -> None: ... + + @Signal + def volume_changed(self, new_volume: int) -> None: ... + + @Signal + def wlan_changed(self, new_wlan: str) -> None: ... + + @Signal + def battery_changed(self, new_battery: str) -> None: ... + + @Signal + def dont_disturb_changed(self, value: bool) -> None: ... + + @Signal + def current_active_app_name_changed(self, value: str) -> None: ... + + @Signal + def current_active_wm_class_changed(self, value: str) -> None: ... + + @Signal + def current_workspace_changed(self, value: str) -> None: ... + + @Signal + def music_changed(self, value: str) -> None: ... + + @Signal + def current_dropdown_changed(self, value: str) -> None: ... + + @Signal + def dropdowns_hide_changed(self, value: bool) -> None: ... + + @Signal + def dock_width_changed(self, value: int) -> None: ... + + @Signal + def dock_height_changed(self, value: int) -> None: ... + + @Signal + def dock_hidden_changed(self, value: bool) -> None: ... + + @Signal + def show_notificationcenter_changed(self, value: bool) -> None: ... + + @Signal + def fullscreen_changed(self, value: bool) -> None: ... + + @Signal + def notification_count_changed(self, value: int) -> None: ... + + @Property(str, flags="read-write") + def current_active_app_name(self) -> str: + return self._current_active_app_name + + @Property(str, flags="read-write") + def current_active_wm_class(self) -> str: + return self._current_active_wm_class + + @Property(str, flags="read-write") + def current_workspace(self) -> str: + return self._current_workspace + + @Property(str, flags="read-write") + def bluetooth(self) -> str: + return self._bluetooth + + @Property(str, flags="read-write") + def wlan(self) -> str: + return self._wlan + + @Property(str, flags="read-write") + def battery(self) -> str: + return self._battery + + @Property(int, flags="read-write") + def volume(self) -> int: + return self._volume + + @Property(str, flags="read-write") + def dock_apps(self) -> str: + return self._dock_apps + + @Property(bool, flags="read-write", default_value=False) + def dont_disturb(self) -> bool: + return self._dont_disturb + + @Property(str, flags="read-write") + def music(self) -> str: + return self._music + + @Property(str, flags="read-write") + def current_dropdown(self) -> str: + return self._current_dropdown + + @Property(bool, flags="read-write", default_value=False) + def dropdowns_hide(self) -> bool: + return self._dropdowns_hide + + @Property(int, flags="read-write") + def dock_width(self) -> int: + return self._dock_width + + @Property(int, flags="read-write") + def dock_height(self) -> int: + return self._dock_height + + @Property(bool, flags="read-write", default_value=False) + def dock_hidden(self) -> bool: + return self._dock_hidden + + @Property(bool, flags="read-write", default_value=False) + def show_notificationcenter(self) -> bool: + return self._show_notificationcenter + + @Property(bool, flags="read-write", default_value=False) + def fullscreen(self) -> bool: + return self._fullscreen + + @fullscreen.setter + def fullscreen(self, value: bool): + if value != self._fullscreen: + self._fullscreen = value + self.fullscreen_changed(value) + + @current_active_app_name.setter + def current_active_app_name(self, value: str): + if value != self._current_active_app_name: + self._current_active_app_name = value + self.current_active_app_name_changed(value) + + @current_active_wm_class.setter + def current_active_wm_class(self, value: str): + if value != self._current_active_wm_class: + self._current_active_wm_class = value + self.current_active_wm_class_changed(value) + + @current_workspace.setter + def current_workspace(self, value: str): + if value != self._current_workspace: + self._current_workspace = value + self.current_workspace_changed(value) + + @volume.setter + def volume(self, value: int): + if value != self._volume: + self._volume = value + self.volume_changed(value) + + @wlan.setter + def wlan(self, value: str): + if value != self._wlan: + self._wlan = value + self.wlan_changed(value) + + @battery.setter + def battery(self, value: str): + if value != self._battery: + self._battery = value + self.battery_changed(value) + + @bluetooth.setter + def bluetooth(self, value: str): + if value != self._bluetooth: + self._bluetooth = value + self.bluetooth_changed(value) + + @dont_disturb.setter + def dont_disturb(self, value: bool): + if value != self._dont_disturb: + self._dont_disturb = value + self.dont_disturb_changed(value) + + @music.setter + def music(self, value: str): + if value != self._music: + self._music = value + self.music_changed(value) + + @current_dropdown.setter + def current_dropdown(self, value: str): + if value != self._current_dropdown: + self._current_dropdown = value + self.current_dropdown_changed(value) + + @dropdowns_hide.setter + def dropdowns_hide(self, value: bool): + if value != self._dropdowns_hide: + self._dropdowns_hide = value + self.dropdowns_hide_changed(value) + + @dock_width.setter + def dock_width(self, value: int): + if value != self._dock_width: + self._dock_width = value + self.dock_width_changed(value) + + @dock_height.setter + def dock_height(self, value: int): + if value != self._dock_height: + self._dock_height = value + self.dock_height_changed(value) + + @dock_hidden.setter + def dock_hidden(self, value: bool): + if value != self._dock_hidden: + self._dock_hidden = value + self.dock_hidden_changed(value) + + @show_notificationcenter.setter + def show_notificationcenter(self, value: bool): + if value != self._show_notificationcenter: + self._show_notificationcenter = value + self.show_notificationcenter_changed(value) + + def sc(self, signal_name: str, callback: callable, def_value="..."): + self.connect(signal_name, callback) + if signal_name == "bluetooth-changed": + return self.bluetooth if self.bluetooth else "Off" + elif signal_name == "wlan-changed": + return self.wlan if self.wlan else "No Connection" + elif signal_name == "battery-changed": + return self.battery if self.battery else "Unknown" + elif signal_name == "music-changed": + return self.music if self.music else "" + else: + return def_value + + def __init__(self): + super().__init__() + self._volume = 0 + self._wlan = "" + self._battery = "" + self._bluetooth = "" + self._dock_apps = "" + self._dont_disturb = False + self._current_active_app_name = "Modus" + self._current_active_wm_class = "" + self._current_workspace = "1" + self._music = "" + self._current_dropdown = None + self._dropdowns_hide = False + self._dock_hidden = False + self._show_notificationcenter = False + self._dock_width = 0 + self._dock_height = 0 + self._fullscreen = False + + @property + def notification_count(self) -> int: + return get_notification_service().count + + +_modus_instance: ModusService | None = None +_hyprland_connection: Hyprland | None = None + + +def get_modus_service() -> ModusService: + global _modus_instance + if _modus_instance is None: + try: + _modus_instance = ModusService() + _setup_workspace_monitoring() + _setup_active_window_monitoring() + except Exception as e: + logger.error("[ModusService] Failed to create instance:", e) + raise + return _modus_instance + + +def _setup_workspace_monitoring(): + global _hyprland_connection + service = get_modus_service() + try: + _hyprland_connection = Hyprland() + service._hyprland_connection = _hyprland_connection + workspace_data = _hyprland_connection.send_command("j/activeworkspace").reply + active_workspace = json.loads(workspace_data.decode("utf-8"))["name"] + service._current_workspace = str(active_workspace) + _hyprland_connection.connect("event::workspace", _on_workspace_changed) + _hyprland_connection.connect("event::fullscreen", _on_fullscreen_changed) + except Exception as e: + logger.error(f"[ModusService] Failed to setup workspace monitoring: {e}") + service._current_workspace = "1" + + +def _setup_active_window_monitoring(): + global _hyprland_connection + get_modus_service() + try: + if not _hyprland_connection: + return + _update_active_window() + except Exception as e: + logger.error(f"[ModusService] Failed to setup active window monitoring: {e}") + + +def _update_active_window(): + global _hyprland_connection + service = get_modus_service() + try: + if not _hyprland_connection: + return + window_data = _hyprland_connection.send_command("j/activewindow").reply + if not window_data: + service.current_active_app_name = "Modus" + return + window_info = json.loads(window_data.decode("utf-8")) + wmclass = window_info.get("class", "") + title = window_info.get("title", "") + if not title and not wmclass: + service.current_active_app_name = "Modus" + return + + name = app_name_resolver.format_app_name(title, wmclass) + service.current_active_wm_class = wmclass + service.current_active_app_name = name + except Exception as e: + logger.error(f"[ModusService] Error updating active window: {e}") + service.current_active_app_name = "Modus" + + +def _on_workspace_changed(obj, signal): + service = get_modus_service() + try: + workspace_name = json.loads(signal.data[0]) + service.current_workspace = str(workspace_name) + except Exception as e: + logger.error(f"[ModusService] Error processing workspace change: {e}") + + +def _on_fullscreen_changed(obj, event): + service = get_modus_service() + try: + data = event.data[0] if event.data else "" + mode = data.split(":")[-1] if ":" in data else "0" + service.fullscreen = mode in ("1", "2") + except Exception as e: + logger.error(f"[ModusService] Error processing fullscreen change: {e}") + + +def toggle_dnd(): + ns = get_notification_service() + ns.toggle_dnd() + get_modus_service().dont_disturb_changed(ns.dont_disturb) + + +# --- Night light orchestration --- +_night_light_active = False + + +def toggle_night_light() -> bool: + global _night_light_active + try: + if _night_light_active: + _kill_process("hyprsunset") + _night_light_active = False + else: + spawn_detached(["hyprsunset", "-t", "4500"]) + _night_light_active = True + return True + except Exception as e: + logger.error(f"[Modus] Failed to toggle night light: {e}") + return False + + +def is_night_light_active() -> bool: + global _night_light_active + if _night_light_active: + return True + try: + _night_light_active = bool(find_process_pid("hyprsunset")) + return _night_light_active + except Exception as e: + logger.warning( + f"[modus] _night_light_active = bool(find_process_pid('hyprsunset')) failed: {e}" + ) + return False + + +# --- Caffeine orchestration --- + + +def is_caffeine_active() -> bool: + return get_inhibit_service().active + + +def toggle_caffeine() -> bool: + return get_inhibit_service().toggle() + + +# --- Hyprland window management API --- + + +def _ensure_hyprland(): + """Ensure hyprland_connection is initialized.""" + global _hyprland_connection + if _hyprland_connection is None: + get_modus_service() + return _hyprland_connection + + +def _hyprctl_send(cmd: str) -> bytes | None: + conn = _ensure_hyprland() + if not conn: + return None + try: + reply = conn.send_command(cmd).reply + return reply + except Exception as e: + logger.error(f"[Modus] hyprctl send error ({cmd}): {e}") + return None + + +def _hyprctl_json(cmd: str) -> list | dict | None: + reply = _hyprctl_send(cmd) + if not reply: + return None + try: + return json.loads(reply.decode("utf-8")) + except Exception as e: + logger.error(f"[Modus] hyprctl JSON parse error ({cmd}): {e}") + return None + + +def get_clients() -> list: + data = _hyprctl_json("j/clients") + return data if isinstance(data, list) else [] + + +def get_active_window() -> dict: + data = _hyprctl_json("j/activewindow") + return data if isinstance(data, dict) else {} + + +def get_monitors() -> list: + data = _hyprctl_json("j/monitors") + return data if isinstance(data, list) else [] + + +def get_focused_monitor() -> dict | None: + monitors = get_monitors() + for monitor in monitors: + if monitor.get("focused"): + return monitor + return monitors[0] if monitors else None + + +def get_monitor_names() -> list[str]: + return [name for m in get_monitors() if (name := m.get("name"))] + + +def get_active_monitor_name(fallback: str = "eDP-1") -> str: + monitor = get_focused_monitor() + if monitor and (name := monitor.get("name")): + return name + return fallback + + +def get_active_workspace_id() -> int: + data = _hyprctl_json("j/activeworkspace") + if isinstance(data, dict): + return data.get("id", -1) + return -1 + + +def get_monitor_for_workspace(workspace_id: int | None = None) -> dict | None: + if workspace_id is None: + workspace_id = get_active_workspace_id() + for monitor in get_monitors(): + if monitor.get("activeWorkspace", {}).get("id") == workspace_id: + return monitor + monitors = get_monitors() + return monitors[0] if monitors else None + + +def get_screen_dimensions() -> tuple[int, int]: + monitor = get_monitor_for_workspace() + if monitor: + return monitor.get("width", 1920), monitor.get("height", 1080) + return 1920, 1080 + + +def focus_window(address: str): + exec_shell_command_async( + f"hyprctl dispatch 'hl.dsp.focus({{ window = \"address:{address}\" }})'" + ) + + +def close_window(address: str): + exec_shell_command_async( + f"hyprctl dispatch 'hl.dsp.window.close({{ window = \"address:{address}\" }})'" + ) + + +def launch_app(command_line: str): + from window.globalmenu.launch import launch_command + + launch_command(command_line) + + +def open_trash(): + trash_path = os.path.expanduser("~/.local/share/Trash/files") + if not os.path.exists(trash_path): + return + for fm in ["nautilus", "dolphin", "thunar", "nemo", "caja", "pcmanfm"]: + try: + if find_binary(fm): + spawn_detached([fm, trash_path]) + return + except Exception as e: + logger.warning( + f"[modus] if find_binary(fm): spawn_detached([fm, trash_path]) return failed: {e}" + ) + continue + + +# Occlusion +def check_occlusion(occlusion_region, workspace=None) -> bool: + if workspace is None: + workspace = get_active_workspace_id() + + if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2: + side, size = occlusion_region + if isinstance(side, str): + screen_width, screen_height = get_screen_dimensions() + if side.lower() == "bottom": + occlusion_region = (0, screen_height - size, screen_width, size) + elif side.lower() == "top": + occlusion_region = (0, 0, screen_width, size) + elif side.lower() == "left": + occlusion_region = (0, 0, size, screen_height) + elif side.lower() == "right": + occlusion_region = (screen_width - size, 0, size, screen_height) + + if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4: + return False + + clients = get_clients() + occ_x, occ_y, occ_width, occ_height = occlusion_region + occ_x2 = occ_x + occ_width + occ_y2 = occ_y + occ_height + + for client in clients: + if not client.get("mapped", False): + continue + client_workspace = client.get("workspace", {}) + if client_workspace.get("id") != workspace: + continue + position = client.get("at") + size = client.get("size") + if not position or not size: + continue + x, y = position + width, height = size + win_x1, win_y1 = x, y + win_x2, win_y2 = x + width, y + height + if not ( + win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2 + ): + return True + return False diff --git a/src/services/mpris.py b/src/services/mpris.py new file mode 100644 index 00000000..dabffdc9 --- /dev/null +++ b/src/services/mpris.py @@ -0,0 +1,580 @@ +import hashlib +import mimetypes +import threading +import urllib.parse +import urllib.request +from pathlib import Path + +import gi +from fabric.utils import GLib, exec_shell_command_async, logger + +gi.require_version("Playerctl", "2.0") +from fabric import Fabricator # noqa: E402 +from fabric.core.service import Property, Service, Signal # noqa: E402 +from gi.repository import Playerctl # noqa: E402 + +from shared.data import CACHE_DIR # noqa: E402 + +TEMP_DIR = CACHE_DIR + +# Native Linux media artwork cache (libmediaart). Optional: when the GI +# namespace is unavailable we transparently fall back to our own cache dir, +# so the service keeps working without adding a hard dependency. +try: + gi.require_version("MediaArt", "2.0") + from gi.repository import MediaArt + + _MEDIAART_AVAILABLE = True +except (ValueError, ImportError): + MediaArt = None + _MEDIAART_AVAILABLE = False + + +def _mediaart_cache_path(artist: str | None, album: str | None, title: str | None): + """Standard media-art cache path for a track, or None when unavailable. + + Does NOT download; merely derives the canonical cache location so we can + both look artwork up and store new downloads following the same rules. + """ + if not _MEDIAART_AVAILABLE: + return None + try: + result = MediaArt.get_path( + artist or None, album or None, title or None, MediaArt.Type.ALBUM + ) + except Exception as e: + logger.debug(f"MediaArt.get_path failed: {e}") + return None + # PyGObject return shape varies by version: (found, path, uri) or (path, uri). + if isinstance(result, tuple): + if len(result) == 3: + found, path, _uri = result + elif len(result) == 2: + found, path = True, result[0] + else: + return None + return path if found else None + return result + + +class PlayerService(Service): + @Signal + def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ... + + @Signal + def artwork_change(self, local_path: str) -> None: ... + + @Signal + def pause(self) -> None: ... + + @Signal + def play(self) -> None: ... + + @Signal + def track_position(self, pos: float, dur: float) -> None: ... + + @Property(bool, "readable", default_value=False) + def can_seek(self) -> bool: + return self._player.get_property("can_seek") + + @Property(str, "readable", default_value="") + def player_name(self) -> str: + try: + return self._player.props.player_name or "" + except Exception as e: + logger.warning( + f"[mpris] return self._player.props.player_name or '' failed: {e}" + ) + return "" + + @Property(str, "readable", default_value="") + def title(self) -> str: + try: + return self._player.get_title() or "" + except Exception as e: + logger.warning(f"[mpris] return self._player.get_title() or '' failed: {e}") + return "" + + @Property(object, "readable") + def artist(self) -> list: + try: + return list(self._player.props.metadata["xesam:artist"]) or [] + except Exception as e: + logger.warning( + f"[mpris] return list(self._player.props.metadata['xesam:artist']) ... failed: {e}" + ) + return [] + + @Property(str, "readable", default_value="") + def album(self) -> str: + try: + return self._player.props.metadata["xesam:album"] or "" + except Exception as e: + logger.warning( + f"[mpris] return self._player.props.metadata['xesam:album'] or '' failed: {e}" + ) + return "" + + @Property(str, "readable", default_value="Stopped") + def playback_status(self) -> str: + try: + val = int(self._player.props.playback_status) + if val == 0: + return "Playing" + elif val == 1: + return "Paused" + else: + return "Stopped" + except Exception as e: + logger.warning( + f"[mpris] val = int(self._player.props.playback_status) failed: {e}" + ) + return "Stopped" + + @Property(int, "readable", default_value=0) + def length(self) -> int: + try: + return int(self._player.props.metadata["mpris:length"]) + except Exception as e: + logger.warning( + f"[mpris] return int(self._player.props.metadata['mpris:length']) failed: {e}" + ) + return 0 + + @Property(int, "readable", default_value=0) + def position(self) -> int: + try: + return int(self._player.get_position()) + except Exception as e: + logger.warning( + f"[mpris] return int(self._player.get_position()) failed: {e}" + ) + return 0 + + def play_pause(self, *_): + try: + self._player.play_pause() + except GLib.Error as e: + logger.warning(f"play_pause failed: {e}") + + def next(self, *_): + try: + self._player.next() + except GLib.Error as e: + logger.warning(f"next failed: {e}") + + def previous(self, *_): + try: + self._player.previous() + except GLib.Error as e: + logger.warning(f"previous failed: {e}") + + def __init__(self, player: Playerctl.Player, **kwargs): + super().__init__(**kwargs) + self._player: Playerctl.Player = player + self._current_artwork_hash = "" + self._current_artwork_path = "" + self._is_cleaning_up = False + self._signal_ids = [] + self._last_emitted_status = "" + + self._signal_ids.append( + self._player.connect("playback-status", self.on_playback_status) + ) + self._signal_ids.append(self._player.connect("metadata", self.on_metadata)) + self._signal_ids.append(self._player.connect("seeked", self.on_seeked)) + + self._pos_polling = False # guard: Fabricator.start() blindly creates a new + # GLib timer every call — this flag prevents stacking + self.pos_fabricator = Fabricator( + interval=2000, + poll_from=lambda f, *_: self.get_position(), + on_changed=lambda f, *_: self.fabricating(), + ) + self.status_fabricator = Fabricator( + interval=5000, + poll_from=lambda f, *_: self.playback_status, + on_changed=lambda f, value: self._on_polled_status_change(value), + ) + self.status_fabricator.start() + self.poll_progress() + + try: + metadata = self._player.props.metadata + if metadata: + # meta_change emission must not block artwork discovery. + try: + self.meta_change(metadata, self._player) + except Exception as e: + logger.debug(f"meta_change emit failed: {e}") + self._handle_artwork(metadata) + except Exception as e: + logger.warning(f"Failed to initialize metadata: {e}") + + # Playerctl loads player properties asynchronously. For a player that + # was already playing before Modus started, the initial metadata read + # above can be empty, and the `metadata` signal only fires on *change* + # — so it would never re-fire and artwork would stay missing. Browser + # players (YouTube in Firefox/Chromium) in particular populate + # `mpris:artUrl` lazily, seconds after playback begins. Re-read metadata + # repeatedly for a short window so the artwork is eventually picked up. + GLib.timeout_add(500, self._refresh_initial_state, 0) + + def _refresh_initial_state(self, attempt: int = 0): + if self._is_cleaning_up: + return False + try: + metadata = self._player.props.metadata + except Exception as e: + logger.warning( + f"[mpris] metadata = self._player.props.metadata failed: {e}" + ) + return False + if metadata: + # Push full metadata only once to avoid UI churn on every retry. + if attempt == 0: + try: + self.meta_change(metadata, self._player) + except Exception as e: + logger.debug(f"meta_change emit failed: {e}") + self._handle_artwork(metadata) + self.fabricating(metadata) + # Stop as soon as artwork was found/queued. + if self._current_artwork_hash: + return False + # Retry for ~10s (browser players can be very slow to expose artUrl). + if attempt < 12: + GLib.timeout_add(800, self._refresh_initial_state, attempt + 1) + return False + + def get_artwork(self) -> str: + return self._current_artwork_path + + def get_position(self) -> float: + if self._is_cleaning_up: + return 0 + try: + return self._player.get_position() + except Exception as e: + logger.warning(f"Could not get position: {e}") + return 0 + + def _start_pos_fabricator(self): + """Start position polling only if not already running.""" + if self._pos_polling: + return + self._pos_polling = True + self.pos_fabricator.start() + + def _stop_pos_fabricator(self): + """Stop position polling and clear the guard flag.""" + if not self._pos_polling: + return + self._pos_polling = False + self.pos_fabricator.stop() + + def seek_position(self, pos: float): + if self._is_cleaning_up: + return + self._stop_pos_fabricator() + current = self.get_position() / 1_000_000 + try: + self._player.set_position(int(pos * 1_000_000)) + except GLib.Error: + try: + offset = pos - current + self._player.seek(int(offset * 1_000_000)) + except GLib.Error: + name = self.player_name + if name: + # No shell: pass args as a list so player_name can't inject. + exec_shell_command_async( + ["playerctl", "-p", name, "position", str(pos)] + ) + finally: + if self.playback_status.lower() == "playing": + self._start_pos_fabricator() + + def seek(self, offset: float): + if self._is_cleaning_up: + return + self._stop_pos_fabricator() + try: + self._player.seek(int(offset * 1_000_000)) + except GLib.Error as e: + logger.error(f"Failed to seek: {e}") + finally: + if self.playback_status.lower() == "playing": + self._start_pos_fabricator() + + def poll_progress(self): + if self._is_cleaning_up: + return + if self.playback_status.lower() == "playing": + self._start_pos_fabricator() + else: + self._stop_pos_fabricator() + + def fabricating(self, metadata=None): + if self._is_cleaning_up: + return + try: + pos = self._player.get_position() / 1_000_000 + except GLib.Error as e: + logger.warning(f"Failed to get position: {e}") + return + dur = 0 + if metadata is None: + try: + metadata = self._player.props.metadata + except Exception: + metadata = None + if metadata is not None: + try: + dur = metadata["mpris:length"] / 1_000_000 + except Exception: + dur = 0 + # Browser players (YouTube in Firefox/Chromium) populate + # `mpris:artUrl` lazily and don't reliably emit the `metadata` + # signal. Since fabricating runs every 2s while playing, this also + # polls the artwork so it shows up even for an already-playing tab. + self._handle_artwork(metadata) + self.track_position(pos, dur) + + def on_seeked(self, player, position): + if self._is_cleaning_up: + return + if self.playback_status.lower() == "playing": + self._start_pos_fabricator() + + def _notify_playback(self, status_str: str): + """Emit play/pause once per actual state change (dedupes the + Playerctl signal and the status Fabricator so listeners don't churn).""" + if self._is_cleaning_up: + return + status_str = (status_str or "").lower() + if status_str == self._last_emitted_status: + return + self._last_emitted_status = status_str + if status_str == "playing": + self.play() + else: + self.pause() + + def on_playback_status(self, player, status): + """DBus signal handler - instant when it fires (e.g. Modus buttons).""" + if self._is_cleaning_up: + return + self.poll_progress() + self._notify_playback(self.playback_status) + + def _on_polled_status_change(self, new_status: str): + """Fabricator-driven status change - fires reliably for browser players + that don't emit playback-status signals.""" + if self._is_cleaning_up: + return + self.poll_progress() + self._notify_playback(new_status) + + def on_metadata(self, player, metadata): + if self._is_cleaning_up: + return + self.meta_change(metadata, player) + self.fabricating(metadata) + + def _meta_str(self, metadata, key: str) -> str | None: + try: + value = metadata[key] + except (KeyError, TypeError, GLib.Error) as e: + logger.warning(f"[mpris] value = metadata[key] failed: {e}") + return None + if isinstance(value, (list, tuple)): + return " ".join(str(v) for v in value) if value else None + return str(value) if value else None + + def _find_cached_artwork(self, metadata) -> str | None: + """Locate already-cached artwork for this track (native MediaArt cache).""" + if not _MEDIAART_AVAILABLE: + return None + artist = self._meta_str(metadata, "xesam:artist") + album = self._meta_str(metadata, "xesam:album") + title = self._meta_str(metadata, "xesam:title") + if not (artist or album or title): + return None + base = _mediaart_cache_path(artist, album, title) + if not base: + return None + base = Path(base) + if base.exists(): + return str(base) + # MediaArt files usually carry an extension; look for any sibling. + matches = list(base.parent.glob(base.stem + ".*")) + return str(matches[0]) if matches else None + + def _existing_local_artwork(self, artwork_hash: str, metadata) -> str | None: + cached = self._find_cached_artwork(metadata) + if cached: + return cached + cache_dir = TEMP_DIR / "player-art" + matches = list(cache_dir.glob(f"{artwork_hash}.*")) + return str(matches[0]) if matches else None + + def _handle_artwork(self, metadata): + if self._is_cleaning_up: + return + try: + art_url = metadata["mpris:artUrl"] + except (KeyError, TypeError) as e: + logger.warning(f"[mpris] art_url = metadata['mpris:artUrl'] failed: {e}") + return + if not art_url: + return + artwork_hash = hashlib.md5(art_url.encode()).hexdigest() + + if artwork_hash == self._current_artwork_hash: + return + self._current_artwork_hash = artwork_hash + + parsed = urllib.parse.urlparse(art_url) + if parsed.scheme == "file": + local = urllib.parse.unquote(parsed.path) + if Path(local).exists(): + self._set_artwork(local) + return + + if parsed.scheme in ("http", "https"): + existing = self._existing_local_artwork(artwork_hash, metadata) + if existing and Path(existing).exists(): + self._set_artwork(existing) + return + threading.Thread( + target=self._download_artwork, + args=(art_url, artwork_hash, metadata), + daemon=True, + ).start() + + def _set_artwork(self, path: str): + if self._is_cleaning_up: + return + self._current_artwork_path = path + self.artwork_change(path) + + def _artwork_target_path(self, artwork_hash: str, metadata, suffix: str) -> Path: + """Where to store a freshly downloaded cover: the standard MediaArt + cache location when available, otherwise our own fallback cache.""" + ma_base = self._find_cached_artwork(metadata) + if ma_base: + base = Path(ma_base) + return base.parent / (base.stem + suffix) + cache_dir = TEMP_DIR / "player-art" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / f"{artwork_hash}{suffix}" + + def _download_artwork(self, art_url: str, artwork_hash: str, metadata): + if self._is_cleaning_up: + return + try: + with urllib.request.urlopen(art_url, timeout=5) as response: + data = response.read() + suffix = ( + mimetypes.guess_extension(response.info().get_content_type()) + or ".png" + ) + target = self._artwork_target_path(artwork_hash, metadata, suffix) + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_bytes(data) + tmp.replace(target) + + if not self._is_cleaning_up: + GLib.idle_add(self._set_artwork, str(target)) + + except Exception as e: + logger.error(f"Failed to download artwork: {e}") + + def cleanup(self): + if self._is_cleaning_up: + return + self._is_cleaning_up = True + + try: + if hasattr(self, "pos_fabricator"): + self._stop_pos_fabricator() + except Exception as e: + logger.error(f"Error stopping fabricator: {e}") + + try: + if hasattr(self, "status_fabricator"): + self.status_fabricator.stop() + except Exception as e: + logger.error(f"Error stopping status fabricator: {e}") + + for signal_id in self._signal_ids: + try: + self._player.disconnect(signal_id) + except Exception as e: + logger.warning(f"Error disconnecting signal: {e}") + self._signal_ids.clear() + self._current_artwork_path = "" + + +class PlayerManager(Service): + _instance = None + + @Signal + def new_player(self, player_name: str, service: PlayerService) -> None: ... + + @Signal + def player_vanish(self, player_name: str) -> None: ... + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._init_singleton() + return cls._instance + + def _init_singleton(self): + super().__init__() + self._manager = Playerctl.PlayerManager() + self._services: dict[str, PlayerService] = {} + self._player_objects: dict[str, Playerctl.Player] = {} + + self._manager.connect("name-appeared", self._on_name_appeared, self._manager) + self._manager.connect( + "player-vanished", self._on_player_vanished, self._manager + ) + self._init_existing_players() + + def _init_existing_players(self): + for player_obj in self._manager.props.player_names: + self._create_player(player_obj) + + def _create_player(self, name_obj): + name_str = name_obj.name + if name_str in self._services: + return + try: + player = Playerctl.Player.new_from_name(name_obj) + self._manager.manage_player(player) + service = PlayerService(player) + self._services[name_str] = service + self._player_objects[name_str] = player + self.new_player(name_str, service) + except Exception as e: + logger.error(f"Failed to create player {name_str}: {e}") + + def _on_name_appeared(self, sender, name, _manager): + self._create_player(name) + + def _on_player_vanished(self, sender, player, _manager): + name = player.props.player_name + if name in self._services: + self._services[name].cleanup() + del self._services[name] + self._player_objects.pop(name, None) + self.player_vanish(name) + + def get_player_service(self, name: str) -> PlayerService | None: + return self._services.get(name) + + def get_all_services(self) -> dict[str, PlayerService]: + return self._services.copy() diff --git a/src/services/network.py b/src/services/network.py new file mode 100644 index 00000000..af6c0119 --- /dev/null +++ b/src/services/network.py @@ -0,0 +1,476 @@ +from types import SimpleNamespace +from typing import List, Literal + +import gi +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, bulk_connect, exec_shell_command_async, logger + +try: + gi.require_version("NM", "1.0") + from gi.repository import NM +except ValueError: + logger.error("Failed to start network manager") + + +class Wifi(Service): + """A service to manage the wifi connection.""" + + @Signal + def changed(self) -> None: ... + + def __init__(self, client: NM.Client, device: NM.DeviceWifi, **kwargs): + self._client: NM.Client = client + self._device: NM.DeviceWifi = device + self._ap: NM.AccessPoint | None = None + self._ap_signal: int | None = None + super().__init__(**kwargs) + + self._client_handler = self._client.connect( + "notify::wireless-enabled", + lambda *args: self.notifier("enabled", args), + ) + if self._device: + self._device_handlers = bulk_connect( + self._device, + { + "notify::active-access-point": lambda *args: self._activate_ap(), + "access-point-added": lambda *args: self.emit("changed"), + "access-point-removed": lambda *args: self.emit("changed"), + "state-changed": lambda *args: self.ap_update(), + }, + ) + self._activate_ap() + + def close(self): + """Disconnect all signal handlers so the service can be garbage collected.""" + try: + self._client.disconnect(self._client_handler) + except Exception as e: + logger.warning( + f"[network] self._client.disconnect(self._client_handler) failed: {e}" + ) + for hid in getattr(self, "_device_handlers", ()): + try: + self._device.disconnect(hid) + except Exception as e: + logger.warning(f"[network] self._device.disconnect(hid) failed: {e}") + if getattr(self, "_ap", None) and getattr(self, "_ap_signal", None): + try: + self._ap.disconnect(self._ap_signal) + except Exception as e: + logger.warning( + f"[network] self._ap.disconnect(self._ap_signal) failed: {e}" + ) + + def ap_update(self): + self.emit("changed") + for sn in [ + "enabled", + "internet", + "strength", + "frequency", + "access-points", + "ssid", + "state", + "icon-name", + ]: + self.notify(sn) + + def _activate_ap(self): + if self._ap: + self._ap.disconnect(self._ap_signal) + self._ap = self._device.get_active_access_point() + if not self._ap: + return + + self._ap_signal = self._ap.connect( + "notify::strength", lambda *args: self.ap_update() + ) + + def toggle_wifi(self): + self._client.wireless_set_enabled(not self._client.wireless_get_enabled()) + + def scan(self): + if hasattr(self, "_scanning") and self._scanning: + return + self._scanning = True + + def _on_scan_finish(device, result): + self._scanning = False + try: + device.request_scan_finish(result) + except Exception as e: + logger.debug(f"Scan finished with error: {e}") + self.emit("changed") + + try: + self._device.request_scan_async(None, _on_scan_finish) + except Exception: + self._scanning = False + + def notifier(self, name: str, *args): + self.notify(name) + self.emit("changed") + return + + @Property(bool, "read-write", default_value=False) + def enabled(self) -> bool: + return bool(self._client.wireless_get_enabled()) + + @enabled.setter + def enabled(self, value: bool): + self._client.wireless_set_enabled(value) + + @Property(int, "readable") + def strength(self): + return self._ap.get_strength() if self._ap else -1 + + @Property(str, "readable") + def icon_name(self): + if not self._ap: + return "network-wireless-disabled-symbolic" + + if self.internet == "activated": + return { + 80: "network-wireless-signal-excellent-symbolic", + 60: "network-wireless-signal-good-symbolic", + 40: "network-wireless-signal-ok-symbolic", + 20: "network-wireless-signal-weak-symbolic", + 00: "network-wireless-signal-none-symbolic", + }.get( + min(80, 20 * round(self._ap.get_strength() / 20)), + "network-wireless-no-route-symbolic", + ) + if self.internet == "activating": + return "network-wireless-acquiring-symbolic" + + return "network-wireless-offline-symbolic" + + @Property(int, "readable") + def frequency(self): + return self._ap.get_frequency() if self._ap else -1 + + @Property(str, "readable") + def internet(self): + conn = self._device.get_active_connection() + if not conn: + return "unknown" + return { + NM.ActiveConnectionState.ACTIVATED: "activated", + NM.ActiveConnectionState.ACTIVATING: "activating", + NM.ActiveConnectionState.DEACTIVATING: "deactivating", + NM.ActiveConnectionState.DEACTIVATED: "deactivated", + }.get(conn.get_state(), "unknown") + + @Property(object, "readable") + def access_points(self) -> List[object]: + points: list[NM.AccessPoint] = self._device.get_access_points() + active_bssid = self._ap.get_bssid() if self._ap else None + + def make_ap(ap: NM.AccessPoint): + ssid_bytes = ap.get_ssid() + ssid = ( + NM.utils_ssid_to_utf8(ssid_bytes.get_data()) + if ssid_bytes + else "Unknown" + ) + bssid = ap.get_bssid() + strength = ap.get_strength() + is_active = bssid == active_bssid if active_bssid else False + flags = ap.get_rsn_flags() or ap.get_wpa_flags() + requires_password = bool(flags) + icon = { + 80: "network-wireless-signal-excellent-symbolic", + 60: "network-wireless-signal-good-symbolic", + 40: "network-wireless-signal-ok-symbolic", + 20: "network-wireless-signal-weak-symbolic", + 0: "network-wireless-signal-none-symbolic", + }.get( + min(80, 20 * round(strength / 20)), + "network-wireless-no-route-symbolic", + ) + return SimpleNamespace( + ssid=ssid, + bssid=bssid, + strength=strength, + is_active=is_active, + requires_password=requires_password, + icon=icon, + ) + + return list(map(make_ap, points)) + + @Property(str, "readable") + def ssid(self): + if not self._ap: + return "Disconnected" + ssid = self._ap.get_ssid().get_data() + return NM.utils_ssid_to_utf8(ssid) if ssid else "Unknown" + + @Property(int, "readable") + def state(self): + return { + NM.DeviceState.UNMANAGED: "unmanaged", + NM.DeviceState.UNAVAILABLE: "unavailable", + NM.DeviceState.DISCONNECTED: "disconnected", + NM.DeviceState.PREPARE: "prepare", + NM.DeviceState.CONFIG: "config", + NM.DeviceState.NEED_AUTH: "need_auth", + NM.DeviceState.IP_CONFIG: "ip_config", + NM.DeviceState.IP_CHECK: "ip_check", + NM.DeviceState.SECONDARIES: "secondaries", + NM.DeviceState.ACTIVATED: "activated", + NM.DeviceState.DEACTIVATING: "deactivating", + NM.DeviceState.FAILED: "failed", + }.get(self._device.get_state(), "unknown") + + +class Ethernet(Service): + """A service to manage the ethernet connection.""" + + @Signal + def changed(self) -> None: ... + + @Property(int, "readable") + def speed(self) -> int: + return self._device.get_speed() + + @Property(int, "readable") + def internet(self): + conn = self._device.get_active_connection() + if not conn: + return "unknown" + return { + NM.ActiveConnectionState.ACTIVATED: "activated", + NM.ActiveConnectionState.ACTIVATING: "activating", + NM.ActiveConnectionState.DEACTIVATING: "deactivating", + NM.ActiveConnectionState.DEACTIVATED: "deactivated", + }.get(conn.get_state(), "unknown") + + @Property(str, "readable") + def icon_name(self) -> str: + network = self.internet + if network == "activated": + return "network-wired-symbolic" + + elif network == "activating": + return "network-wired-acquiring-symbolic" + + elif self._device.get_connectivity != NM.ConnectivityState.FULL: + return "network-wired-no-route-symbolic" + + return "network-wired-disconnected-symbolic" + + def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None: + super().__init__(**kwargs) + self._client: NM.Client = client + self._device: NM.DeviceEthernet = device + + self._handlers: list[int] = [] + for pn in ( + "active-connection", + "icon-name", + "internet", + "speed", + "state", + ): + self._handlers.append( + self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn)) + ) + + self._handlers.append( + self._device.connect("notify::speed", lambda *_: self.notifier("speed")) + ) + + def notifier(self, pn): + self.notify(pn) + self.emit("changed") + + def close(self): + """Disconnect all signal handlers so the service can be garbage collected.""" + for hid in getattr(self, "_handlers", ()): + try: + self._device.disconnect(hid) + except Exception as e: + logger.warning(f"[network] self._device.disconnect(hid) failed: {e}") + + +class NetworkClient(Service): + @Signal + def device_ready(self) -> None: ... + + @Signal + def device_added(self, iface: str) -> None: ... + + @Signal + def device_removed(self, iface: str) -> None: ... + + def __init__(self, **kwargs): + self._client: NM.Client | None = None + self.wifi_device: Wifi | None = None + self.wifi_devices: dict[str, Wifi] = {} + self.ethernet_device: Ethernet | None = None + super().__init__(**kwargs) + NM.Client.new_async( + cancellable=None, + callback=self._init_network_client, + ) + + def _init_network_client(self, client: NM.Client, task: Gio.Task, **kwargs): + self._client = client + + for device in self._client.get_devices(): + self._handle_device_added(device) + + self._client.connect("device-added", lambda _, d: self._handle_device_added(d)) + self._client.connect( + "device-removed", lambda _, d: self._handle_device_removed(d) + ) + + self.notify("primary-device") + + def _handle_device_added(self, nm_device): + dtype = nm_device.get_device_type() + + if dtype == NM.DeviceType.WIFI: + iface = nm_device.get_iface() + if iface in self.wifi_devices: + return + wifi = Wifi(self._client, nm_device) + self.wifi_devices[iface] = wifi + if self.wifi_device is None: + self.wifi_device = wifi + logger.info(f"[Network] Wifi device added: {iface}") + self.emit("device-added", iface) + self.emit("device-ready") + + elif dtype == NM.DeviceType.ETHERNET: + if self.ethernet_device is None: + self.ethernet_device = Ethernet(client=self._client, device=nm_device) + self.emit("device-ready") + + def _handle_device_removed(self, nm_device): + dtype = nm_device.get_device_type() + + if dtype == NM.DeviceType.WIFI: + iface = nm_device.get_iface() + wifi = self.wifi_devices.pop(iface, None) + if wifi is None: + return + if self.wifi_device is wifi: + self.wifi_device = next(iter(self.wifi_devices.values()), None) + wifi.close() + logger.info(f"[Network] Wifi device removed: {iface}") + self.emit("device-removed", iface) + + elif dtype == NM.DeviceType.ETHERNET: + if self.ethernet_device is not None: + self.ethernet_device.close() + self.ethernet_device = None + self.emit("device-removed", nm_device.get_iface()) + + def _get_primary_device(self) -> Literal["wifi", "wired"] | None: + if not self._client: + return None + try: + conn_type = self._client.get_primary_connection().get_connection_type() + if "wireless" in str(conn_type): + return "wifi" + if "ethernet" in str(conn_type): + return "wired" + except Exception as e: + logger.error(f"An error occurred: {e}") + return None + + def is_network_saved(self, ssid: str) -> bool: + client = self._client or NM.Client.new(None) + if not client: + return False + for conn in client.get_connections(): + s_wifi = conn.get_setting_wireless() + if s_wifi: + saved_ssid = s_wifi.get_ssid() + if saved_ssid: + if NM.utils_ssid_to_utf8(saved_ssid.get_data()) == ssid: + return True + return False + + def connect_wifi_bssid(self, bssid: str, callback=None): + if not self._client or not self.wifi_device: + if callback: + callback(False, "No wifi device") + return + + device = self.wifi_device._device + handler_id: list[int] = [] + + def _on_state_changed(_dev, new_state, _old_state, reason): + if not callback: + return + state = NM.DeviceState(new_state) + if state == NM.DeviceState.ACTIVATED: + _cleanup() + callback(True, "Connected") + elif state in (NM.DeviceState.FAILED, NM.DeviceState.DISCONNECTED): + _cleanup() + reason_str = NM.DeviceStateReason(reason).value_nick + if "secret" in reason_str or "auth" in reason_str: + callback(False, "Wrong password") + else: + callback(False, f"Failed ({reason_str})") + elif state == NM.DeviceState.NEED_AUTH: + _cleanup() + callback(False, "Wrong password") + + def _cleanup(): + if handler_id: + try: + device.disconnect_by_func(_on_state_changed) + except Exception as e: + logger.error(f"An error occurred: {e}") + + if callback: + handler_id.append(device.connect("state-changed", _on_state_changed)) + + exec_shell_command_async( + f"nmcli device wifi connect {bssid}", + lambda *args: logger.debug(f"connect result: {args}"), + ) + + def connect_wifi_with_password(self, bssid: str, password: str, callback): + if not self._client or not self.wifi_device: + callback(False, "No wifi device") + return + + device = self.wifi_device._device + handler_id: list[int] = [] + + def _on_state_changed(_dev, new_state, _old_state, reason): + state = NM.DeviceState(new_state) + if state == NM.DeviceState.ACTIVATED: + _cleanup() + callback(True, "Connected") + elif state in (NM.DeviceState.FAILED, NM.DeviceState.DISCONNECTED): + _cleanup() + reason_str = NM.DeviceStateReason(reason).value_nick + if "secret" in reason_str or "auth" in reason_str: + callback(False, "Wrong password") + else: + callback(False, f"Failed ({reason_str})") + elif state == NM.DeviceState.NEED_AUTH: + _cleanup() + callback(False, "Wrong password") + + def _cleanup(): + if handler_id: + try: + device.disconnect_by_func(_on_state_changed) + except Exception as e: + logger.error(f"An error occurred: {e}") + + handler_id.append(device.connect("state-changed", _on_state_changed)) + + exec_shell_command_async( + f"nmcli device wifi connect {bssid} password {password!r}", + lambda *args: logger.debug(f"connect_with_password result: {args}"), + ) diff --git a/src/services/nightlight.py b/src/services/nightlight.py new file mode 100644 index 00000000..1e102925 --- /dev/null +++ b/src/services/nightlight.py @@ -0,0 +1,206 @@ +import re + +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger + +from services.config import get_config, on_config_change + +_CONFIG_KEY = "night_light_temperature" +_DEFAULT_TEMPERATURE = 4500 +_NEUTRAL_TEMPERATURE = 6000 +_PROFILE_TEMP_RE = re.compile(r"(\d{3,5})") + + +def _coerce_temperature(value, fallback: int = _DEFAULT_TEMPERATURE) -> int: + try: + return int(value) + except (TypeError, ValueError) as e: + logger.warning(f"[nightlight] return int(value) failed: {e}") + return fallback + + +class NightLightService(Service): + """Blue-light filter service backed by a persistent hyprsunset daemon. + + All state changes go through ``hyprctl hyprsunset`` IPC so the daemon is + started at most once and never respawned on every toggle. + """ + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @Signal + def night_light_changed(self, active: bool) -> None: + """Emitted when the filter is enabled or disabled.""" + + @Signal + def temperature_changed(self, value: int) -> None: + """Emitted when the color temperature changes.""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + self._initialized = True + super().__init__(**kwargs) + self._active = False + self._temperature = _coerce_temperature(get_config(_CONFIG_KEY)) + self._process: Gio.Subprocess | None = None + self._daemon_running = False + on_config_change(self._on_config_change) + self._sync_state() + + @Property(bool, "readable", default_value=False) + def active(self) -> bool: + return self._active + + @Property(int, "readable", default_value=_DEFAULT_TEMPERATURE) + def temperature(self) -> int: + return self._temperature + + def is_active(self) -> bool: + return self._active + + def enable(self): + kelvin = self._temperature or _DEFAULT_TEMPERATURE + if self._daemon_running: + self._hyprctl("temperature", kelvin) + else: + self._start_daemon(["-t", str(kelvin)]) + self._set_temperature(kelvin) + self._set_active(True) + + def disable(self): + if self._daemon_running: + self._hyprctl("identity") + self._set_active(False) + + def toggle(self) -> bool: + if self._active: + self.disable() + else: + self.enable() + return self._active + + def set_temperature(self, kelvin: int): + self._set_temperature(kelvin) + if not self._active: + return + if self._daemon_running: + self._hyprctl("temperature", kelvin) + else: + self._start_daemon(["-t", str(kelvin)]) + + def set_gamma(self, percent: int): + if self._daemon_running: + self._hyprctl("gamma", percent) + else: + self._start_daemon(["-g", str(percent)]) + + def reset(self): + if self._daemon_running: + self._hyprctl("reset") + self._sync_state() + + def _on_config_change(self, new_config, old_config): + if new_config.get(_CONFIG_KEY) == old_config.get(_CONFIG_KEY): + return + self.set_temperature(_coerce_temperature(new_config.get(_CONFIG_KEY))) + + def _set_active(self, value: bool): + if value != self._active: + self._active = value + self.emit("night_light_changed", value) + + def _set_temperature(self, value: int): + if value != self._temperature: + self._temperature = value + self.emit("temperature_changed", value) + + def _start_daemon(self, args: list[str]): + try: + self._process = Gio.Subprocess.new( + ["hyprsunset", *args], + Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE, + ) + except GLib.Error as error: + logger.error(f"[NightLightService] Failed to start hyprsunset: {error}") + self._process = None + return + self._daemon_running = True + self._process.wait_async(None, self._on_daemon_exit) + + def _on_daemon_exit(self, process: Gio.Subprocess, task: Gio.Task): + self._process = None + self._daemon_running = False + self._set_active(False) + + def _hyprctl(self, *args, on_reply=None): + argv = ["hyprctl", "hyprsunset", *[str(arg) for arg in args]] + try: + proc = Gio.Subprocess.new( + argv, + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE, + ) + except GLib.Error as error: + logger.error(f"[NightLightService] Failed to run {argv}: {error}") + if on_reply: + on_reply(None) + return + + def on_done(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, _ = process.communicate_utf8_finish(task) + except GLib.Error: + stdout = None + if on_reply: + on_reply(stdout.strip() if stdout else None) + + proc.communicate_utf8_async(None, None, on_done) + + def _sync_state(self): + self._hyprctl("profile", on_reply=self._on_profile_reply) + + def _on_profile_reply(self, reply: str | None): + if not self._daemon_alive(reply): + self._daemon_running = False + return + self._daemon_running = True + temperature = self._parse_temperature(reply) + if temperature is not None: + self._apply_synced_temperature(temperature) + else: + self._hyprctl("temperature", on_reply=self._on_temperature_reply) + + def _on_temperature_reply(self, reply: str | None): + if reply is None: + return + temperature = self._parse_temperature(reply) + if temperature is not None: + self._apply_synced_temperature(temperature) + + def _apply_synced_temperature(self, temperature: int): + active = temperature < _NEUTRAL_TEMPERATURE + if active: + self._set_temperature(temperature) + self._set_active(active) + + @staticmethod + def _daemon_alive(reply: str | None) -> bool: + if reply is None: + return False + return "couldn't connect" not in reply.lower() + + @staticmethod + def _parse_temperature(reply: str | None) -> int | None: + if not reply: + return None + match = _PROFILE_TEMP_RE.search(reply) + return int(match.group(1)) if match else None + + +def get_night_light_service() -> NightLightService: + return NightLightService() diff --git a/src/services/numlock.py b/src/services/numlock.py new file mode 100644 index 00000000..4a809eb2 --- /dev/null +++ b/src/services/numlock.py @@ -0,0 +1,17 @@ +from utils.evdev_lock import EvdevLockService + + +class NumLock(EvdevLockService): + """Service for monitoring NumLock LED state via evdev.""" + + _LED_GLOB = "input*::numlock" + _LED_INDEX = 0 # LED_NUML + _SERVICE_NAME = "NumLock" + + _instance = None + + @staticmethod + def get_initial(): + if NumLock._instance is None: + NumLock._instance = NumLock() + return NumLock._instance diff --git a/src/services/screenrecorder.py b/src/services/screenrecorder.py new file mode 100644 index 00000000..4f8ad218 --- /dev/null +++ b/src/services/screenrecorder.py @@ -0,0 +1,273 @@ +"""Screen recording service. + +Owns recording only; screenshots live in :mod:`services.screenshot`. +Recording state is held entirely in memory as Fabric properties -- no +temporary files are used. +""" + +import time +from datetime import datetime +from pathlib import Path + +from fabric.core.service import Property, Service, Signal +from fabric.utils import GLib, exec_shell_command_async + +from shared.capture import ( + CaptureBackend, + CaptureNotifier, + CaptureResult, + HyprlandCaptureBackend, + RecordingRequest, +) +from utils.functions import is_app_running, resolve_monitor + +_RECORDER_PROCESSES = ("wf-recorder", "gpu-screen-recorder") + + +class ScreenRecorder(Service): + """Record the screen through a pluggable :class:`CaptureBackend`.""" + + @Signal + def started(self, path: str) -> None: + """Emitted with the output path when a recording begins.""" + + @Signal + def stopped(self, path: str) -> None: + """Emitted with the output path when a recording ends.""" + + @Signal("paused") + def paused_signal(self) -> None: + """Emitted when an active recording is paused.""" + + @Signal + def resumed(self) -> None: + """Emitted when a paused recording resumes.""" + + @Signal + def failed(self, reason: str) -> None: + """Emitted when a recording is cancelled or fails.""" + + _instance: "ScreenRecorder | None" = None + + def __new__(cls) -> "ScreenRecorder": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, backend: CaptureBackend | None = None, **kwargs) -> None: + if getattr(self, "_initialized", False): + return + super().__init__(**kwargs) + self._initialized = True + + self._backend = backend or HyprlandCaptureBackend() + self._notifier = CaptureNotifier() + + self._recording = False + self._paused = False + self._output_file: str | None = None + self._start_time: float | None = None + self._tick_source_id: int | None = None + + self.recordings_dir = Path.home() / "Videos" / "Recordings" + self.recordings_dir.mkdir(parents=True, exist_ok=True) + + # -- properties -------------------------------------------------------- + + @Property(bool, "readable", default_value=False) + def recording(self) -> bool: + """In-memory recording state managed by this service.""" + return self._recording + + @Property(bool, "readable", default_value=False) + def paused(self) -> bool: + """Whether the current recording is paused.""" + return self._paused + + @Property(int, "readable", default_value=0) + def elapsed_seconds(self) -> int: + """Seconds elapsed since the current recording started.""" + if self._start_time is None: + return 0 + return int(time.monotonic() - self._start_time) + + @Property(str, "readable", default_value="") + def output_file(self) -> str: + """Path of the current or most recent recording.""" + return self._output_file or "" + + @Property(str, "readable", default_value="") + def backend(self) -> str: + """Name of the active capture backend.""" + return self._backend.name + + @Property(bool, "readable", default_value=False) + def busy(self) -> bool: + """Whether a recording is active (in-memory state).""" + return self._recording + + @Property(bool, "readable", default_value=False) + def available(self) -> bool: + """Whether the backend can record on this system.""" + return self._backend.capabilities.can_record + + @Property(bool, "readable", default_value=False) + def supports_pause(self) -> bool: + """Whether the backend supports pause/resume.""" + return self._backend.capabilities.supports_pause + + @Property(bool, "readable", default_value=False) + def active(self) -> bool: + """Whether a recorder process is actually running. + + Unlike :attr:`recording`, this reflects the real process state and + also detects recordings started outside this service. + """ + return any(is_app_running(proc) for proc in _RECORDER_PROCESSES) + + # -- public API -------------------------------------------------------- + + def start( + self, + target: str = "selection", + use_audio: bool = False, + show_cursor: bool = False, + ) -> bool: + """Start a recording. + + :param target: ``selection``, ``active`` or a specific monitor name. + :param use_audio: capture audio. + :param show_cursor: show the mouse cursor in the recording. + :returns: ``True`` if the recording attempt was launched. + """ + if self.active: + self.failed("A recording is already running") + return False + if not self._backend.capabilities.can_record: + self.failed("Recording backend is unavailable") + return False + + request = self._build_request(target, use_audio, show_cursor) + return self._backend.start_recording(request, self._on_start_result) + + def stop(self) -> bool: + """Stop the active recording.""" + if not (self._recording or self.active): + return False + path = self._output_file + self._backend.stop_recording() + self._teardown_state() + if path: + self._notify_saved(path) + self.stopped(path) + return True + + def pause(self) -> bool: + """Pause the active recording, if the backend supports it.""" + if not self._recording or self._paused: + return False + if not self._backend.pause_recording(): + return False + self._paused = True + self.notify("paused") + self.paused_signal() + return True + + def resume(self) -> bool: + """Resume a paused recording, if the backend supports it.""" + if not self._recording or not self._paused: + return False + if not self._backend.resume_recording(): + return False + self._paused = False + self.notify("paused") + self.resumed() + return True + + def toggle(self) -> bool: + """Stop when recording, otherwise start with defaults.""" + if self._recording or self.active: + return self.stop() + return self.start() + + # -- internals --------------------------------------------------------- + + def _build_request( + self, target: str, use_audio: bool, show_cursor: bool + ) -> RecordingRequest: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + return RecordingRequest( + target=target, + output_file=self.recordings_dir / f"{timestamp}.mkv", + use_audio=use_audio, + show_cursor=show_cursor, + monitor=resolve_monitor(target), + ) + + def _on_start_result(self, result: CaptureResult) -> None: + if result.success and result.path: + self._setup_state(result.path) + self.started(result.path) + elif result.cancelled: + self._notify_cancelled() + self.failed("cancelled") + else: + self.failed(result.error or "Recording failed") + + def _setup_state(self, path: str) -> None: + self._output_file = path + self._recording = True + self._paused = False + self._start_time = time.monotonic() + self._start_tick() + self.notify("output-file") + self.notify("recording") + self.notify("busy") + + def _teardown_state(self) -> None: + self._stop_tick() + self._recording = False + self._paused = False + self._start_time = None + self.notify("recording") + self.notify("busy") + self.notify("elapsed-seconds") + + def _start_tick(self) -> None: + self._stop_tick() + self._tick_source_id = GLib.timeout_add_seconds(1, self._on_tick) + + def _stop_tick(self) -> None: + if self._tick_source_id is not None: + GLib.source_remove(self._tick_source_id) + self._tick_source_id = None + + def _on_tick(self) -> bool: + if not self._recording: + self._tick_source_id = None + return False + self.notify("elapsed-seconds") + return True + + def _notify_saved(self, path: str) -> None: + self._notifier.notify( + "Screencast Saved", + f"Saved Screencast at {path}", + icon="camera-video-symbolic", + actions=[("files", "Show in Files"), ("view", "View")], + on_action=lambda action: self._handle_action(action, path), + ) + + def _handle_action(self, action: str, path: str) -> None: + match action: + case "files": + exec_shell_command_async(f"xdg-open {self.recordings_dir}") + case "view": + exec_shell_command_async(f"xdg-open {path}") + + def _notify_cancelled(self) -> None: + self._notifier.notify( + "Recording cancelled", + "Selection was cancelled", + icon="camera-video-symbolic", + ) diff --git a/src/services/screenshot.py b/src/services/screenshot.py new file mode 100644 index 00000000..a480e313 --- /dev/null +++ b/src/services/screenshot.py @@ -0,0 +1,179 @@ +"""Screenshot service. + +Owns screenshot capture only; recording lives in +:mod:`services.screenrecorder`. The service resolves the desired target +into a concrete request, hands it to a :class:`CaptureBackend`, then raises +notifications and signals based on the result. +""" + +from datetime import datetime +from pathlib import Path + +from fabric.core.service import Property, Service, Signal +from fabric.utils import exec_shell_command_async + +from shared.capture import ( + CaptureBackend, + CaptureNotifier, + CaptureResult, + HyprlandCaptureBackend, + ScreenshotRequest, +) +from utils.functions import find_binary, resolve_monitor + + +class Screenshot(Service): + """Capture screenshots through a pluggable :class:`CaptureBackend`.""" + + @Signal + def captured(self, path: str) -> None: + """Emitted with the saved path when a screenshot succeeds.""" + + @Signal + def failed(self, reason: str) -> None: + """Emitted when a screenshot is cancelled or fails.""" + + _instance: "Screenshot | None" = None + + def __new__(cls) -> "Screenshot": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, backend: CaptureBackend | None = None, **kwargs) -> None: + if getattr(self, "_initialized", False): + return + super().__init__(**kwargs) + self._initialized = True + + self._backend = backend or HyprlandCaptureBackend() + self._notifier = CaptureNotifier() + self._busy = False + self._last_file: str | None = None + self._has_satty = find_binary("satty") is not None + + self.screenshots_dir = Path.home() / "Pictures" / "Screenshots" + self.screenshots_dir.mkdir(parents=True, exist_ok=True) + + # -- properties -------------------------------------------------------- + + @Property(bool, "readable", default_value=False) + def busy(self) -> bool: + """Whether a capture is currently in progress.""" + return self._busy + + @Property(str, "readable", default_value="") + def last_file(self) -> str: + """Path of the most recently captured screenshot.""" + return self._last_file or "" + + @Property(str, "readable", default_value="") + def backend(self) -> str: + """Name of the active capture backend.""" + return self._backend.name + + @Property(bool, "readable", default_value=False) + def available(self) -> bool: + """Whether the backend can take screenshots on this system.""" + return self._backend.capabilities.can_screenshot + + @Property(bool, "readable", default_value=False) + def can_edit(self) -> bool: + """Whether an external editor (``satty``) is available.""" + return self._has_satty + + # -- public API -------------------------------------------------------- + + def screenshot( + self, + target: str = "region", + output_dir: str | Path | None = None, + show_cursor: bool = False, + ) -> bool: + """Take a screenshot. + + :param target: ``region``, ``window``, ``active``, ``output`` or a + specific monitor name. + :param output_dir: destination directory; defaults to the standard + screenshots directory. + :param show_cursor: include the mouse cursor in the capture. + :returns: ``True`` if the capture attempt was launched. + """ + if not self._backend.capabilities.can_screenshot: + self._fail("Screenshot backend is unavailable") + return False + + request = self._build_request(target, output_dir, show_cursor) + self._set_busy(True) + launched = self._backend.screenshot(request, self._on_result) + if not launched: + self._set_busy(False) + return launched + + # -- internals --------------------------------------------------------- + + def _build_request( + self, target: str, output_dir: str | Path | None, show_cursor: bool + ) -> ScreenshotRequest: + save_dir = Path(output_dir).expanduser() if output_dir else self.screenshots_dir + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + monitor = resolve_monitor(target) + return ScreenshotRequest( + target=target, + save_path=save_dir / f"{timestamp}.png", + show_cursor=show_cursor, + monitor=monitor, + ) + + def _on_result(self, result: CaptureResult) -> None: + self._set_busy(False) + if result.success and result.path: + self._on_success(result.path) + elif result.cancelled: + self._notify_cancelled() + self.failed("cancelled") + else: + self._fail(result.error or "Screenshot failed") + + def _on_success(self, path: str) -> None: + self._last_file = path + self.notify("last-file") + self._notify_saved(path) + self.captured(path) + + def _notify_saved(self, path: str) -> None: + actions = [("files", "Show in Files"), ("view", "View")] + if self._has_satty: + actions.append(("edit", "Edit")) + self._notifier.notify( + "Screenshot Saved", + f"Saved Screenshot at {path}", + icon="camera-photo-symbolic", + actions=actions, + hints=[f"STRING:image-path:{path}"], + on_action=lambda action: self._handle_action(action, path), + ) + + def _handle_action(self, action: str, path: str) -> None: + match action: + case "files": + exec_shell_command_async(f"xdg-open {self.screenshots_dir}") + case "view": + exec_shell_command_async(f"xdg-open {path}") + case "edit": + exec_shell_command_async(f"satty -f {path}") + + def _notify_cancelled(self) -> None: + self._notifier.notify( + "Screenshot cancelled", + "Selection was cancelled", + icon="camera-photo-symbolic", + ) + + def _fail(self, reason: str) -> None: + self.failed(reason) + + def _set_busy(self, value: bool) -> None: + if value != self._busy: + self._busy = value + self.notify("busy") diff --git a/services/todo.py b/src/services/todo.py similarity index 92% rename from services/todo.py rename to src/services/todo.py index 876505e8..93e622b4 100644 --- a/services/todo.py +++ b/src/services/todo.py @@ -6,9 +6,10 @@ # Fabric imports from fabric.core.service import Property, Service +from fabric.utils import logger # Local imports -import config.data as data +import shared.data as data class TodoService(Service): @@ -36,7 +37,7 @@ def _notify_callbacks(self, event_type, data=None): try: callback(event_type, data) except Exception as e: - print(f"Error in todo callback: {e}") + logger.error(f"Error in todo callback: {e}") def _get_todos_file_path(self): """Returns the path to the todos JSON file""" @@ -46,14 +47,15 @@ def _get_todos_file_path(self): def _load_todos(self): """Load todos from JSON file""" + from utils.functions import read_json_file + try: if self._file_path.exists(): - with open(self._file_path, "r", encoding="utf-8") as f: - self._todos = json.load(f) + self._todos = read_json_file(str(self._file_path)) or [] else: self._todos = [] except Exception as e: - print(f"Error loading todos: {e}") + logger.error(f"Error loading todos: {e}") self._todos = [] def _save_todos(self): @@ -62,7 +64,7 @@ def _save_todos(self): with open(self._file_path, "w", encoding="utf-8") as f: json.dump(self._todos, f, indent=2, ensure_ascii=False) except Exception as e: - print(f"Error saving todos: {e}") + logger.error(f"Error saving todos: {e}") @Property(list, "readable") def todos(self): @@ -167,3 +169,7 @@ def get_stats(self) -> dict: # Global service instance todo_service = TodoService() + +def get_todo_service() -> TodoService: + """Get the global todo service instance""" + return todo_service diff --git a/src/services/wallpaper.py b/src/services/wallpaper.py new file mode 100644 index 00000000..e7653c8c --- /dev/null +++ b/src/services/wallpaper.py @@ -0,0 +1,129 @@ +import hashlib +import mimetypes + +from fabric.core import Service, Signal +from fabric.utils import GdkPixbuf as gdk_pixbuf +from fabric.utils import exec_shell_command_async, logger, os + +from shared.data import ( + WALLPAPER_PATH, + WALLPAPER_THUMBS_PATH, + WALLPAPERS_THUMBNAILS_SIZE, +) + +os.makedirs(WALLPAPER_PATH, exist_ok=True) +os.makedirs(WALLPAPER_THUMBS_PATH, exist_ok=True) + + +def get_thumbnail_filename(image_path: str) -> str: + key = hashlib.sha256(image_path.encode()).hexdigest() + return f"{key}_{WALLPAPERS_THUMBNAILS_SIZE}x{WALLPAPERS_THUMBNAILS_SIZE}.webp" + + +def create_thumbnail( + image_path: str, + thumbnail_path: str, + size: int = WALLPAPERS_THUMBNAILS_SIZE, +) -> bool: + """Create a compressed WebP thumbnail.""" + + try: + pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale( + image_path, + size, + size, + preserve_aspect_ratio=True, + ) + + return bool( + pixbuf.savev( + thumbnail_path, + "webp", + ["quality"], + ["85"], + ) + ) + + except Exception as e: + logger.warning( + f"[wallpaper] pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale( image_... failed: {e}" + ) + return False + + +class WallpaperService(Service): + @Signal + def wallpaper_ready(self, image_path: str, thumbnail_path: str) -> None: ... + + @Signal + def wallpaper_set(self, image_path: str) -> None: ... + + @Signal + def colors_generated(self, image_path: str) -> None: ... + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._file_cache: list[str] | None = None + + def get_all_wallpapers(self, use_cache: bool = True) -> list[str]: + if use_cache and self._file_cache is not None: + return self._file_cache + + wallpapers: list[str] = [] + + with os.scandir(WALLPAPER_PATH) as entries: + for entry in entries: + if not entry.is_file(): + continue + + mime, _ = mimetypes.guess_type(entry.path) + if mime and mime.startswith("image/"): + wallpapers.append(entry.path) + + self._file_cache = wallpapers + return wallpapers + + def invalidate_cache(self) -> None: + self._file_cache = None + + def get_thumbnail_path(self, image_path: str) -> str: + return os.path.join( + WALLPAPER_THUMBS_PATH, + get_thumbnail_filename(image_path), + ) + + def has_thumbnail(self, image_path: str) -> bool: + return os.path.isfile(self.get_thumbnail_path(image_path)) + + def set_wallpaper(self, image_path: str) -> None: + exec_shell_command_async( + f'awww img "{image_path}" ' + "--transition-type none " + "--transition-duration 0 " + "--transition-fps 60" + ) + + self.wallpaper_set(image_path) + + link_path = os.path.expanduser("~/.current.wall") + + if os.path.lexists(link_path): + os.remove(link_path) + + os.symlink(os.path.abspath(image_path), link_path) + + exec_shell_command_async(f'matugen image "{image_path}" --source-color-index 0') + + self.colors_generated(image_path) + + def get_wallpaper_info(self, image_path: str) -> dict: + stat = os.stat(image_path) + + return { + "path": image_path, + "filename": os.path.basename(image_path), + "size": stat.st_size, + "modified": stat.st_mtime, + "has_thumbnail": self.has_thumbnail(image_path), + "thumbnail_path": self.get_thumbnail_path(image_path), + } diff --git a/src/shared/__init__.py b/src/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/shared/capture.py b/src/shared/capture.py new file mode 100644 index 00000000..0d7e50ea --- /dev/null +++ b/src/shared/capture.py @@ -0,0 +1,487 @@ +"""Shared capture infrastructure: backend abstraction + notifications. + +Services never invoke capture tools (``hyprshot``, ``grim``, ``slurp``, +``wf-recorder``) directly. They speak only to a :class:`CaptureBackend`, +which encapsulates the concrete tooling. This makes it possible to add +alternative backends (GPU Screen Recorder, PipeWire, XDG Desktop Portal) +later without touching the services or the UI. + +:class:`CaptureNotifier` provides the reusable ``notify-send`` plumbing used +by both the screenshot and recorder services so the notification code is not +duplicated in each. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Iterable + +from fabric.utils import Gio, logger + +from utils.functions import find_binary, kill_process + +if TYPE_CHECKING: + from pathlib import Path + +ResultCallback = Callable[["CaptureResult"], None] +NotificationAction = tuple[str, str] +ActionCallback = Callable[[str], None] + + +@dataclass(slots=True) +class CaptureResult: + """Outcome of a capture operation reported back to a service.""" + + success: bool + path: str | None = None + cancelled: bool = False + error: str | None = None + + @classmethod + def ok(cls, path: str) -> CaptureResult: + return cls(success=True, path=path) + + @classmethod + def user_cancelled(cls) -> CaptureResult: + return cls(success=False, cancelled=True) + + @classmethod + def failure(cls, error: str) -> CaptureResult: + return cls(success=False, error=error) + + +@dataclass(slots=True) +class ScreenshotRequest: + """Description of a single screenshot to capture. + + :param target: ``region``, ``window``, ``active``, ``output`` or a + specific monitor name. + :param save_path: absolute path the resulting image is written to. + :param show_cursor: whether the mouse cursor is included. + :param monitor: resolved monitor name (used for ``active`` / ``output``). + """ + + target: str + save_path: Path + show_cursor: bool = False + monitor: str | None = None + + +@dataclass(slots=True) +class RecordingRequest: + """Description of a screen recording to start. + + :param target: ``selection``, ``active`` or a specific monitor name. + :param output_file: absolute path the recording is written to. + :param use_audio: whether audio is captured. + :param show_cursor: whether the mouse cursor is recorded. + :param monitor: resolved monitor name (used for ``active``). + """ + + target: str + output_file: Path + use_audio: bool = False + show_cursor: bool = False + monitor: str | None = None + + +@dataclass(slots=True) +class BackendCapabilities: + """Static description of what a backend can do on this system.""" + + tools: dict[str, bool] = field(default_factory=dict) + can_screenshot: bool = False + can_record: bool = False + supports_cursor: bool = False + supports_pause: bool = False + + +class CaptureBackend(ABC): + """Abstract capture/recording backend. + + Concrete backends translate high level requests into whatever tooling + they wrap and report the outcome through the supplied callback. + """ + + name: str = "abstract" + + @property + @abstractmethod + def capabilities(self) -> BackendCapabilities: + """Return the static capabilities of this backend.""" + + @abstractmethod + def screenshot(self, request: ScreenshotRequest, on_result: ResultCallback) -> bool: + """Capture a screenshot; return ``True`` if the attempt was launched.""" + + @abstractmethod + def start_recording( + self, request: RecordingRequest, on_result: ResultCallback + ) -> bool: + """Begin a recording; return ``True`` if the attempt was launched.""" + + @abstractmethod + def stop_recording(self) -> bool: + """Stop the active recording; return ``True`` if one was stopped.""" + + def pause_recording(self) -> bool: + """Pause the active recording. Unsupported by default.""" + return False + + def resume_recording(self) -> bool: + """Resume a paused recording. Unsupported by default.""" + return False + + +class HyprlandCaptureBackend(CaptureBackend): + """Capture backend built on ``hyprshot``, ``grim``, ``slurp`` and + ``wf-recorder`` for Hyprland/wlroots sessions.""" + + name = "hyprland" + + _RECORDER = "wf-recorder" + + def __init__(self) -> None: + self._tools = { + tool: find_binary(tool) is not None + for tool in ("hyprshot", "grim", "slurp", self._RECORDER) + } + + @property + def capabilities(self) -> BackendCapabilities: + tools = self._tools + return BackendCapabilities( + tools=dict(tools), + can_screenshot=tools.get("hyprshot", False) or tools.get("grim", False), + can_record=tools.get(self._RECORDER, False), + supports_cursor=tools.get("grim", False), + supports_pause=False, + ) + + def has_tool(self, tool: str) -> bool: + """Return whether a wrapped tool is available on this system.""" + return self._tools.get(tool, False) + + # screenshots + + def screenshot(self, request: ScreenshotRequest, on_result: ResultCallback) -> bool: + request.save_path.parent.mkdir(parents=True, exist_ok=True) + if request.show_cursor and request.target in ("region", "active", "output"): + return self._screenshot_with_cursor(request, on_result) + return self._screenshot_hyprshot(request, on_result) + + def _screenshot_hyprshot( + self, request: ScreenshotRequest, on_result: ResultCallback + ) -> bool: + if not self.has_tool("hyprshot"): + on_result(CaptureResult.failure("hyprshot is not installed")) + return False + + command = [ + "hyprshot", + "-s", + "-o", + str(request.save_path.parent), + "-f", + request.save_path.name, + *self._hyprshot_mode(request), + ] + return self._spawn_capture(command, request.save_path, on_result) + + @staticmethod + def _hyprshot_mode(request: ScreenshotRequest) -> list[str]: + target = request.target + if target == "region": + return ["-m", "region"] + if target == "window": + return ["-m", "window"] + if target == "active": + return ["-m", "output", "-m", request.monitor or ""] + if target == "output": + return ["-m", "output"] + return ["-m", "output", "-m", target] + + def _screenshot_with_cursor( + self, request: ScreenshotRequest, on_result: ResultCallback + ) -> bool: + if not self.has_tool("grim"): + on_result(CaptureResult.failure("grim is not installed")) + return False + + if request.target == "region": + return self._region_with_cursor(request, on_result) + + monitor = request.monitor if request.target == "active" else None + return self._grim_capture(request.save_path, on_result, monitor=monitor) + + def _region_with_cursor( + self, request: ScreenshotRequest, on_result: ResultCallback + ) -> bool: + def on_geometry(geometry: str | None) -> None: + if not geometry: + on_result(CaptureResult.user_cancelled()) + return + self._grim_capture(request.save_path, on_result, geometry=geometry) + + return self._select_geometry(on_geometry) + + def _grim_capture( + self, + save_path: Path, + on_result: ResultCallback, + geometry: str | None = None, + monitor: str | None = None, + ) -> bool: + command = ["grim", "-c"] + if geometry: + command += ["-g", geometry] + elif monitor: + command += ["-o", monitor] + command.append(str(save_path)) + return self._spawn_capture(command, save_path, on_result) + + def _spawn_capture( + self, command: list[str], expected: Path, on_result: ResultCallback + ) -> bool: + def on_done(proc: Gio.Subprocess, task: Gio.Task) -> None: + try: + proc.wait_finish(task) + except Exception as error: + logger.warning(f"[capture] proc.wait_finish(task) failed: {error}") + on_result(CaptureResult.failure(str(error))) + return + if expected.exists(): + on_result(CaptureResult.ok(str(expected))) + else: + on_result(CaptureResult.user_cancelled()) + + proc = self._spawn(command, Gio.SubprocessFlags.STDERR_SILENCE) + if proc is None: + on_result(CaptureResult.failure(f"failed to launch {command[0]}")) + return False + proc.wait_async(None, on_done) + return True + + # recording + + def start_recording( + self, request: RecordingRequest, on_result: ResultCallback + ) -> bool: + if not self.has_tool(self._RECORDER): + on_result(CaptureResult.failure(f"{self._RECORDER} is not installed")) + return False + request.output_file.parent.mkdir(parents=True, exist_ok=True) + + if request.target == "selection": + return self._record_geometry(request, on_result, self._select_geometry) + if request.target == "window": + return self._record_geometry(request, on_result, self._select_window) + + monitor = request.monitor or request.target + return self._launch_recorder(request, on_result, monitor=monitor) + + def _record_geometry( + self, + request: RecordingRequest, + on_result: ResultCallback, + selector: Callable[[Callable[[str | None], None]], bool], + ) -> bool: + def on_geometry(geometry: str | None) -> None: + if not geometry: + on_result(CaptureResult.user_cancelled()) + return + self._launch_recorder(request, on_result, geometry=geometry) + + return selector(on_geometry) + + def _launch_recorder( + self, + request: RecordingRequest, + on_result: ResultCallback, + geometry: str | None = None, + monitor: str | None = None, + ) -> bool: + command = [ + self._RECORDER, + "--file", + str(request.output_file), + "--pixel-format", + "yuv420p", + ] + if not request.use_audio: + command.append("--no-audio") + if request.show_cursor: + command.append("--show-cursor") + if geometry: + command += ["-g", geometry] + elif monitor: + command += ["-o", monitor] + + proc = self._spawn(command, Gio.SubprocessFlags.NONE) + if proc is None: + on_result(CaptureResult.failure(f"failed to launch {self._RECORDER}")) + return False + on_result(CaptureResult.ok(str(request.output_file))) + return True + + def stop_recording(self) -> bool: + kill_process(self._RECORDER) + return True + + # helpers + + def _select_geometry(self, on_geometry: Callable[[str | None], None]) -> bool: + if not self.has_tool("slurp"): + on_geometry(None) + return False + + def on_done(proc: Gio.Subprocess, task: Gio.Task) -> None: + try: + _, stdout, _ = proc.communicate_utf8_finish(task) + except Exception as e: + logger.warning( + f"[capture] _, stdout, _ = proc.communicate_utf8_finish(task) failed: {e}" + ) + on_geometry(None) + return + on_geometry(stdout.strip() if stdout else None) + + proc = self._spawn( + ["slurp"], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE, + ) + if proc is None: + on_geometry(None) + return False + proc.communicate_utf8_async(None, None, on_done) + return True + + def _select_window(self, on_geometry: Callable[[str | None], None]) -> bool: + """Let the user pick a visible window and return its geometry. + + Mirrors how ``hyprshot`` implements window selection: the geometry of + every mapped window on an active workspace is fed to ``slurp -r`` so a + single click on a window selects it. + """ + if not self.has_tool("slurp"): + on_geometry(None) + return False + + boxes = self._window_boxes() + if not boxes: + on_geometry(None) + return False + + def on_done(proc: Gio.Subprocess, task: Gio.Task) -> None: + try: + _, stdout, _ = proc.communicate_utf8_finish(task) + except Exception as e: + logger.warning( + f"[capture] _, stdout, _ = proc.communicate_utf8_finish(task) failed: {e}" + ) + on_geometry(None) + return + on_geometry(stdout.strip() if stdout else None) + + proc = self._spawn( + ["slurp", "-r"], + Gio.SubprocessFlags.STDIN_PIPE + | Gio.SubprocessFlags.STDOUT_PIPE + | Gio.SubprocessFlags.STDERR_SILENCE, + ) + if proc is None: + on_geometry(None) + return False + proc.communicate_utf8_async(boxes, None, on_done) + return True + + @staticmethod + def _window_boxes() -> str: + """Build a ``slurp``-compatible box list of currently visible windows.""" + from services.modus import get_clients, get_monitors + + active_ids = {m.get("activeWorkspace", {}).get("id") for m in get_monitors()} + boxes: list[str] = [] + for client in get_clients(): + if not client.get("mapped", True): + continue + if client.get("workspace", {}).get("id") not in active_ids: + continue + at = client.get("at") + size = client.get("size") + if not at or not size: + continue + boxes.append(f"{at[0]},{at[1]} {size[0]}x{size[1]}") + return "\n".join(boxes) + + @staticmethod + def _spawn(command: list[str], flags: Gio.SubprocessFlags) -> Gio.Subprocess | None: + try: + return Gio.Subprocess.new(command, flags) + except Exception as error: + logger.error(f"[CaptureBackend] Failed to spawn {command}: {error}") + return None + + +class CaptureNotifier: + """Thin wrapper around ``notify-send`` with async action handling.""" + + def __init__(self, app_name: str = "Fabric Screenshot Utility") -> None: + self._app_name = app_name + + def notify( + self, + title: str, + message: str, + *, + icon: str | None = None, + actions: Iterable[NotificationAction] | None = None, + hints: Iterable[str] | None = None, + on_action: ActionCallback | None = None, + ) -> None: + """Send a notification. + + :param actions: iterable of ``(id, label)`` pairs shown as buttons. + :param hints: raw ``notify-send`` ``-h`` hint strings. + :param on_action: invoked with the chosen action id when a button is + pressed. When omitted the notification is fire-and-forget. + """ + command = ["notify-send", "-a", self._app_name] + if icon: + command += ["-i", icon] + for hint in hints or (): + command += ["-h", hint] + for action_id, label in actions or (): + command += ["-A", f"{action_id}={label}"] + command += [title, message] + + if on_action is None: + self._spawn(command, Gio.SubprocessFlags.STDOUT_SILENCE) + return + self._spawn_with_action(command, on_action) + + def _spawn_with_action(self, command: list[str], on_action: ActionCallback) -> None: + proc = self._spawn(command, Gio.SubprocessFlags.STDOUT_PIPE) + if proc is None: + return + + def on_done(process: Gio.Subprocess, task: Gio.Task) -> None: + try: + _, stdout, _ = process.communicate_utf8_finish(task) + except Exception as error: + logger.error(f"[CaptureNotifier] Failed to read action: {error}") + return + action = stdout.strip() if stdout else "" + if action: + on_action(action) + + proc.communicate_utf8_async(None, None, on_done) + + @staticmethod + def _spawn(command: list[str], flags: Gio.SubprocessFlags) -> Gio.Subprocess | None: + try: + return Gio.Subprocess.new(command, flags) + except Exception as error: + logger.error(f"[CaptureNotifier] Failed to notify: {error}") + return None diff --git a/src/shared/data.py b/src/shared/data.py new file mode 100644 index 00000000..b2154833 --- /dev/null +++ b/src/shared/data.py @@ -0,0 +1,66 @@ +from fabric.utils import GLib, get_relative_path, logger, os + +from utils.functions import parse_timeout_string +from utils.gtk_utils import toml_file + +HOME_DIR = GLib.get_home_dir() +APP_NAME = "modus" + +CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" + +CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}") + +SYSTEM_CACHE_DIR = GLib.get_user_cache_dir() + + +def _get_wallpaper_path() -> str: + try: + from services.config import get_config + + custom = get_config("wallpapers_dir") + if custom: + return os.path.expanduser(str(custom)) + except Exception: + pass + return f"{HOME_DIR}/Pictures/Wallpapers" + + +WALLPAPER_PATH = _get_wallpaper_path() +WALLPAPER_THUMBS_PATH = f"{WALLPAPER_PATH}/.thumbnails" +WALLPAPERS_THUMBNAILS_SIZE = 200 +WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/") + +CONFIG_FILE = toml_file("config.toml") + + +CLIPBOARD_THUMBS_DIR = f"{GLib.get_user_cache_dir()}/{APP_NAME}/clipboard_thumbs" +CLIPBOARD_DB_PATH = f"{GLib.get_user_cache_dir()}/cliphist/db" + + +def load_config(): + """Load the configuration from config.toml""" + try: + from services.config import start_config_service + + service = start_config_service() + return service.get_all() + except ImportError: + # Fallback to direct file loading + config = {} + + if os.path.exists(CONFIG_FILE): + try: + import tomlkit + + with open(CONFIG_FILE) as f: + config = dict(tomlkit.load(f)) + except Exception as e: + logger.error(f"Error loading config: {e}") + + return config + + +NOTIFICATION_TIMEOUT_STR = "5s" +NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) +NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"] +NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"] diff --git a/src/shared/dialogs/__init__.py b/src/shared/dialogs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/about.py b/src/shared/dialogs/about.py similarity index 65% rename from modules/about.py rename to src/shared/dialogs/about.py index 46c7f1cb..7ff3d0bf 100644 --- a/modules/about.py +++ b/src/shared/dialogs/about.py @@ -1,15 +1,7 @@ -import re -import subprocess -import os +from fabric.utils import GdkPixbuf, Gtk, get_relative_path, logger, os, re -import gi - -gi.require_version("GdkPixbuf", "2.0") -gi.require_version("Gtk", "3.0") -from gi.repository import GdkPixbuf, Gtk # type: ignore - -from fabric.utils.helpers import get_relative_path -from utils.roam import modus_service +from utils.functions import escape_markup_text, find_binary, run_command +from utils.gtk_utils import setup_cursor_hover from utils.icon_resolver import IconResolver @@ -17,19 +9,23 @@ def read_dmi(field): try: with open(f"/sys/class/dmi/id/{field}") as f: return f.read().strip() - except Exception: + except Exception as e: + logger.warning( + f"[about] with open(f'/sys/class/dmi/id/(field)') as f: return f.re... failed: {e}" + ) return "Unknown" def get_gpu_name(): try: - output = ( - subprocess.check_output( - "lspci -nn | grep -i 'VGA compatible controller'", shell=True, text=True - ) - .strip() - .split("\n") - ) + result = run_command(["lspci", "-nn"]) + if result.returncode != 0: + return "Unknown" + output = [ + line + for line in result.stdout.strip().split("\n") + if "VGA compatible controller" in line.lower() + ] def clean(line): matches = re.findall(r"\[(.*?)\]", line) @@ -46,7 +42,10 @@ def clean(line): return clean(output[0]) return "Unknown GPU" - except Exception: + except Exception as e: + logger.warning( + f"[about] output = ( subprocess.check_output( 'lspci -nn | grep -i ... failed: {e}" + ) return "Unknown GPU" @@ -76,11 +75,11 @@ def get_executable_path(exec_string): # Search in PATH try: - result = subprocess.run(["which", executable], capture_output=True, text=True) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass + result = find_binary(executable) + if result: + return result + except Exception as e: + logger.error(f"An error occurred: {e}") return None @@ -106,67 +105,88 @@ def get_app_info(wmclass): "/usr/local/share/applications", ] + # Search for desktop files for path in desktop_paths: if not os.path.exists(path): continue - # Try exact match first - exact_matches = [ - f for f in os.listdir(path) if f.lower() == f"{wmclass.lower()}.desktop" - ] - - # Then try starts with - startswith_matches = [ - f - for f in os.listdir(path) - if f.startswith(wmclass.lower()) and f.endswith(".desktop") - ] - - # Finally try contains - contains_matches = [ - f - for f in os.listdir(path) - if wmclass.lower() in f.lower() and f.endswith(".desktop") - ] - - # Process matches in order of preference - for matches in [exact_matches, startswith_matches, contains_matches]: - for filename in matches: - desktop_file = os.path.join(path, filename) + search_terms = [wmclass] + if "." in wmclass: + search_terms.append(wmclass.split(".")[-1]) + + # Add lowercase versions + search_terms = list(dict.fromkeys([s.lower() for s in search_terms])) + + files = os.listdir(path) + desktop_file = None + + # 1. Try exact matches with .desktop suffix + for term in search_terms: + if f"{term}.desktop" in files: + desktop_file = os.path.join(path, f"{term}.desktop") + break + + # 2. Try prefix matches + if not desktop_file: + for term in search_terms: + matches = [ + f for f in files if f.startswith(term) and f.endswith(".desktop") + ] + if matches: + desktop_file = os.path.join(path, matches[0]) + break + + # 3. Try content searching (if no file match found yet) + if not desktop_file: + for f in files: + if not f.endswith(".desktop"): + continue try: - with open(desktop_file, "r", encoding="utf-8") as f: - content = f.read() - - name = wmclass.title() - version = "" - comment = "" - icon = wmclass.lower() - exec_cmd = "" - categories = "" - - # Parse desktop file - in_desktop_entry = False - for line in content.split("\n"): - line = line.strip() - if line == "[Desktop Entry]": - in_desktop_entry = True - continue - elif line.startswith("[") and line.endswith("]"): - in_desktop_entry = False - continue - - if not in_desktop_entry or "=" not in line: - continue + full_path = os.path.join(path, f) + with open(full_path, encoding="utf-8") as df: + content = df.read() + if ( + f"StartupWMClass={wmclass}" in content + or f"Exec={wmclass}" in content + ): + desktop_file = full_path + break + except Exception as e: + logger.warning( + f"[about] full_path = os.path.join(path, f) failed: {e}" + ) + continue + if desktop_file: + try: + with open(desktop_file, encoding="utf-8") as f: + content = f.read() + + name = wmclass.title() + version = "" + comment = "" + icon = wmclass.lower() + exec_cmd = "" + categories = "" + + # Parse desktop file + current_section = None + for line in content.split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + + if line.startswith("[") and line.endswith("]"): + current_section = line + continue + + if current_section == "[Desktop Entry]" and "=" in line: key, value = line.split("=", 1) if key == "Name": name = value elif key == "Version": version = value - elif key == "Comment": - comment = value - elif key == "GenericName" and not comment: - # Use GenericName as fallback description + elif key == "Comment" or (key == "GenericName" and not comment): comment = value elif key == "Icon": icon = value @@ -175,32 +195,32 @@ def get_app_info(wmclass): elif key == "Categories": categories = value - # Get executable location - location = get_executable_path(exec_cmd) - - return { - "name": name, - "version": version, - "comment": comment, - "icon": icon, - "exec": exec_cmd, - "location": location or "", - "categories": categories, - "desktop_file": desktop_file, - } - except Exception: - continue + location = get_executable_path(exec_cmd) + + return { + "name": name, + "version": version, + "comment": comment, + "icon": icon, + "exec": exec_cmd, + "location": location or "", + "categories": categories, + "desktop_file": desktop_file, + } + except Exception as e: + logger.warning( + f"[about] with open(desktop_file, encoding='utf-8') as f: content =... failed: {e}" + ) + continue # Fallback: try to find executable in PATH location = "" try: - result = subprocess.run( - ["which", wmclass.lower()], capture_output=True, text=True - ) - if result.returncode == 0: - location = result.stdout.strip() - except Exception: - pass + result = find_binary(wmclass.lower()) + if result: + location = result + except Exception as e: + logger.error(f"An error occurred: {e}") return { "name": wmclass.title() if wmclass else "Unknown Application", @@ -217,6 +237,8 @@ def get_app_info(wmclass): class AboutApp(Gtk.Window): def __init__(self, app_name="Unknown Application", wmclass=""): super().__init__(title=f"About {app_name}") + self.set_wmclass("modus-about", "Modus") + self.set_title("modus-about") self.app_name = app_name self.wmclass = wmclass self.icon_resolver = IconResolver() @@ -241,29 +263,32 @@ def setup_ui(self): # App Icon logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + logo = None + try: - # Use IconResolver's get_icon_pixbuf method like other parts of the project + # 1. Try resolving via IconResolver icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info["icon"], 128) if icon_pixbuf: logo = Gtk.Image.new_from_pixbuf(icon_pixbuf) - else: - raise Exception("Icon pixbuf not found") - except Exception: - # Fallback: try direct file path if it's an absolute path + except Exception as e: + logger.error(f"An error occurred: {e}") + + if not logo: try: - if app_info["icon"].startswith("/") and os.path.exists( - app_info["icon"] - ): + # 2. Try direct file path + icon_path = app_info["icon"] + if icon_path.startswith("/") and os.path.exists(icon_path): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - app_info["icon"], 128, 128, preserve_aspect_ratio=True + icon_path, 128, 128, True ) logo = Gtk.Image.new_from_pixbuf(pixbuf) - else: - raise Exception("Direct path failed") - except Exception: - # Final fallback: emoji - logo = Gtk.Label() - logo.set_markup("📱") + except Exception as e: + logger.error(f"An error occurred: {e}") + + if not logo: + # 3. Final fallback + logo = Gtk.Label() + logo.set_markup("📦") logo_box.pack_start(logo, False, False, 0) logo_box.set_margin_bottom(15) @@ -271,7 +296,7 @@ def setup_ui(self): # App Name app_name_label = Gtk.Label() app_name_label.set_markup( - f"{app_info['name']}" + f"{escape_markup_text(app_info['name'])}" ) app_name_label.set_halign(Gtk.Align.CENTER) app_name_label.set_margin_bottom(5) @@ -295,7 +320,9 @@ def setup_ui(self): desc_frame.set_shadow_type(Gtk.ShadowType.IN) description_label = Gtk.Label() - description_label.set_markup(f"{app_info['comment']}") + description_label.set_markup( + f"{escape_markup_text(app_info['comment'])}" + ) description_label.set_justify(Gtk.Justification.CENTER) description_label.set_halign(Gtk.Align.CENTER) description_label.set_line_wrap(True) @@ -345,7 +372,11 @@ def make_info_row(label_text, value_text, row): label.set_markup(f"{label_text}:") value = Gtk.Label() - value.set_markup(f'{value_text}') + value.set_markup( + f"{ + escape_markup_text(str(value_text)) + }" + ) value.set_halign(Gtk.Align.START) value.set_line_wrap(True) value.set_max_width_chars(30) @@ -409,6 +440,7 @@ def toggle(self, b): class About(Gtk.Window): def __init__(self): super().__init__(title="About Menu") + self.set_wmclass("modus-about", "Modus") self.set_default_size(300, 550) self.set_size_request(300, 500) self.set_resizable(False) @@ -425,7 +457,7 @@ def __init__(self): # Logo logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - get_relative_path("../config/assets/icons/misc/imac.svg"), + get_relative_path("../../assets/icons/misc/imac.svg"), 158, 108, preserve_aspect_ratio=True, @@ -438,7 +470,9 @@ def __init__(self): name_label = Gtk.Label() name_label.set_margin_top(30) name_label.set_markup( - f"{read_dmi('product_name')}" + f"{ + escape_markup_text(read_dmi('product_name')) + }" ) vendor_label = Gtk.Label(label=read_dmi("sys_vendor")) @@ -460,37 +494,38 @@ def make_label(text, align_end=False, name=None): return label # Info values + kernel_result = run_command(["uname", "-r"]) + cpu_result = run_command(["lscpu"]) + cpu_model = "" + if cpu_result.returncode == 0: + for line in cpu_result.stdout.split("\n"): + if "Model name:" in line: + cpu_model = line.split(":", 1)[1].strip() + break + mem_result = run_command(["free", "-h", "--giga"]) + mem_value = "" + if mem_result.returncode == 0: + for line in mem_result.stdout.split("\n"): + if line.startswith("Mem:"): + mem_value = line.split()[1] + break + uptime_result = run_command(["uptime", "-p"]) + labels = [ ( "Kernel", - subprocess.run( - "uname -r", shell=True, capture_output=True, text=True - ).stdout.strip(), - ), - ( - "CPU", - subprocess.run( - "lscpu | grep 'Model name:' | cut -d ':' -f2-", - shell=True, - capture_output=True, - text=True, - ).stdout.strip(), - ), - ( - "Memory", - subprocess.run( - "free -h --giga | grep Mem | tr -s ' ' | cut -d ' ' -f 2", - shell=True, - capture_output=True, - text=True, - ).stdout.strip(), + kernel_result.stdout.strip() + if kernel_result.returncode == 0 + else "Unknown", ), + ("CPU", cpu_model), + ("Memory", mem_value), ("GPU", get_gpu_name()), ( "Uptime", - subprocess.run( - "uptime -p", shell=True, capture_output=True, text=True - ).stdout.strip(), + uptime_result.stdout.strip() + if uptime_result.returncode == 0 + else "Unknown", ), ] @@ -504,6 +539,7 @@ def make_label(text, align_end=False, name=None): button_box = Gtk.Box(halign=Gtk.Align.CENTER) button_box.set_margin_top(20) more_info_button = Gtk.Button(label="More Info...", name="more-info-button") + setup_cursor_hover(more_info_button, "pointer") more_info_button.connect("clicked", self.open_more_info) button_box.pack_start(more_info_button, False, False, 0) @@ -530,8 +566,19 @@ def open_more_info(self, button): # TODO: Implement the logic to open more information pass - def toggle(self, b): + def toggle(self, b=None): if self.get_visible(): self.hide() else: self.show_all() + self.present() + + +_about_window = None + + +def get_about_window(): + global _about_window + if _about_window is None: + _about_window = About() + return _about_window diff --git a/widgets/wifi_password_dialog.py b/src/shared/dialogs/wifi_password_dialog.py similarity index 97% rename from widgets/wifi_password_dialog.py rename to src/shared/dialogs/wifi_password_dialog.py index 9d53586e..90b4357a 100644 --- a/widgets/wifi_password_dialog.py +++ b/src/shared/dialogs/wifi_password_dialog.py @@ -1,18 +1,11 @@ -import gi -from gi.repository import Gdk, GLib - +from fabric.utils import Gdk, GLib, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.image import Image from fabric.widgets.label import Label - -# from widgets.wayland import WaylandWindow as Window - from fabric.widgets.window import Window -gi.require_version("Gtk", "3.0") - class WiFiPasswordDialog(Window): def __init__( @@ -24,7 +17,7 @@ def __init__( **kwargs, ): super().__init__( - title="modus", + title="modus-dialog", layer="overlay", anchor="center", keyboard_mode="on-demand", @@ -280,12 +273,12 @@ def _focus_and_select_password(self): self.password_entry.grab_focus() self.password_entry.select_region(0, -1) return False - except: + except Exception as e: + logger.warning( + f"[wifi_password_dialog] self.password_entry.grab_focus() failed: {e}" + ) return False - def get_password(self): - return self.password_entry.get_text() - def destroy_dialog(self): self.hide() self.destroy() diff --git a/src/shared/widgets/__init__.py b/src/shared/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/shared/widgets/animator.py b/src/shared/widgets/animator.py new file mode 100644 index 00000000..9ffac943 --- /dev/null +++ b/src/shared/widgets/animator.py @@ -0,0 +1,265 @@ +# Author: Yousef EL-Darsh +# License (SPDX): AGPL-3.0-or-later + +from functools import lru_cache +from typing import Protocol, cast + +from fabric.core.service import Property, Service, Signal +from fabric.utils import GLib, Gtk, clamp + + +# NOTE: Do NOT use @cache here — progress is a continuous float so cache +# entries are never reused and the cache grows forever, causing a slow +# CPU/memory spike while music is playing (the pos_fabricator fires every +# 2 s, each call generating unique float progress values). +# lru_cache with a small maxsize bounds memory while still getting some hits +# for identical easing calls. +@lru_cache(maxsize=512) +def lerp(start: float, end: float, progress: float) -> float: + return start + (end - start) * progress + + +@lru_cache(maxsize=512) +def cubic_bezier( + x1: float, y1: float, x2: float, y2: float, progress: float, epsilon=1e-6 +) -> float: + # implementation yanked off of the internet, don't blame me about anything. + # Fast-path boundaries to avoid overshoot and unnecessary work + if progress <= 0.0 or progress >= 1.0: + return clamp(progress, 0.0, 1.0) + + t_guess = progress + for _ in range(8): + t = t_guess + t_sq = t * t + omt = 1.0 - t + omt_sq = omt * omt + + x = 3 * x1 * omt_sq * t + 3 * x2 * omt * t_sq + t * t_sq + dx = 3 * x1 * omt_sq + 6 * (x2 - x1) * omt * t + 3 * (1 - x2) * t_sq + + if abs(dx) < epsilon: + break + + delta = (x - progress) / dx + t_guess -= delta + t_guess = clamp(t_guess, 0.0, 1.0) + + if abs(delta) < epsilon: + break + + t = clamp(t_guess, 0.0, 1.0) + t_sq = t * t + omt = 1.0 - t + return 3 * y1 * omt * omt * t + 3 * y2 * omt * t_sq + t * t_sq + + +def ease_linear(progress: float) -> float: + return cubic_bezier(1, 1, 0, 0, progress) + + +def ease_in(progress: float) -> float: + return cubic_bezier(0.4, 0, 1, 1, progress) + + +def ease_out(progress: float) -> float: + return cubic_bezier(0, 0, 0.2, 1, progress) + + +def ease_in_out(progress: float) -> float: + return cubic_bezier(0.4, 0, 0.2, 1, progress) + + +class TimingFunctionCallback(Protocol): + def __call__(self, progress: float, *args, **kwargs) -> float: ... + + +class Animator(Service): + """ + An animator is a simple way for animating a value on + a set timeline based on a given timing function + """ + + @Signal + def finished(self) -> None: ... + + @Property(tuple[float, float, float, float], "read-write") + def bezier_curve(self) -> tuple[float, float, float, float]: + return self._bezier_curve + + @bezier_curve.setter + def bezier_curve(self, value: tuple[float, float, float, float]): + self._bezier_curve = value + self._timing_function = lambda progress=0.0, **_: cubic_bezier( + *value, progress=progress + ) + + @Property(TimingFunctionCallback, "read-write") + def timing_function(self) -> TimingFunctionCallback: + return self._timing_function + + @timing_function.setter + def timing_function(self, value: TimingFunctionCallback): + self._timing_function = value + + @Property(float, "read-write") + def duration(self): + return self._duration + + @duration.setter + def duration(self, value: float): + if value <= 0.0: + raise ValueError("duration can't be smaller than or equal to 0.0") + + self._duration = value + + @Property(float, "read-write") + def value(self): + return self._value + + @value.setter + def value(self, value: float): + self._value = value + + @Property(float, "read-write") + def max_value(self): + return self._max_value + + @max_value.setter + def max_value(self, value: float): + self._max_value = value + + @Property(float, "read-write") + def min_value(self): + return self._min_value + + @min_value.setter + def min_value(self, value: float): + self._min_value = value + + @Property(bool, "read-write", default_value=False) + def playing(self): + return self._playing + + @playing.setter + def playing(self, value: bool): # this setter is intended for internal usage only + self._playing = value + + @Property(bool, "read-write", default_value=False) + def repeat(self): + return self._repeat + + @repeat.setter + def repeat(self, value: bool): + self._repeat = value + + def __init__( + self, + duration: float = 0.8, + timing_function: TimingFunctionCallback = ease_linear, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 1.0, + repeat: bool = False, + tick_widget: Gtk.Widget | None = None, + tick_interval: int = 16, + **kwargs, + ): + super().__init__(**kwargs) + self._playing = False + self._value = value + self._min_value = 0.0 + self._max_value = 1.0 + self._repeat = False + self._duration = 0.8 + self._bezier_curve = (1, 1, 0, 0) + self._timing_function = timing_function + self._tick_widget = tick_widget + self._tick_interval = tick_interval + + self.timing_function = timing_function + self.repeat = repeat + self.duration = duration + self.min_value = min_value + self.max_value = max_value + self.value = value + self.playing = False + + self._start_time = None + self._tick_handler = None + self._timeline_pos = 0.0 + + def do_get_time_now(self): + return GLib.get_monotonic_time() / 1_000_000 + + def do_update_value(self, delta_time: float): + if not self._playing: + return + + elapsed_time = delta_time - cast("float", self._start_time) + + self._timeline_pos = min(1.0, elapsed_time / self._duration) + + self.value = lerp( + self._min_value, + self._max_value, + self._timing_function(progress=self._timeline_pos), + ) + + if not self._timeline_pos >= 1.0: + return + + if not self._repeat: + # all done.. + self.value = self._max_value + self.finished() + self.pause() + return + + self._start_time = delta_time + self._timeline_pos = 0.0 + + def do_handle_tick(self, *_): + current_time = self.do_get_time_now() + self.do_update_value(current_time) + return True + + def do_remove_tick_handlers(self): + if not self._tick_handler: + return + + if self._tick_widget: + self._tick_widget.remove_tick_callback(self._tick_handler) + else: + GLib.source_remove(self._tick_handler) + self._tick_handler = None + + def play(self): + if self._playing: + return + + self.playing = True + self._start_time = self.do_get_time_now() + + if self._tick_handler: + return + + if self._tick_widget: + self._tick_handler = self._tick_widget.add_tick_callback( + self.do_handle_tick + ) + return + + self._tick_handler = GLib.timeout_add(self._tick_interval, self.do_handle_tick) + return + + def pause(self): + self.playing = False + return self.do_remove_tick_handlers() + + def stop(self): + if not self._tick_handler: + self._timeline_pos = 0 + self.playing = False + return None + return self.do_remove_tick_handlers() diff --git a/widgets/circle_image.py b/src/shared/widgets/circle_image.py similarity index 81% rename from widgets/circle_image.py rename to src/shared/widgets/circle_image.py index aefd7e08..e2c0dad2 100644 --- a/widgets/circle_image.py +++ b/src/shared/widgets/circle_image.py @@ -1,14 +1,9 @@ -import math from typing import Literal -import cairo -import gi from fabric.core.service import Property +from fabric.utils import Gdk, GdkPixbuf, Gtk, cairo, math from fabric.widgets.widget import Widget -gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402 - class CircleImage(Gtk.DrawingArea, Widget): """A widget that displays an image in a circular shape with a 1:1 aspect ratio.""" @@ -102,24 +97,3 @@ def on_draw(self, widget: "CircleImage", ctx: cairo.Context): Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0) ctx.paint() ctx.restore() - - def set_image_from_file(self, new_image_file: str): - if not new_image_file: - return - pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file) - self._orig_image = pixbuf - self._image = self._process_image(pixbuf) - self.queue_draw() - - def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf): - if not pixbuf: - return - self._orig_image = pixbuf - self._image = self._process_image(pixbuf) - self.queue_draw() - - def set_image_size(self, size: int): - self.size = size - if self._orig_image: - self._image = self._process_image(self._orig_image) - self.queue_draw() diff --git a/src/shared/widgets/clipping_box.py b/src/shared/widgets/clipping_box.py new file mode 100644 index 00000000..cf16d68a --- /dev/null +++ b/src/shared/widgets/clipping_box.py @@ -0,0 +1,46 @@ +import math +from typing import cast + +from fabric.utils import cairo +from fabric.widgets.box import Box + + +class ClippingBox(Box): + """A regular `Box` that replicates the CSS behaviour of `overflow: hidden` because GTK failed at it. + + NOTE: use instead of the old `CustomImage` snippet. + """ + + @staticmethod + def render_shape(cr: cairo.Context, width: int, height: int, radius: int = 0): + cr.move_to(radius, 0) + cr.line_to(width - radius, 0) + cr.arc(width - radius, radius, radius, -(math.pi / 2), 0) + cr.line_to(width, height - radius) + cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2)) + cr.line_to(radius, height) + cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi) + cr.line_to(0, radius) + cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2))) + + return cr.close_path() + + def do_draw(self, cr: cairo.Context): + cr.save() + ClippingBox.render_shape( + cr, + self.get_allocated_width(), + self.get_allocated_height(), + cast( + "int", + self.get_style_context().get_property( + "border-radius", self.get_state_flags() + ), + ), + ) + cr.clip() + + Box.do_draw(self, cr) + + cr.restore() + return True diff --git a/widgets/custom_image.py b/src/shared/widgets/custom_image.py similarity index 89% rename from widgets/custom_image.py rename to src/shared/widgets/custom_image.py index 1aae7f6a..4475a7c5 100644 --- a/widgets/custom_image.py +++ b/src/shared/widgets/custom_image.py @@ -1,9 +1,7 @@ import math from typing import cast -import cairo -from gi.repository import Gtk - +from fabric.utils import Gtk, cairo from fabric.widgets.image import Image @@ -31,7 +29,7 @@ def do_draw(self, cr: cairo.Context): cr, width, height, - cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)), + cast("int", context.get_property("border-radius", Gtk.StateFlags.NORMAL)), ) cr.clip() Image.do_draw(self, cr) diff --git a/src/shared/widgets/customrevealer.py b/src/shared/widgets/customrevealer.py new file mode 100644 index 00000000..382340cd --- /dev/null +++ b/src/shared/widgets/customrevealer.py @@ -0,0 +1,165 @@ +from fabric.utils import Gtk, math + +from shared.widgets.animator import Animator + + +# macOS-style easing functions for natural motion +def ease_out_quint(progress): + return 1 - math.pow(1 - progress, 5) + + +def ease_in_cubic(progress): + return progress * progress * progress + + +class SlideRevealer(Gtk.Overlay): + def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None): + super().__init__() + + self.child = child + self.direction = direction + self.duration = duration + self.fixed_size = size + self._revealed = False + self._show_animation = False + self._cached_dimensions = None + + self.animator = Animator( + duration=duration / 1000.0, + timing_function=ease_out_quint, + tick_interval=8, # 120 FPS for smoothness + tick_widget=self, + ) + self.animator.connect("notify::value", self._on_animator_value_changed) + self.animator.connect("finished", self._on_animator_finished) + + self._fixed = Gtk.Fixed() + self._fixed.set_has_window(False) + self._fixed.add(child) + self.add_overlay(self._fixed) + + if self.fixed_size: + self.set_size_request(self.fixed_size[0], self.fixed_size[1]) + child.hide() + self.show_all() + else: + child.connect("size-allocate", self._on_size_allocate) + child.hide() + self.show_all() + + def _on_size_allocate(self, _widget, allocation): + if not self.fixed_size: + current_req = self.get_size_request() + if ( + current_req[0] != allocation.width + or current_req[1] != allocation.height + ): + self.set_size_request(allocation.width, allocation.height) + + def set_reveal_child(self, reveal: bool): + if reveal: + self.reveal() + else: + self.hide() + + def reveal(self): + if self._revealed and not self.animator.playing: + return + self._revealed = True + + if self.get_realized(): + self._start_animation(show=True) + else: + + def on_realize(*_): + self._start_animation(show=True) + self.disconnect_by_func(on_realize) + + self.connect("realize", on_realize) + + def hide(self): + if not self._revealed and not self.animator.playing: + return + self._revealed = False + self._start_animation(show=False) + + def _start_animation(self, show: bool): + self.animator.stop() + + self._cached_dimensions = self._get_dimensions() + if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0: + return + + self._show_animation = show + + # Configure animator based on direction + if show: + self.child.show() + self.animator.timing_function = ease_out_quint + # We animate the 'progress' from 0 to 1 + self.animator.min_value = 0.0 + self.animator.max_value = 1.0 + else: + self.animator.timing_function = ease_in_cubic + self.animator.min_value = 0.0 + self.animator.max_value = 1.0 + + self.animator.play() + + def _on_animator_value_changed(self, animator, _pspec): + if not self._cached_dimensions: + return + + progress = animator.value + x, y = self._get_position_at_progress_cached(progress) + + # Round to nearest pixel for actual positioning + pixel_x, pixel_y = round(x), round(y) + self._fixed.move(self.child, pixel_x, pixel_y) + self.queue_draw() + + def _on_animator_finished(self, _animator): + self._cached_dimensions = None + if not self._revealed: + self.child.hide() + + def _get_dimensions(self): + if self.fixed_size: + return self.fixed_size + else: + alloc = self.child.get_allocation() + return alloc.width, alloc.height + + def _get_position_at_progress_cached(self, progress): + w, h = self._cached_dimensions + if self._show_animation: + # Showing animation: slide from offscreen to onscreen (0,0) + if self.direction == "left": + return -w + w * progress, 0.0 + elif self.direction == "right": + return w - w * progress, 0.0 + elif self.direction == "top": + return 0.0, -h + h * progress + elif self.direction == "bottom": + return 0.0, h - h * progress + else: + # Hiding animation: slide from onscreen (0,0) to offscreen + if self.direction == "left": + return -w * progress, 0.0 # Slide left (negative x) + elif self.direction == "right": + return w * progress, 0.0 # Slide right (positive x) + elif self.direction == "top": + return 0.0, -h * progress # Slide up (negative y) + elif self.direction == "bottom": + return 0.0, h * progress # Slide down (positive y) + return 0.0, 0.0 + + def set_slide_direction(self, direction): + self.direction = direction + + def stop_animation(self): + self.animator.stop() + + def destroy(self): + self.stop_animation() + super().destroy() diff --git a/src/shared/widgets/flat_scale.py b/src/shared/widgets/flat_scale.py new file mode 100644 index 00000000..dbe1aa20 --- /dev/null +++ b/src/shared/widgets/flat_scale.py @@ -0,0 +1,657 @@ +import math +from typing import Callable, Iterable, Literal, TypedDict + +from fabric.utils import Gdk, GObject, Gtk, cairo +from fabric.utils.helpers import clamp +from fabric.widgets.widget import Widget +from gi.repository import Pango, PangoCairo + +from shared.widgets.animator import Animator, ease_in, ease_in_out, ease_out + + +def _draw_rounded_rect(cr, x, y, w, h, r): + r = min(r, w / 2, h / 2) + if r <= 0: + cr.rectangle(x, y, w, h) + return + cr.new_sub_path() + cr.move_to(x + r, y) + cr.line_to(x + w - r, y) + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.line_to(x + w, y + h - r) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.line_to(x + r, y + h) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.line_to(x, y + r) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +class FlatScaleStyle(TypedDict): + slider_color: Gdk.RGBA + slider_height: float + slider_thickness: float + top_gap: float + bottom_gap: float + left_gap: float + right_gap: float + corner_radius: float + progress_color: Gdk.RGBA + progress_thickness: float + trough_color: Gdk.RGBA + trough_thickness: float + background_color: Gdk.RGBA + + +class FlatScale(Gtk.DrawingArea, Widget): + """ + A Cairo-drawn linear scale widget for Fabric. + + Styled via CSS sub-nodes (slider, trough, highlight), mirroring + CircularScale's gadget context approach. Use margin-top/margin-bottom + on the slider node to create a gap between the slider and the arcs. + + While dragging, a compact percentage pill animates above (or beside, for + vertical) the slider handle. The pill is small enough that only a few + pixels of headroom are needed above the widget. + + Example CSS: + .flat-scale slider { + background-color: var(--primary); + min-height: 16px; + min-width: 16px; + border-radius: 8px; + margin-top: 4px; + margin-bottom: 4px; + } + .flat-scale trough { + background-color: alpha(var(--primary), 0.3); + border-width: 6px; + } + .flat-scale trough highlight { + background-color: var(--primary); + border-width: 6px; + } + """ + + __gsignals__ = { + "value-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), + } + + # Mini bubble constants — kept deliberately compact + _BUBBLE_GAP = -5 # px between top of handle and bottom of pill + _BUBBLE_FONT_SIZE = 8 # pt — smaller than body text + _BUBBLE_PAD_X = 4 # horizontal padding inside pill + _BUBBLE_PAD_Y = -1 # vertical padding inside pill + _BUBBLE_RADIUS = 10 # corner radius of pill + _BUBBLE_SCALE_FROM = 0.55 # scale at start of open animation + + def __init__( + self, + value: float = 0.5, + min_value: float = 0.0, + max_value: float = 1.0, + step: float = 0.05, + draw_value: bool = False, + orientation: Literal["horizontal", "vertical"] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + on_value_changed: Callable[["FlatScale", float], None] | None = None, + value_formatter: Callable[[float], str] | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) + Widget.__init__( + self, + name=name, + visible=visible, + all_visible=all_visible, + style=style, + style_classes=style_classes, + tooltip_text=tooltip_text, + tooltip_markup=tooltip_markup, + h_align=h_align, + v_align=v_align, + h_expand=h_expand, + v_expand=v_expand, + size=size, + **kwargs, + ) + + self._value = clamp(value, min_value, max_value) + self._min_value = min_value + self._max_value = max_value + self._step = step + self._draw_value = draw_value + self._dragging = False + self._hovering = False + self._cached_style: FlatScaleStyle | None = None + self._gadget_classes: dict[Gtk.StyleContext, frozenset[str] | None] = {} + self._anim_initialized = False + self._scroll_accumulator = 0.0 + self._value_formatter = value_formatter or ( + lambda val: f"{round(self.do_normalize_value() * 100)}%" + ) + + # Slider press/hover size animator + self._anim_press = Animator( + timing_function=ease_in_out, + duration=0.1, + min_value=16.0, + max_value=16.0, + tick_widget=self, + ) + self._anim_press.connect("notify::value", lambda *_: self.queue_draw()) + + # Bubble open animator (0 → 1) + self._anim_bubble_open = Animator( + timing_function=ease_out, + duration=0.18, + min_value=0.0, + max_value=1.0, + tick_widget=self, + ) + self._anim_bubble_open.connect("notify::value", lambda *_: self.queue_draw()) + + # Bubble close animator (0 → 1, mapped as 1 - value) + self._anim_bubble_close = Animator( + timing_function=ease_in, + duration=0.12, + min_value=0.0, + max_value=1.0, + tick_widget=self, + ) + self._anim_bubble_close.connect("notify::value", lambda *_: self.queue_draw()) + + self._bubble_progress = 0.0 # 0.0 = hidden, 1.0 = fully visible + self._bubble_closing = False + + if isinstance(orientation, str): + self._orientation = ( + Gtk.Orientation.HORIZONTAL + if orientation == "horizontal" + else Gtk.Orientation.VERTICAL + ) + else: + self._orientation = orientation + + self._highlight_ctx = self.do_create_gadget_context("highlight") + self._trough_ctx = self.do_create_gadget_context("trough") + self._slider_ctx = self.do_create_gadget_context("slider") + + self.get_style_context().add_class("flat-scale") + + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + | Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.SMOOTH_SCROLL_MASK + | Gdk.EventMask.ENTER_NOTIFY_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + + self.connect("button-press-event", self._on_button_press) + self.connect("button-release-event", self._on_button_release) + self.connect("motion-notify-event", self._on_motion) + self.connect("scroll-event", self._on_scroll) + self.connect("enter-notify-event", self._on_enter) + self.connect("leave-notify-event", self._on_leave) + self.connect("realize", self._on_realize) + + self.show_all() + if on_value_changed: + self.connect("value-changed", on_value_changed) + + @property + def value(self) -> float: + return self._value + + @value.setter + def value(self, v: float) -> None: + self.set_value(v) + + def set_value(self, value: float) -> None: + new = clamp(value, self._min_value, self._max_value) + if new != self._value: + self._value = new + self.emit("value-changed", self._value) + self.queue_draw() + + def get_value(self) -> float: + return self._value + + def _on_realize(self, _widget) -> None: + state = self.get_state_flags() + self._gadget_classes[self._slider_ctx] = None + self.do_update_gadget_path(self._slider_ctx, "slider") + default_size = self._slider_ctx.get_property("min-height", state) + self._anim_press.min_value = default_size + self._anim_press.max_value = default_size + self._anim_press.value = default_size + self._anim_initialized = True + + def do_create_gadget_context(self, node_name: str) -> Gtk.StyleContext: + ctx = Gtk.StyleContext() + ctx.set_parent(self.get_style_context()) + ctx.set_screen(self.get_screen()) + self._gadget_classes[ctx] = None + ctx.connect("changed", lambda *_: self.do_update_gadget_path(ctx, node_name)) + self.do_update_gadget_path(ctx, node_name) + return ctx + + def do_update_gadget_path(self, context: Gtk.StyleContext, node_name: str) -> None: + parent_ctx = self.get_style_context() + current_classes = frozenset(parent_ctx.list_classes()) + + if current_classes != self._gadget_classes[context]: + self._gadget_classes[context] = current_classes + new_path = parent_ctx.get_path().copy() + for cls in list(new_path.iter_list_classes(-1)): + if cls not in current_classes: + new_path.iter_remove_class(-1, cls) + for cls in current_classes: + if not new_path.iter_has_class(-1, cls): + new_path.iter_add_class(-1, cls) + new_path.append_type(GObject.TYPE_NONE) + new_path.iter_set_object_name(-1, node_name) + context.set_path(new_path) + + context.set_state(self.get_state_flags()) + self._cached_style = None + self.queue_draw() + + def do_get_border_width( + self, context: Gtk.StyleContext, state: Gtk.StateFlags + ) -> float: + border = context.get_border(state) + return max( + border.top, + border.bottom, + border.left, + border.right, + context.get_property("min-width", state), + context.get_property("min-height", state), + ) + + def do_resolve_style(self) -> FlatScaleStyle: + if self._cached_style is not None: + return self._cached_style + + state = self.get_state_flags() + self._cached_style = FlatScaleStyle( + slider_color=self._slider_ctx.get_background_color(state), + slider_height=self._slider_ctx.get_property("min-height", state), + slider_thickness=self._slider_ctx.get_property("min-width", state), + top_gap=self._slider_ctx.get_property("margin-top", state), + bottom_gap=self._slider_ctx.get_property("margin-bottom", state), + left_gap=self._slider_ctx.get_property("margin-left", state), + right_gap=self._slider_ctx.get_property("margin-right", state), + corner_radius=self._slider_ctx.get_property("border-radius", state), + progress_color=self._highlight_ctx.get_background_color(state), + progress_thickness=self.do_get_border_width(self._highlight_ctx, state), + trough_color=self._trough_ctx.get_background_color(state), + trough_thickness=self.do_get_border_width(self._trough_ctx, state), + background_color=self.get_style_context().get_background_color(state), + ) + return self._cached_style + + def do_normalize_value(self) -> float: + value_range = self._max_value - self._min_value + if value_range == 0: + return 0.0 + return clamp((self._value - self._min_value) / value_range, 0.0, 1.0) + + def _is_horizontal(self) -> bool: + return self._orientation == Gtk.Orientation.HORIZONTAL + + def _value_from_coords(self, x: float, y: float) -> float: + width = self.get_allocated_width() + height = self.get_allocated_height() + slider_size = self._anim_press.value + slider_half = slider_size / 2.0 + + if self._is_horizontal(): + track_length = max(float(width) - slider_size, 0.001) + ratio = clamp((x - slider_half) / track_length, 0.0, 1.0) + else: + track_length = max(float(height) - slider_size, 0.001) + ratio = clamp(1.0 - ((y - slider_half) / track_length), 0.0, 1.0) + + return self._min_value + ratio * (self._max_value - self._min_value) + + def _bubble_open(self): + self._bubble_closing = False + self._anim_bubble_close.pause() + + p = self._bubble_progress + self._anim_bubble_open.pause() + self._anim_bubble_open.min_value = p + self._anim_bubble_open.max_value = 1.0 + self._anim_bubble_open.value = p + self._anim_bubble_open.play() + + def _bubble_close(self): + self._bubble_closing = True + self._anim_bubble_open.pause() + + start = 1.0 - self._bubble_progress + self._anim_bubble_close.pause() + self._anim_bubble_close.min_value = start + self._anim_bubble_close.max_value = 1.0 + self._anim_bubble_close.value = start + self._anim_bubble_close.play() + + def _sync_bubble_progress(self): + """Pull latest progress from whichever animator is active.""" + if self._bubble_closing: + self._bubble_progress = max(0.0, 1.0 - self._anim_bubble_close.value) + else: + self._bubble_progress = min(1.0, self._anim_bubble_open.value) + + def do_draw_rounded_rect(self, cr, x, y, width, height, radius): + _draw_rounded_rect(cr, x, y, width, height, radius) + + def _draw_bubble( + self, + cr: cairo.Context, + slider_cx: float, + slider_cy: float, + max_slider_thickness: float, + styles: FlatScaleStyle, + ): + """ + Draws the percentage pill scaling elegantly out from its anchor point + (bottom-center for horizontal, right-center for vertical). + """ + if not self._draw_value: + return + + self._sync_bubble_progress() + p = self._bubble_progress + if p <= 0.0: + return + + text = self._value_formatter(self._value) + state = self.get_state_flags() + + widget_w = self.get_allocated_width() + widget_h = self.get_allocated_height() + + # Font setup (Keep native sizes static; Cairo will handle the scaling matrix) + font_desc = self.get_style_context().get_font(state).copy() + font_desc.set_size(self._BUBBLE_FONT_SIZE * Pango.SCALE) + font_desc.set_weight(Pango.Weight.SEMIBOLD) + + layout = self.create_pango_layout(text) + layout.set_font_description(font_desc) + text_w, text_h = layout.get_pixel_size() + + # Target full dimensions at 100% scale + pill_w = text_w + self._BUBBLE_PAD_X * 2 + pill_h = text_h + self._BUBBLE_PAD_Y * 2 + + edge_margin = 2.0 + + # Calculate scale factor (maps smoothly from start scale to 1.0) + current_scale = self._BUBBLE_SCALE_FROM + (1.0 - self._BUBBLE_SCALE_FROM) * p + cr.save() + + static_slider_half = max_slider_thickness / 2 + + if self._is_horizontal(): + ideal_x = slider_cx - pill_w / 2 + pill_x = clamp(ideal_x, edge_margin, widget_w - pill_w - edge_margin) + + fixed_top_edge = slider_cy - static_slider_half + pill_y = fixed_top_edge - self._BUBBLE_GAP - pill_h + + # Anchor point for scaling: Bottom-Center of the pill + anchor_x = pill_x + (pill_w / 2) + anchor_y = pill_y + pill_h + + else: + # Vertical layout + fixed_left_edge = slider_cy - static_slider_half + pill_x = fixed_left_edge - self._BUBBLE_GAP - pill_w + + ideal_y = slider_cx - pill_h / 2 + pill_y = clamp(ideal_y, edge_margin, widget_h - pill_h - edge_margin) + + # Anchor point for scaling: Right-Center of the pill + anchor_x = pill_x + pill_w + anchor_y = pill_y + (pill_h / 2) + + # Apply Matrix Transformations for Scaling from the Anchor + cr.translate(anchor_x, anchor_y) + cr.scale(current_scale, current_scale) + cr.translate(-anchor_x, -anchor_y) + + # Draw the pill background using full alpha or a subtle fade + fg = self._slider_ctx.get_color(state) + bg = styles["slider_color"] + + cr.set_source_rgba(bg.red, bg.green, bg.blue, bg.alpha * min(1.0, p * 1.5)) + _draw_rounded_rect(cr, pill_x, pill_y, pill_w, pill_h, self._BUBBLE_RADIUS) + cr.fill() + + # Render Text + # Because the matrix scales everything, text won't warp or clip layout boundaries + cr.set_source_rgba(fg.red, fg.green, fg.blue, fg.alpha * min(1.0, p * 2.0)) + text_offset_y = (pill_h - text_h) / 2 + cr.move_to(pill_x + self._BUBBLE_PAD_X, pill_y + text_offset_y) + PangoCairo.show_layout(cr, layout) + + cr.restore() + + def do_draw(self, cr: cairo.Context) -> bool: + styles = self.do_resolve_style() + + width = self.get_allocated_width() + height = self.get_allocated_height() + + trough_thickness = styles["trough_thickness"] + progress_thickness = styles["progress_thickness"] + slider_height = self._anim_press.value + slider_thickness = self._anim_press.value + corner_radius = styles["corner_radius"] + + normalized = self.do_normalize_value() + + if self._is_horizontal(): + track_y = height / 2 + track_start = 0.0 + track_end = float(width) + track_length = track_end - track_start + + slider_half = slider_height / 2 + slider_cx = ( + track_start + slider_half + normalized * (track_length - slider_height) + ) + slider_cy = track_y + + # Full Trough + Gdk.cairo_set_source_rgba(cr, styles["trough_color"]) + self.do_draw_rounded_rect( + cr, + track_start, + track_y - trough_thickness / 2, + track_length, + trough_thickness, + trough_thickness / 2, + ) + cr.fill() + + # Progress (drawn up to right edge of thumb, thumb covers the end) + progress_w = max(slider_cx + slider_half - track_start, 0.0) + if progress_w > 0: + Gdk.cairo_set_source_rgba(cr, styles["progress_color"]) + self.do_draw_rounded_rect( + cr, + track_start, + track_y - progress_thickness / 2, + progress_w, + progress_thickness, + progress_thickness / 2, + ) + cr.fill() + + # Slider handle + Gdk.cairo_set_source_rgba(cr, styles["slider_color"]) + self.do_draw_rounded_rect( + cr, + slider_cx - slider_half, + slider_cy - slider_thickness / 2, + slider_height, + slider_thickness, + corner_radius, + ) + cr.fill() + max_thickness = max(self._anim_press.min_value, self._anim_press.max_value) + self._draw_bubble(cr, slider_cx, slider_cy, max_thickness, styles) + + else: + track_x = width / 2 + track_start = 0.0 + track_end = float(height) + track_length = track_end - track_start + + slider_half = slider_height / 2 + slider_cy = ( + track_end - slider_half - normalized * (track_length - slider_height) + ) + slider_cx = track_x + + trough_y = track_start + trough_h = max(slider_cy - track_start, 0.0) + if trough_h > 0: + Gdk.cairo_set_source_rgba(cr, styles["trough_color"]) + self.do_draw_rounded_rect( + cr, + track_x - trough_thickness / 2, + trough_y, + trough_thickness, + trough_h, + trough_thickness / 2, + ) + cr.fill() + + progress_y = slider_cy + progress_h = max(track_end - progress_y, 0.0) + if progress_h > 0: + Gdk.cairo_set_source_rgba(cr, styles["progress_color"]) + self.do_draw_rounded_rect( + cr, + track_x - progress_thickness / 2, + progress_y, + progress_thickness, + progress_h, + progress_thickness / 2, + ) + cr.fill() + + Gdk.cairo_set_source_rgba(cr, styles["slider_color"]) + self.do_draw_rounded_rect( + cr, + slider_cx - slider_thickness / 2, + slider_cy - slider_half, + slider_thickness, + slider_height, + corner_radius, + ) + cr.fill() + + # Percentage bubble (drag only) + max_thickness = max(self._anim_press.min_value, self._anim_press.max_value) + self._draw_bubble(cr, slider_cx, slider_cy, max_thickness, styles) + + return False + + def _on_button_press(self, _widget, event: Gdk.EventButton) -> bool: + if event.button == 1: + self._dragging = True + self._update_state_flags() + self.set_value(self._value_from_coords(event.x, event.y)) + self._bubble_open() + return True + + def _on_button_release(self, _widget, event: Gdk.EventButton) -> bool: + if event.button == 1: + self._dragging = False + self._update_state_flags() + self._bubble_close() + return False + + def _on_motion(self, _widget, event: Gdk.EventMotion) -> bool: + if self._dragging: + self.set_value(self._value_from_coords(event.x, event.y)) + return True + + def _on_scroll(self, _widget, event: Gdk.EventScroll) -> bool: + if event.direction == Gdk.ScrollDirection.UP: + self.set_value(self._value + self._step) + elif event.direction == Gdk.ScrollDirection.DOWN: + self.set_value(self._value - self._step) + elif event.direction == Gdk.ScrollDirection.SMOOTH: + self._scroll_accumulator += event.delta_y * -10 + if abs(self._scroll_accumulator) >= 1.0: + steps = int(self._scroll_accumulator) + self.set_value(self._value + steps * self._step) + self._scroll_accumulator -= steps + return True + + def _on_enter(self, _widget, _event) -> bool: + self._hovering = True + self._update_state_flags() + return False + + def _on_leave(self, _widget, _event) -> bool: + self._hovering = False + self._dragging = False + self._update_state_flags() + # Close bubble if pointer leaves mid-drag + if self._bubble_progress > 0.0: + self._bubble_close() + return False + + def _update_state_flags(self) -> None: + ctx = self.get_style_context() + + if self._hovering: + ctx.add_class("hover") + else: + ctx.remove_class("hover") + + if self._dragging: + ctx.add_class("active") + else: + ctx.remove_class("active") + + self._gadget_classes[self._slider_ctx] = None + self.do_update_gadget_path(self._slider_ctx, "slider") + self._cached_style = None + + state = self.get_state_flags() + target = self._slider_ctx.get_property("min-height", state) + + if self._anim_initialized: + self._anim_press.min_value = self._anim_press.value + self._anim_press.max_value = target + self._anim_press.play() + + self.queue_draw() diff --git a/src/shared/widgets/smooth_switch.py b/src/shared/widgets/smooth_switch.py new file mode 100644 index 00000000..bbaf206c --- /dev/null +++ b/src/shared/widgets/smooth_switch.py @@ -0,0 +1,146 @@ +from fabric.utils import Gdk, Gtk, cairo + +from shared.widgets.animator import Animator, ease_in_out + + +class SmoothSwitch(Gtk.DrawingArea): + """ + Cairo-drawn switch with animated thumb, drop-in replacement for Switch. + Emits 'user-toggled' (bool) only on actual user clicks. + """ + + def __init__( + self, + active: bool = False, + width: int = 44, + height: int = 24, + on_user_toggle=None, + style_classes: list | None = None, + v_align=None, + v_expand: bool = False, + h_expand: bool = False, + **kwargs, + ): + super().__init__(**kwargs) + self._active = active + self._width = width + self._height = height + self._on_user_toggle = on_user_toggle + + self.set_size_request(width, height) + self.set_hexpand(h_expand) + self.set_vexpand(v_expand) + if v_align is not None: + if isinstance(v_align, str): + v_align = { + "fill": Gtk.Align.FILL, + "start": Gtk.Align.START, + "end": Gtk.Align.END, + "center": Gtk.Align.CENTER, + "baseline": Gtk.Align.BASELINE, + }[v_align] + self.set_valign(v_align) + + if style_classes: + ctx = self.get_style_context() + for cls in style_classes: + ctx.add_class(cls) + + self._animator = Animator( + timing_function=ease_in_out, + duration=0.2, + min_value=0.0, + max_value=1.0, + tick_widget=self, + ) + if active: + self._animator.value = 1.0 + + self._animator.connect("notify::value", lambda *_: self.queue_draw()) + + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.ENTER_NOTIFY_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + self.connect("button-press-event", self._on_click) + self.connect("draw", self._on_draw) + self.connect("enter-notify-event", lambda *_: self.queue_draw()) + self.connect("leave-notify-event", lambda *_: self.queue_draw()) + self.show_all() + + def get_active(self) -> bool: + return self._active + + def set_active(self, value: bool): + """Programmatic set — does NOT emit user-toggled.""" + if value == self._active: + return + self._active = value + self._animate_to(1.0 if value else 0.0) + + def _on_click(self, _, event): + if event.button != 1: + return False + self._active = not self._active + self._animate_to(1.0 if self._active else 0.0) + if self._on_user_toggle: + self._on_user_toggle(self._active) + return True + + def _animate_to(self, target: float): + self._animator.pause() + self._animator.min_value = self._animator.value + self._animator.max_value = target + self._animator.value = self._animator.min_value + self._animator.play() + + def _on_draw(self, _, cr: cairo.Context): + w, h = self._width, self._height + r = h / 2 + margin = 2 + t = self._animator.value + + style = self.get_style_context() + + if self._active or t > 0.0: + style.add_class("checked") + else: + style.remove_class("checked") + + off_color = style.get_background_color(Gtk.StateFlags.NORMAL) + + on_color = style.get_background_color(Gtk.StateFlags.CHECKED) + + if self._active or t > 0.5: + thumb_color = style.get_color(Gtk.StateFlags.CHECKED) + else: + thumb_color = style.get_color(Gtk.StateFlags.NORMAL) + + track_r = off_color.red + (on_color.red - off_color.red) * t + track_g = off_color.green + (on_color.green - off_color.green) * t + track_b = off_color.blue + (on_color.blue - off_color.blue) * t + track_a = off_color.alpha + (on_color.alpha - off_color.alpha) * t + + cr.new_sub_path() + cr.arc(r, r, r, 0.5 * 3.14159, 1.5 * 3.14159) + cr.arc(w - r, r, r, -0.5 * 3.14159, 0.5 * 3.14159) + cr.close_path() + cr.set_source_rgba(track_r, track_g, track_b, track_a) + cr.fill() + + thumb_r = r - margin + travel = w - 2 * r + thumb_x = r + travel * t + thumb_y = r + + cr.arc(thumb_x, thumb_y, thumb_r, 0, 2 * 3.14159) + cr.set_source_rgba( + thumb_color.red, + thumb_color.green, + thumb_color.blue, + thumb_color.alpha, + ) + cr.fill() + + return False diff --git a/src/shared/window/__init__.py b/src/shared/window/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/shared/window/animated_scrollwindow.py b/src/shared/window/animated_scrollwindow.py new file mode 100644 index 00000000..ac1c8b75 --- /dev/null +++ b/src/shared/window/animated_scrollwindow.py @@ -0,0 +1,86 @@ +from fabric.utils import idle_add +from fabric.widgets.scrolledwindow import ScrolledWindow + +from shared.widgets.animator import Animator + + +class AnimatedScrollable(ScrolledWindow): + def __init__( + self, + bezier_curve: tuple[float, float, float, float] = (0.25, 0.1, 0.25, 1.0), + duration: float = 0.3, + animate: bool = True, + **kwargs, + ): + super().__init__(**kwargs) + self._last_req = -1 + self.animate = animate + _, min_height = self.min_content_size + _, max_height = self.max_content_size + + self.height_animator = Animator( + bezier_curve=bezier_curve, + duration=duration, + min_value=min_height, + max_value=max_height, + ) + self.height_animator.connect("notify::value", self.on_animator_change) + self.set_overlay_scrolling(True) + + self.connect("notify::child", self._on_child_notified) + if self.get_child(): + self._on_child_notified() + + def _on_child_notified(self, *_): + child = self.get_child() + if child: + child.connect("size-allocate", self._on_child_size_allocate) + + def _on_child_size_allocate(self, widget, allocation): + _, nat_height = widget.get_preferred_height() + target = min(nat_height, self.max_content_size[1]) + if target != self._last_req: + self.animate_size(target) + + def on_animator_change(self, animator: Animator, *_): + value = round(animator.value) + self.set_min_content_height(value) + self.queue_resize() + + def do_animate( + self, + from_height: int = 0, + to_height: int = -1, + ): + if to_height == -1: + return + self.height_animator.pause() + self.height_animator.min_value = from_height + self.height_animator.max_value = to_height + self.height_animator.play() + return + + def animate_size(self, height: int = -1): + if not self.animate: + idle_add(lambda: self.snap_to_size(height)) + return None + self._last_req = height + current_val = self.height_animator.value + return self.do_animate(current_val, height) + + def snap_to_size(self, height: int): + """Instantly resize without animation""" + self._last_req = height + self.height_animator.pause() + self.height_animator.value = height + self.set_min_content_height(height) + self.queue_resize() + + def do_get_preferred_height(self): + value = self.height_animator.value + value = 0 if value < 0 else value + return value, value + + def destroy(self): + self.height_animator.stop() + super().destroy() diff --git a/src/shared/window/applet_window.py b/src/shared/window/applet_window.py new file mode 100644 index 00000000..422a76cb --- /dev/null +++ b/src/shared/window/applet_window.py @@ -0,0 +1,105 @@ +from fabric.utils import GLib, logger +from fabric.widgets.eventbox import EventBox +from fabric.widgets.wayland import WaylandWindow as Window + +from shared.window.popup_win import PopupWindow + + +class DismissLayer(Window): + def __init__(self, on_dismiss, **kwargs): + self.event_box = EventBox(events=["button-press"]) + super().__init__( + anchor="left right top bottom", + layer="top", + exclusivity="none", + keyboard_mode="none", + child=self.event_box, + visible=False, + **kwargs, + ) + self.event_box.connect("button-press-event", lambda *_: on_dismiss()) + + +class AppletWindow(PopupWindow): + _active_popup = None + + def __init__(self, edge_margin: int = 0, **kwargs): + self._is_open = False + self._hide_timeout_id = None + self.dismiss_layer = DismissLayer(on_dismiss=self.toggle) + kwargs["keyboard_mode"] = "on-demand" + super().__init__(edge_margin=edge_margin, **kwargs) + self.add_keybinding("escape", lambda *_: self.toggle()) + + def toggle(self, *_): + if self._is_open: + self.close_applet() + else: + self.open_applet() + + def open_applet(self): + if self._is_open: + return + + self._is_open = True + + active = AppletWindow._active_popup + if active and active != self: + try: + if hasattr(active, "close_applet"): + active.close_applet() + else: + active.hide() + except Exception as e: + logger.error( + f"[AppletWindow] Warning: failed to close active popup: {e}" + ) + + AppletWindow._active_popup = self + + if self._hide_timeout_id is not None: + GLib.source_remove(self._hide_timeout_id) + self._hide_timeout_id = None + + child = self.get_child() + if child: + child.set_opacity(1.0) + + if hasattr(self, "dismiss_layer"): + self.dismiss_layer.show() + + self.set_visible(True) + + def close_applet(self): + if not self._is_open: + return + + self._is_open = False + + if self._hide_timeout_id is not None: + GLib.source_remove(self._hide_timeout_id) + self._hide_timeout_id = None + + if hasattr(self, "dismiss_layer"): + self.dismiss_layer.hide() + + self.set_visible(False) + + if hasattr(self, "_parent") and self._parent: + self._parent.set_visible(True) + + if AppletWindow._active_popup == self: + AppletWindow._active_popup = None + + def show(self): + self.open_applet() + + def hide(self): + self.close_applet() + + def destroy(self): + if hasattr(self, "dismiss_layer"): + self.dismiss_layer.destroy() + if self._hide_timeout_id is not None: + GLib.source_remove(self._hide_timeout_id) + super().destroy() diff --git a/modules/controlcenter/battery.py b/src/shared/window/battery_widget.py similarity index 79% rename from modules/controlcenter/battery.py rename to src/shared/window/battery_widget.py index 068de6e3..c864c423 100644 --- a/modules/controlcenter/battery.py +++ b/src/shared/window/battery_widget.py @@ -1,17 +1,15 @@ -import subprocess - -from fabric.utils import get_relative_path +from fabric.utils import GLib, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.image import Image -from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import GLib from services.battery import Battery +from services.gamemode import GameModeService +from utils.functions import clear_children, format_duration +from utils.gtk_utils import svg_file class EnergyModeButton(Box): @@ -29,13 +27,8 @@ def __init__( self.battery_service = battery_service self.parent = parent - self.mode_icon_svg = Svg( - # icon_name=f"battery-{icon_name}-symbolic", - svg_file=get_relative_path( - f"../../config/assets/icons/power_modes/battery-{icon_name}.svg" - ), - size=24, - ) + self.mode_icon_svg = svg_file(f"power_modes/battery-{icon_name}.svg", size=24) + self.mode_icon = Box( children=[self.mode_icon_svg], name="energy-mode-icon", @@ -67,7 +60,9 @@ def __init__( self.update_state() def on_clicked(self, *args): - success = self.battery_service.change_power_profile(self.profile_name) + success = False + if hasattr(self.battery_service, "set_power_profile"): + success = self.battery_service.set_power_profile(self.profile_name) if success: # Update all profile buttons in parent self.parent.update_energy_mode_buttons() @@ -79,7 +74,10 @@ def _reset_icon_state(self): return False # Remove timeout def update_state(self): - is_active = self.battery_service.power_profile == self.profile_name + current_profile = None + if hasattr(self.battery_service, "get_power_profile"): + current_profile = self.battery_service.get_power_profile() + is_active = current_profile == self.profile_name if is_active: self.mode_icon.add_style_class("connected") else: @@ -90,6 +88,8 @@ class GameModeButton(Box): def __init__(self, parent, **kwargs): super().__init__(name="energy-mode-button", h_expand=True, **kwargs) self.parent = parent + self.gamemode_service = GameModeService.get_initial() + self.gamemode_service.connect("changed", self.update_state) self.game_icon = Image( icon_name="applications-games-symbolic", @@ -123,38 +123,14 @@ def __init__(self, parent, **kwargs): self.update_state() def on_clicked(self, *args): - try: - script_path = get_relative_path("../../scripts/gamemode.sh") - subprocess.run([script_path], check=False) - - GLib.timeout_add(500, lambda: self.update_state()) - except Exception as e: - print(f"Failed to toggle game mode: {e}") - - GLib.timeout_add(300, lambda: self._reset_icon_state()) - - def _reset_icon_state(self): - return False # Remove timeout - - def update_state(self): - try: - script_path = get_relative_path("../../scripts/gamemode.sh") - result = subprocess.run( - [script_path, "check"], capture_output=True, text=True, check=False - ) - is_active = result.stdout.strip() == "t" + self.gamemode_service.toggle() - if is_active: - self.game_icon.add_style_class("connected") - else: - self.game_icon.remove_style_class("connected") - except Exception as e: - print(f"Failed to check game mode status: {e}") - # Default to inactive state on error + def update_state(self, *_): + if self.gamemode_service.enabled: + self.game_icon.add_style_class("connected") + else: self.game_icon.remove_style_class("connected") - return False # Remove timeout if called from GLib.timeout_add - class BatteryControl(Box): def __init__(self, parent, **kwargs): @@ -167,7 +143,7 @@ def __init__(self, parent, **kwargs): self.set_size_request(354, -1) self.parent = parent - self.battery_service = Battery() + self.battery_service = Battery.get_initial() self.energy_mode_buttons = [] self.battery_widget = Box( @@ -264,7 +240,16 @@ def __init__(self, parent, **kwargs): self.add(self.battery_widget) self.battery_service.connect("changed", self.on_battery_changed) - self.battery_service.connect("profile_changed", self.on_profile_changed) + if hasattr(self.battery_service, "connect"): + try: + self.battery_service.connect( + "power_profile_changed", self.on_profile_changed + ) + except TypeError as e: + logger.warning( + f"[battery_widget] self.battery_service.connect( 'power_profile_changed', se... failed: {e}" + ) + # signal does not exist # Initialize display self.update_battery_info() @@ -282,7 +267,11 @@ def create_energy_mode_buttons(self): self.energy_mode_buttons.clear() # Get available profiles - available_profiles = self.battery_service.available_profiles + available_profiles = [] + if hasattr(self.battery_service, "get_available_power_profiles"): + available_profiles = ( + self.battery_service.get_available_power_profiles() or [] + ) if not available_profiles: no_profiles_label = Label( @@ -337,30 +326,38 @@ def update_energy_mode_buttons(self): def create_game_mode_button(self): # Clear existing game mode button if any - for child in list(self.game_mode_container.get_children()): - child.destroy() + clear_children(self.game_mode_container) # Create game mode button self.game_mode_button = GameModeButton(parent=self) self.game_mode_container.add(self.game_mode_button) def update_battery_info(self): - if not self.battery_service.is_present: + is_present = self.battery_service.available + if not is_present: self.battery_percentage_label.set_label("No Battery") self.power_source_label.set_label("Power Source: Not Present") self.charging_time_label.set_label("") return # Update percentage in header - percentage = self.battery_service.percentage + percentage = int(self.battery_service.percent) self.battery_percentage_label.set_label(f"{percentage}%") # Update power source and charging info - state = self.battery_service.state + if self.battery_service.charging: + state = "CHARGING" + elif self.battery_service.discharging: + state = "DISCHARGING" + elif self.battery_service.charged: + state = "FULLY_CHARGED" + else: + state = "UNKNOWN" if state in ["CHARGING", "PENDING_CHARGE"]: self.power_source_label.set_label("Power Source: Power Adapter") - time_to_full = self.battery_service.time_to_full + seconds_to_full = self.battery_service.time_to_full + time_to_full = format_duration(seconds_to_full) if time_to_full != "N/A" and time_to_full != "0m": self.charging_time_label.set_label( f"{time_to_full} until fully charged" @@ -372,7 +369,8 @@ def update_battery_info(self): self.charging_time_label.set_label("Fully Charged") elif state in ["DISCHARGING", "PENDING_DISCHARGE"]: self.power_source_label.set_label("Power Source: Battery") - time_to_empty = self.battery_service.time_to_empty + seconds_to_empty = self.battery_service.time_remaining + time_to_empty = format_duration(seconds_to_empty) if time_to_empty != "N/A" and not time_to_empty.startswith( "4553h" ): # Filter out unrealistic times @@ -389,5 +387,20 @@ def update_battery_info(self): def on_battery_changed(self, *args): self.update_battery_info() - def on_profile_changed(self, service, new_profile): + def on_profile_changed(self, service, *args): self.update_energy_mode_buttons() + + def destroy(self): + try: + self.battery_service.disconnect_by_func(self.on_battery_changed) + except Exception as e: + logger.warning( + f"[battery_widget] self.battery_service.disconnect_by_func(self.on_battery_c... failed: {e}" + ) + try: + self.battery_service.disconnect_by_func(self.on_profile_changed) + except Exception as e: + logger.warning( + f"[battery_widget] self.battery_service.disconnect_by_func(self.on_profile_c... failed: {e}" + ) + super().destroy() diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py new file mode 100644 index 00000000..2b3f404b --- /dev/null +++ b/src/shared/window/dropdown.py @@ -0,0 +1,78 @@ +from fabric.utils import GLib, logger +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox + +from shared.window.applet_window import AppletWindow +from utils.roam import modus_service + +dropdowns = [] + + +def dropdown_divider(comment): + return Box( + children=[Box(name="dropdown-divider", h_expand=True)], + name="dropdown-divider-box", + h_align="fill", + h_expand=True, + v_expand=True, + ) + + +class ModusDropdown(AppletWindow): + def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs): + super().__init__( + layer="top", + exclusivity="auto", + name="dropdown-menu", + title="modus-dropdown", + visible=False, + **kwargs, + ) + + self.id = dropdown_id or str(len(dropdowns)) + dropdowns.append(self) + + self.connect("notify::visible", self._on_visible_changed) + modus_service.connect("dropdowns-hide-changed", self.hide_dropdown) + + self.dropdown = Box( + children=dropdown_children or [], + h_expand=True, + name="dropdown-options", + orientation="vertical", + ) + + self.child_box = CenterBox(start_children=[self.dropdown]) + + self.event_box = EventBox( + events=["enter-notify", "leave-notify"], + child=self.child_box, + all_visible=True, + ) + + self.children = [self.event_box] + self.add_keybinding("escape", self.hide_dropdown) + + def hide_dropdown(self, *_): + if self.is_visible(): + GLib.idle_add(lambda: self.hide()) + + def _on_visible_changed(self, *_): + if self.is_visible(): + modus_service.current_dropdown = self.id + elif str(modus_service.current_dropdown) == str(self.id): + modus_service.current_dropdown = None + + def destroy(self): + """Clean up resources and global references""" + global dropdowns + if self in dropdowns: + dropdowns.remove(self) + + try: + modus_service.disconnect_by_func(self.hide_dropdown) + except Exception as e: + logger.error(f"An error occurred: {e}") + + super().destroy() diff --git a/src/shared/window/popup_win.py b/src/shared/window/popup_win.py new file mode 100644 index 00000000..46e4f35c --- /dev/null +++ b/src/shared/window/popup_win.py @@ -0,0 +1,211 @@ +from fabric.utils import Gdk, GLib, Gtk, logger +from fabric.widgets.wayland import WaylandWindow +from gi.repository import GtkLayerShell + + +def _get_monitor_geometry(widget: Gtk.Widget) -> tuple[int, int]: + screen = Gdk.Screen.get_default() + window = widget.get_toplevel().get_window() + + if window is None: + return 0, screen.get_width() + + monitor_index = screen.get_monitor_at_window(window) + geo = screen.get_monitor_geometry(monitor_index) + + return geo.x, geo.width + + +class PopupWindow(WaylandWindow): + def __init__( + self, + parent: WaylandWindow | None = None, + pointing_to: Gtk.Widget | None = None, + margin: tuple[int, ...] | str = "0px 0px 0px 0px", + edge_margin: int = 0, + **kwargs, + ): + super().__init__(**kwargs) + self.exclusivity = "none" + + self._parent = parent + self._pointing_widget = pointing_to + self._base_margin = self.extract_margin(margin) + self.margin = self._base_margin.values() + self.edge_margin = edge_margin + self.connect("notify::visible", self.do_update_handlers) + + def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]: + if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): + return 0, 0 + allocation = widget.get_allocation() + x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or ( + 0, + 0, + ) + return round(x / 2), round(y / 2) + + def set_pointing_to(self, widget: Gtk.Widget | None): + if self._pointing_widget: + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + except Exception as e: + if "nothing connected" not in str(e): + logger.error(f"Error disconnecting signal: {e}") + self._pointing_widget = widget + return self.do_update_handlers() + + def get_parent_window(self): + if not self._parent: + return None + if hasattr(self._parent, "anchor"): + return self._parent + toplevel = self._parent.get_toplevel() + if hasattr(toplevel, "anchor"): + return toplevel + return None + + def do_update_handlers(self, *_): + if not self._pointing_widget: + return + + # Cleanly (re)connect: always drop any existing handlers first so that + # repeated calls while visible do not stack duplicate connections. + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + self.disconnect_by_func(self.do_handle_size_allocate) + except Exception as e: + if "nothing connected" not in str(e): + logger.error(f"Error disconnecting signal: {e}") + + if not self.get_visible(): + return + + self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate) + self.connect("size-allocate", self.do_handle_size_allocate) + + GLib.timeout_add(10, self._do_delayed_reposition) + + def _do_delayed_reposition(self): + self.do_reposition(self.do_calculate_edges()) + return False + + def do_handle_size_allocate(self, *_): + if not self.get_visible(): + return None + return self.do_reposition(self.do_calculate_edges()) + + def destroy(self): + if self._pointing_widget: + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + except Exception as e: + if "nothing connected" not in str(e): + logger.warning( + f"[popup_win] self._pointing_widget.disconnect_by_func(self.do_handle_s... failed: {e}" + ) + try: + self.disconnect_by_func(self.do_handle_size_allocate) + except Exception as e: + if "nothing connected" not in str(e): + logger.warning( + f"[popup_win] self.disconnect_by_func(self.do_handle_size_allocate) failed: {e}" + ) + super().destroy() + + def do_calculate_edges(self): + move_axe = "x" + parent_window = self.get_parent_window() + if not parent_window: + return move_axe + + parent_anchor = parent_window.anchor + + if len(parent_anchor) == 1: + if GtkLayerShell.Edge.TOP in parent_anchor: + self.anchor = "left top" + else: + self.anchor = "left bottom" + return move_axe + + if len(parent_anchor) != 3: + return move_axe + + if ( + GtkLayerShell.Edge.LEFT in parent_anchor + and GtkLayerShell.Edge.RIGHT in parent_anchor + ): + move_axe = "x" + if GtkLayerShell.Edge.TOP in parent_anchor: + self.anchor = "left top" + else: + self.anchor = "left bottom" + elif ( + GtkLayerShell.Edge.TOP in parent_anchor + and GtkLayerShell.Edge.BOTTOM in parent_anchor + ): + move_axe = "y" + if GtkLayerShell.Edge.RIGHT in parent_anchor: + self.anchor = "top right" + else: + self.anchor = "top left" + + return move_axe + + def do_reposition(self, move_axe: str): + parent_window = self.get_parent_window() + if not parent_window: + return + + parent_margin = parent_window.margin + parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3] + + height = self.get_allocated_height() + width = self.get_allocated_width() + + _monitor_x, monitor_width = _get_monitor_geometry(parent_window) + + if self._pointing_widget: + coords = self.get_coords_for_widget(self._pointing_widget) + coords_centered = ( + round(coords[0] + self._pointing_widget.get_allocated_width() / 2), + round(coords[1] + self._pointing_widget.get_allocated_height() / 2), + ) + else: + coords_centered = ( + round(parent_window.get_allocated_width() / 2), + round(parent_window.get_allocated_height() / 2), + ) + + if move_axe == "x": + if len(parent_window.anchor) == 1: + bar_width = parent_window.get_allocated_width() + bar_left = (monitor_width - bar_width) // 2 + raw_margin = round((bar_left + coords_centered[0]) - (width / 2)) + else: + raw_margin = round((parent_x_margin + coords_centered[0]) - (width / 2)) + min_margin = self.edge_margin + max_margin = monitor_width - width - self.edge_margin + clamped = max(min_margin, min(raw_margin, max_margin)) + position_margins = (0, 0, 0, clamped) + else: + raw_margin = round((parent_y_margin + coords_centered[1]) - (height / 2)) + monitor_height = Gdk.Screen.get_default().get_height() + min_margin = self.edge_margin + max_margin = monitor_height - height - self.edge_margin + clamped = max(min_margin, min(raw_margin, max_margin)) + position_margins = (clamped, 0, 0, 0) + + new_margin = tuple( + a + b + for a, b in zip(position_margins, self._base_margin.values(), strict=False) + ) + new_margin = ( + max(0, new_margin[0]), + new_margin[1], + max(0, new_margin[2]), + new_margin[3], + ) + + if tuple(self.margin) != tuple(new_margin): + self.margin = new_margin diff --git a/src/styles/constant.css b/src/styles/constant.css new file mode 100644 index 00000000..daab1349 --- /dev/null +++ b/src/styles/constant.css @@ -0,0 +1,94 @@ +@define radius-xs 4px; +@define radius-sm 8px; +@define radius-md 12px; +@define radius-lg 16px; +@define radius-xl 20px; +@define radius-full 999px; +@define radius-2xs 2px; +@define transition-xfast 0.1s; +@define transition-fast 0.15s; +@define transition-medium 0.2s; +@define transition-slower 0.25s; +@define transition-slow 0.3s; +@define font-2xs 10px; +@define font-xs 11px; +@define font-sm 12px; +@define font-md 13px; +@define font-base 14px; +@define font-lg 16px; + + +/* Glass Surface Tiers */ + +@define-color glass-surface-1 alpha(@background, 0.30); +@define-color glass-surface-2 alpha(@background, 0.45); +@define-color glass-surface-3 alpha(@background, 0.60); +@define-color glass-surface-4 alpha(@background, 0.75); + +/* Glass Panels */ + +@define-color glass-panel alpha(@background, 0.50); +@define-color glass-dock alpha(@background, 0.35); +@define-color glass-popup alpha(@background, 0.60); +@define-color glass-card alpha(@background, 0.50); +@define-color glass-widget alpha(@background, 0.55); +@define-color glass-dialog alpha(@background, 0.65); +@define-color glass-tooltip alpha(@background, 0.80); +@define-color glass-menu alpha(@background, 0.65); + +/* Interactive States */ + +@define-color glass-hover alpha(@foreground, 0.08); +@define-color glass-active alpha(@foreground, 0.14); +@define-color glass-selected alpha(@primary, 0.18); +@define-color glass-focus alpha(@primary, 0.25); + +/* Borders */ + +@define-color glass-border alpha(@outline, 0.18); +@define-color glass-border-soft alpha(@outline, 0.10); +@define-color glass-border-strong alpha(@outline, 0.30); + +/* Highlights */ + +@define-color glass-highlight alpha(@foreground, 0.08); +@define-color glass-highlight-soft alpha(@foreground, 0.04); +@define-color glass-highlight-strong alpha(@foreground, 0.14); + +/* Accent */ + +@define-color glass-accent @primary; +@define-color glass-accent-soft alpha(@primary, 0.15); +@define-color glass-accent-container alpha(@primary, 0.10); + +/* Text */ + +@define-color glass-text @foreground; +@define-color glass-text-secondary alpha(@foreground, 0.60); +@define-color glass-text-disabled alpha(@foreground, 0.30); + +/* Semantic Status Colors */ + +@define-color glass-success @secondary; +@define-color glass-warning @tertiary; +@define-color glass-error @error; +@define-color glass-info @primary; + +/* Overlay Layers */ + +@define-color glass-overlay alpha(@shadow, 0.35); +@define-color glass-overlay-dark alpha(@shadow, 0.60); +@define-color glass-overlay-light alpha(@shadow, 0.15); + +/* Desktop / Wallpaper Integration */ + +@define-color desktop-selection alpha(@primary, 0.20); +@define-color desktop-grid alpha(@foreground, 0.06); +@define-color desktop-shadow alpha(@shadow, 0.30); + +/* Utility Components */ + +@define-color glass-ring alpha(@primary, 0.50); +@define-color glass-divider alpha(@outline, 0.12); +@define-color glass-icon alpha(@foreground, 0.70); +@define-color glass-scrollbar alpha(@foreground, 0.15); diff --git a/src/styles/controlpanel/dialogs.css b/src/styles/controlpanel/dialogs.css new file mode 100644 index 00000000..a5ecf07d --- /dev/null +++ b/src/styles/controlpanel/dialogs.css @@ -0,0 +1,134 @@ +#wifi-password-dialog { + background-color: transparent; +} + +/* WiFi Password Dialog */ + +#wifi-dialog-background { + background-color: @glass-dialog; + border-radius: apply(radius-md); + border: 1px solid @glass-border; + padding: 20px; + min-width: 450px; +} + +#wifi-dialog-title-container { + /* margin-bottom: 12px; */ +} + +#wifi-dialog-icon { + color: @primary; + margin-right: 4px; +} + +#wifi-dialog-title { + color: @foreground; + font-size: apply(font-base); + font-weight: 500; +} + +#wifi-dialog-error { + color: @error; + font-size: apply(font-sm); + font-weight: 500; + /* margin-bottom: 8px; */ +} + +#wifi-dialog-password-container { + margin: 5px 0; +} + +#wifi-dialog-password-label { + color: @foreground; + font-size: apply(font-md); + font-weight: 500; + margin-bottom: 4px; +} + +#wifi-dialog-password-entry { + background-color: @glass-surface-1; + border: 1px solid @glass-border; + border-radius: apply(radius-xs); + padding: 8px 12px; + color: @foreground; + font-size: apply(font-md); +} + +#wifi-dialog-password-entry:focus { + border-color: @glass-ring; + box-shadow: 0 0 0 2px @glass-focus; + background-color: @glass-hover; +} + +#wifi-dialog-show-password-box { + margin-top: 4px; +} + +#wifi-dialog-show-password-button { + background-color: transparent; + border: none; + padding: 4px; + border-radius: apply(radius-xs); + min-width: 24px; + min-height: 24px; +} + +#wifi-dialog-show-password-button:hover { + background-color: @glass-hover; +} + +#wifi-dialog-show-password-button image { + color: @foreground; +} + +#wifi-dialog-show-password-label { + color: @foreground; + font-size: apply(font-sm); +} + +#wifi-dialog-button-box { + margin-top: -15px; +} + +#wifi-dialog-cancel-button, +#wifi-dialog-join-button { + border-radius: apply(radius-xs); + font-size: apply(font-md); + font-weight: 500; + min-width: 80px; + min-height: 30px; +} + +#wifi-dialog-cancel-button { + background-color: @glass-hover; + border: 1px solid @glass-border; + color: @foreground; +} + +#wifi-dialog-cancel-button:hover { + background-color: @glass-active; +} + +#wifi-dialog-join-button { + background-color: @glass-accent; + border: 1px solid @glass-accent; + color: @foreground; +} + +#wifi-dialog-join-button:hover { + background-color: @glass-accent; + border-color: @glass-accent; +} + +#wifi-dialog-join-button.disabled { + opacity: 0.5; + background-color: @glass-surface-1; + border-color: @glass-surface-1; + color: @glass-text-secondary; +} + +#wifi-dialog-join-button.disabled:hover { + background-color: @glass-surface-1; + border-color: @glass-surface-1; + color: @glass-text-secondary; +} diff --git a/src/styles/controlpanel/expanded-player.css b/src/styles/controlpanel/expanded-player.css new file mode 100644 index 00000000..54d5df47 --- /dev/null +++ b/src/styles/controlpanel/expanded-player.css @@ -0,0 +1,179 @@ +/* macOS-style expanded player - more compact */ +#macos-outer-player-box { + min-height: 80px; + min-width: 345px; + padding: 10px; +} + +#macos-track-info { + margin-top: 5px; + margin-left: 15px; + margin-right: 15px; +} + +#macos-player-title { + font-weight: 600; + font-size: apply(font-lg); + color: @foreground; + margin-bottom: 2px; +} + +#macos-player-artist { + font-weight: 400; + margin-top: -2px; + font-size: apply(font-md); + color: @glass-text-secondary; +} + +#macos-player-album { + font-weight: 400; + margin-top: -4px; + font-size: apply(font-md); + color: @glass-text-secondary; + margin-bottom: 8px; +} + +/* macOS seek bar styling */ +#macos-seek-bar { + background-color: transparent; + margin: 8px 0 4px 0; + min-height: 10px; +} + +#macos-seek-bar trough { + min-width: 200px; + + min-height: 2px; + border-radius: apply(radius-2xs); + background-color: @glass-border; + border: none; +} + +#macos-seek-bar highlight { + background: @foreground; + border-radius: apply(radius-2xs); + border: none; +} + +#macos-seek-bar slider { + background: @foreground; + border: none; + border-radius: 20%; + min-width: 1px; + min-height: 8px; + margin: -3px; + box-shadow: 0 1px 3px @glass-overlay-light; + transition: all apply(transition-xfast) ease; +} + +#macos-seek-bar slider:hover { + min-width: 4px; + min-height: 14px; + margin: -5px; + box-shadow: 0 2px 6px @glass-overlay; +} + +#macos-position-label, +#macos-length-label { + font-size: apply(font-xs); + color: @glass-text-secondary; + font-weight: 400; +} + +/* macOS control buttons - more compact */ +#macos-button-box { + margin-top: -1px; +} + +#macos-control-button { + background: transparent; + border: none; + border-radius: apply(radius-full); + min-width: 28px; + opacity: 0.7; + min-height: 28px; + padding: 5px; + color: @foreground; + transition: all apply(transition-fast) ease; +} + +#macos-control-button:hover { + opacity: 1; +} + +#macos-play-button { + background: transparent; + border: none; + border-radius: apply(radius-full); + min-width: 36px; + opacity: 0.7; + min-height: 36px; + padding: 7px; + color: @foreground; + transition: all apply(transition-fast) ease; +} + +#macos-play-button:hover { + opacity: 1; +} + +/* macOS player switcher dots - compact horizontal layout */ +#macos-stack-buttons-box { + margin: 2px 5px; +} + +.macos-switcher-dot { + background: @glass-border; + border-radius: apply(radius-full); + min-width: 12px; + min-height: 12px; + margin: 2px; +} + +.macos-switcher-dot:hover { + background: @glass-active; +} + +.macos-switcher-dot.active { + background: @foreground; + box-shadow: 0 0 0 1px @glass-border; +} + +#expanded-seek-bar { + background-color: transparent; + margin: 8px 0 4px 0; + min-height: 10px; +} + +#expanded-seek-bar trough { + min-width: 200px; + + min-height: 2px; + border-radius: apply(radius-2xs); + background-color: @glass-border; + border: none; +} + +#expanded-seek-bar highlight { + background: @foreground; + border-radius: apply(radius-2xs); + border: none; +} + +#expanded-seek-bar slider { + background: @foreground; + border: none; + border-radius: 20%; + min-width: 1px; + min-height: 10px; + margin: -3px; + box-shadow: 0 1px 3px @glass-overlay-light; + transition: all apply(transition-xfast) ease; +} + +#expanded-seek-bar slider:hover { + min-width: 4px; + min-height: 14px; + margin: -5px; + box-shadow: 0 2px 6px @glass-overlay; +} diff --git a/src/styles/controlpanel/general.css b/src/styles/controlpanel/general.css new file mode 100644 index 00000000..f06eaf87 --- /dev/null +++ b/src/styles/controlpanel/general.css @@ -0,0 +1,28 @@ +.title { + font-size: apply(font-lg); + font-family: "SF Pro Rounded"; + font-weight: bold; + margin-bottom: 10px; +} + +#separator { + min-height: 0.09rem; + margin: 3px 0; + background-color: @glass-divider; + border-radius: apply(radius-sm); +} + + +#control-center-menu { + background-color: @glass-popup; + border-radius: apply(radius-md); + margin: 0; +} + + +#control-center-widgets { + background-color: @glass-card; + border: 1px solid @glass-border; + border-radius: apply(radius-md); + padding: 0.5rem; +} diff --git a/src/styles/controlpanel/index.css b/src/styles/controlpanel/index.css new file mode 100644 index 00000000..ba43d040 --- /dev/null +++ b/src/styles/controlpanel/index.css @@ -0,0 +1,8 @@ +@import url("controlpanel/general.css"); +@import url("controlpanel/quicksettings.css"); +@import url("controlpanel/dialogs.css"); +@import url("controlpanel/sliders.css"); +@import url("controlpanel/per_app_volume.css"); +@import url("controlpanel/player.css"); +@import url("controlpanel/expanded-player.css"); +@import url("controlpanel/networks.css"); diff --git a/src/styles/controlpanel/networks.css b/src/styles/controlpanel/networks.css new file mode 100644 index 00000000..84ac69b1 --- /dev/null +++ b/src/styles/controlpanel/networks.css @@ -0,0 +1,71 @@ +#bluetooth-title, +#wifi-title { + font-size: apply(font-lg); + font-weight: bold; +} + +/* macOS-style WiFi interface */ +#wifi-connections, +#bluetooth-connections { + background-color: @glass-surface-1; + border: 1px solid @glass-border; + border-radius: apply(radius-sm); + padding: 10px; +} + +/* WiFi network slots - macOS style */ +#wifi-network-slot button { + border-radius: apply(radius-sm); + padding: 4px 4px; + margin: 2px 0; +} + +#wifi-network-slot button:hover, +#wifi-network-button:hover { + background-color: @glass-hover; + border-radius: apply(radius-sm); +} + +#wifi-network-slot button:hover .wifi-icon-box, +#wifi-network-button:hover .wifi-icon-box { + background-color: @glass-text-secondary; +} + +/* WiFi-specific device icon styles */ +#wifi-connections #device-icon { + background-color: @glass-hover; +} + +#wifi-connections #device-icon.connected { + background-color: @glass-accent; + color: @foreground; +} + +#wifi-network-name { + color: @foreground; + font-size: apply(font-base); + font-weight: 500; +} + +#wifi-lock-icon { + color: @glass-text-secondary; + margin-left: 8px; +} + +/* Other Networks and Settings buttons */ +#wifi-other-button { + background-color: transparent; + border: none; + padding: 8px 6px; + margin-bottom: -12px; + margin-top: -6px; + border-radius: apply(radius-sm); +} + +#wifi-other-button:hover { + background-color: @glass-hover; +} + +#wifi-other-button:last-child { + margin-bottom: 3px; +} diff --git a/src/styles/controlpanel/per_app_volume.css b/src/styles/controlpanel/per_app_volume.css new file mode 100644 index 00000000..416d2103 --- /dev/null +++ b/src/styles/controlpanel/per_app_volume.css @@ -0,0 +1,77 @@ +/* Per-app volume control styles */ +#per-app-volume-control { + min-height: 140px; + min-width: 328px; + border-radius: apply(radius-sm); + padding: 1rem; +} + +#apps-scrolled-container { + min-height: 120px; + min-width: 120px; +} + +.app-volume-item { + margin: 8px 0; + padding: 8px; + background-color: @glass-hover; + border-radius: apply(radius-xs); + border: 1px solid @glass-border-soft; +} + +.app-name { + font-size: apply(font-md); + font-weight: 500; + color: @foreground; + margin-bottom: 5px; +} + +#per-app-volume-button { + background-color: @glass-hover; + border: 1px solid @glass-border; + border-radius: apply(radius-full); +} + +#per-app-volume-button:hover { + background-color: @glass-active; +} + +#per-app-volume-button:active { + background-color: @glass-selected; +} + +#per-app-volume-icon { + background-color: @glass-hover; + + border: 2px solid @glass-border; + + border-radius: apply(radius-full); + + min-width: 30px; +} + +/* Per app-volume-item */ + +.compact-app-volume-item { + padding-bottom: 5px; + padding-right: 14px; + border-bottom: 1px solid @glass-divider; +} + +#app-icon { + margin-top: 8px; +} + +.app-name-compact { + font-size: apply(font-base); + font-weight: bold; + margin-top: 8px; +} + +#app-volume-header { + font-size: apply(font-lg); + padding-left: 5px; + margin-top: -2px; + font-weight: bold; + color: @foreground; +} diff --git a/src/styles/controlpanel/player.css b/src/styles/controlpanel/player.css new file mode 100644 index 00000000..c62d5f9a --- /dev/null +++ b/src/styles/controlpanel/player.css @@ -0,0 +1,136 @@ +#player-stack-button { + border-radius: apply(radius-xs); + min-width: 7px; + min-height: 7px; + margin: 10px 3px; +} + +#button-box-c { + padding-right: 15px; +} + +#player-stack-button:hover { + background-color: @glass-hover; + box-shadow: inset @glass-highlight 0 0 10px; +} + +#player-stack-button.active { + border-radius: apply(radius-xs); + background-color: @foreground; +} + +#outer-player-box { + min-height: 110px; + min-width: 320px; + border-radius: apply(radius-sm); + margin: 0 6px; + background-color: @glass-surface-1; + border: 1px solid @glass-border-soft; +} + +#outer-player-box-c { + min-height: 55px; + min-width: 245px; + border-radius: apply(radius-sm); +} + +#outer-no-player-box-c { + background-color: @glass-surface-1; + border: 1px solid @glass-border; + min-height: 55px; + min-width: 345px; + border-radius: apply(radius-sm); +} + +#box-c { + background-color: @glass-surface-1; + border: 1px solid @glass-border; + border-radius: apply(radius-md); + margin-top: 0.5rem; + margin-left: 4px; + margin-right: 4px; + margin-bottom: 0.5rem; + min-height: 50px; + min-width: 330px; +} + +#player-box:disabled highlight, +#player-box:disabled progress { + background-color: @glass-ring; + background-image: none; +} + +#player-info-box-c { + margin-left: 10px; +} +#player-info-box { + margin-left: 10px; +} + +#player-title-c { + font-weight: bold; + font-size: apply(font-base); +} +#player-title { + margin-top: 18px; + font-weight: 700; + font-size: apply(font-lg); +} +#player-title-no { + font-weight: 700; + margin-left: 120px; + font-size: apply(font-lg); +} +#player-app-icon { + margin-bottom: 10px; +} + +#player-artist-c { + margin-top: -5px; + color: @glass-text-secondary; + font-size: apply(font-sm); +} +#player-artist, +#player-album { + font-weight: 500; + font-size: apply(font-sm); +} + +#player-controls { + margin-top: 5px; + margin-bottom: 15px; +} + +.player-button { + padding: 1px; +} + +#player-box #player-controls #button-box .player-button:disabled { + color: @glass-text-disabled; +} + +#player-button { + opacity: 0.5; +} +#player-button:hover { + opacity: 0.9; +} + +.album-image-c { + min-width: 50px; + min-height: 50px; + background-position: center; + margin-top: 10px; + margin-bottom: 10px; + background-size: cover; + margin-left: 10px; + border-radius: apply(radius-xs); +} +.album-image { + min-width: 70px; + min-height: 70px; + background-position: center; + background-size: cover; + margin-left: 12px; + border-radius: 9px; +} diff --git a/src/styles/controlpanel/quicksettings.css b/src/styles/controlpanel/quicksettings.css new file mode 100644 index 00000000..813b979b --- /dev/null +++ b/src/styles/controlpanel/quicksettings.css @@ -0,0 +1,160 @@ +/* Widgets */ +#wifi-widget, +#bluetooth-widget, +#nightlight-widget { + min-width: 140px; + padding: 5px 0 0px 0; +} + +.icon { + background-color: @primary; + font-size: 15px; + padding: 20px 20px; +} + +#bluetooth-widget-label, +#wifi-widget-label, +#nightlight-widget-label { + font-size: apply(font-sm); + margin-left: 5px; + font-weight: 500; + color: @glass-text-secondary; +} + +#device-icon { + border-radius: apply(radius-full); + padding: 5px; + margin-bottom: 2.5px; + margin-top: 2.5px; + margin-right: 10px; + background-color: @glass-hover; +} + +#device-icon.connected { + background-color: @glass-accent; +} + +#toggle-button { + min-width: 40px; + min-height: 22px; + background-color: @glass-border; + border-radius: apply(radius-full); + padding: 3px; + transition: background-color apply(transition-slow) ease; +} + +#toggle-button slider { + background-color: @foreground; + border-radius: apply(radius-full); + min-width: 16px; + min-height: 16px; + margin: -1px; + transition: background-color apply(transition-xfast) cubic-bezier(0.5, 0.25, 0, 1.25); +} + +#toggle-button:checked { + background-color: @glass-accent; +} + + +.menu { + background-color: @glass-surface-1; + border: 1px solid @glass-border; + border-radius: apply(radius-md); + margin: 5px; + padding: 1rem; +} + +.title { + padding: 0; + font-weight: 500; + font-size: apply(font-sm); +} + +#bluetooth-widget-name, +#wifi-widget-name, +#nightlight-widget-name { + margin-top: 6px; + font-size: apply(font-base); + font-weight: bold; +} + +.ct { + margin-left: 5px; +} +#control-center-menu mark indicator { + background: none; + background-image: none; + color: @glass-text-secondary; +} + +#control-center-menu mark label { + background: none; + background-image: none; + color: @glass-text-secondary; +} + +#device-button { + background-color: transparent; + border-radius: apply(radius-sm); +} + +#device-button button:hover { + background-color: @glass-hover; + border-radius: apply(radius-sm); +} + +#device-button button:hover #device-icon { + background-color: @glass-active; +} + +.button-hovered { + background-color: @glass-hover; +} + +#device-button #battery-label { + font-size: apply(font-xs); + color: @glass-text-secondary; + margin-right: 4px; +} + +#wifi-other-button label { + font-size: apply(font-sm); + font-weight: bold; +} +.wifi-icon-box { + background-color: @glass-text-secondary; + border-radius: apply(radius-full); + padding: 2px; +} + +.wifi-icon-box-connected { + background-color: @glass-accent; + border-radius: apply(radius-full); + padding: 2px; +} + + + +.title-widget { + font-size: apply(font-md); + font-family: "SF Pro Rounded"; + font-weight: bold; +} + +.status-label { + font-size: apply(font-xs); + font-weight: 500; + color: @glass-text-secondary; + font-family: "SF Pro Rounded"; +} + +#app-control-box { + min-width: 100px; +} + +#caffeine-widget, +#flight-widget { + padding: 0px 0 5px 0; + min-width: 50px; +} diff --git a/src/styles/controlpanel/sliders.css b/src/styles/controlpanel/sliders.css new file mode 100644 index 00000000..cf168b94 --- /dev/null +++ b/src/styles/controlpanel/sliders.css @@ -0,0 +1,19 @@ +#vol-slider-box { + /* margin-bottom: 2px; */ + margin-top: -10px; + /* border: 1px solid @foreground; */ +} + +#volume-widget-slider { + /* margin-bottom: 2px; */ +} + +#volume-widget-icon { + margin-left: 10px; + color: @glass-text-secondary; +} + +#brightness-widget-icon { + margin-left: 10px; + color: @glass-text-secondary; +} diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 00000000..df55fcdc --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,17 @@ +@import url("./colors.css"); +@import url("./constant.css"); +@import url("./controlpanel/index.css"); +@import url("./notification/index.css"); +@import url("./window/index.css"); +@import url("./widgets/index.css"); +@import url("./window/lock.css"); + +* { + all: unset; + color: @glass-text; + font-size: unset; + font-family: "SF Pro Rounded"; +} + +@import url("widgets/pomodoro.css"); +@import url("widgets/todo.css"); diff --git a/src/styles/notification/center_base.css b/src/styles/notification/center_base.css new file mode 100644 index 00000000..dd382749 --- /dev/null +++ b/src/styles/notification/center_base.css @@ -0,0 +1,27 @@ +#noti-center-box { + background-color: transparent; + + /* border: 1px solid @glass-border; */ + border-radius: apply(radius-xl); + margin-right: 10px; + /* padding: 20px; */ + /* margin: 6px; */ +} + +#noti-clear-button { + /* min-width: 10px; */ + background-color: @glass-popup; + min-height: 25px; + min-width: 70px; + border: 1px solid @glass-border; + border-radius: apply(radius-md); + margin-top: 10px; +} + +#notif-clear-button label { + font-size: 15px; +} + +#noti-clear-button:hover { + background-color: @glass-hover; +} diff --git a/src/styles/notification/center_expanded.css b/src/styles/notification/center_expanded.css new file mode 100644 index 00000000..1c07ab2b --- /dev/null +++ b/src/styles/notification/center_expanded.css @@ -0,0 +1,53 @@ +/* Expanded notification group header */ +#notification-group-header { + background-color: @glass-popup; + border: 1px solid @glass-border; + border-radius: apply(radius-md) apply(radius-md) 0 0; + padding: 8px 12px; + margin: 2px 0 0 0; +} + +#notification-group-title { + color: @foreground; + font-weight: bold; + font-size: apply(font-base); +} + +#notification-show-less { + background-color: transparent; + color: @glass-text-secondary; + border: 1px solid @glass-border; + border-radius: apply(radius-xs); + padding: 2px 8px; + font-size: apply(font-xs); + margin-right: 8px; +} + +#notification-show-less:hover { + background-color: @glass-active; + color: @glass-text; +} + +#notification-close-all { + background-color: transparent; + color: @glass-text-secondary; + border: 1px solid @glass-border; + border-radius: apply(radius-xs); + padding: 2px 8px; + font-size: apply(font-sm); + min-width: 24px; +} + +#notification-close-all:hover { + background-color: @glass-error; + color: @foreground; + border-color: @glass-error; +} + +/* Expanded notification list */ +#notification-group-expanded { + border: 1px solid @glass-border; + border-top: none; + border-radius: 0 0 apply(radius-md) apply(radius-md); + /* background-color: @glass-overlay-light; */ +} diff --git a/src/styles/notification/center_overrides.css b/src/styles/notification/center_overrides.css new file mode 100644 index 00000000..e0f5fa0a --- /dev/null +++ b/src/styles/notification/center_overrides.css @@ -0,0 +1,61 @@ +/* Expanded view styles - notifications use their default styling */ + +#notification-group-title { + font-size: 19px; + margin: 10px; + font-weight: bold; +} +#notification-show-less { + background-color: @glass-popup; + font-size: apply(font-sm); + margin: 10px; + border-radius: apply(radius-md); +} + +#notif-close-button { + background-color: transparent; +} + +#notification-group-expanded { + background-color: transparent; + border: none; +} + +#notification-content { + background-color: transparent; +} +#notification-count-label { + background-color: transparent; + color: white; + font-size: apply(font-md); +} +#notification-centre-notifs { + background-color: @glass-popup; + min-height: 40px; + padding: 10px; + border: 1px solid @glass-border; + border-radius: apply(radius-md); +} + +#notification-centre-notifs:last-child { + margin-bottom: 10px; +} + +#notification-close-header { + margin-right: 10px; +} +#notification-close, +#notification-close-header { + min-width: 23px; + min-height: 23px; + border-radius: apply(radius-full); +} +#notification-close:hover { + background-color: @glass-active; + min-width: 23px; + min-height: 23px; + border-radius: apply(radius-full); +} +#notification-close-summery:hover .notification-close-header button { + background-color: transparent; +} diff --git a/src/styles/notification/center_stack.css b/src/styles/notification/center_stack.css new file mode 100644 index 00000000..6ecb4399 --- /dev/null +++ b/src/styles/notification/center_stack.css @@ -0,0 +1,67 @@ +/* Expandable notification group styles */ +#notification-group { + margin: 4px 0; +} + +/* Stacked notification container */ +#notification-stack-container { + background-color: transparent; + margin: 8px 0; +} + +/* Bottom shadow layer (deepest) - offset to show stacking from below */ +#stack-shadow-bottom { + background-color: @glass-popup; + border: 1.2px solid @glass-border; + border-radius: apply(radius-md); + opacity: 0.9; + min-height: 20px; + margin-left: 16px; + margin-right: 16px; + margin-bottom: -10px; +} + +/* Middle shadow layer */ +#stack-shadow-middle { + background-color: @glass-popup; + border: 1.2px solid @glass-border; + border-radius: apply(radius-md); + opacity: 0.95; + min-height: 20px; + margin-left: 8px; + margin-right: 8px; + margin-bottom: -10px; +} + +/* Main notification content (top layer) */ +#stack-main-notification { + background-color: @glass-popup; + border: 1.2px solid @glass-border; + border-radius: apply(radius-md); + padding: 12px; + transition: all apply(transition-medium) ease; +} + +/* Hover effects for stacked notifications */ +#notification-stack-container:hover #stack-main-notification { + background-color: @glass-active; +} + +#notification-stack-container:hover #stack-shadow-middle { + opacity: 0.7; +} + +#notification-stack-container:hover #stack-shadow-bottom { + opacity: 0.5; +} + +/* Notification count badge */ +#notification-count-label { + background-color: @glass-selected; + color: @glass-text-secondary; + border-radius: 10px; + padding: 2px 6px; + font-size: apply(font-2xs); + font-weight: 500; + min-width: 16px; +} diff --git a/src/styles/notification/center_stacked_items.css b/src/styles/notification/center_stacked_items.css new file mode 100644 index 00000000..c506a5e3 --- /dev/null +++ b/src/styles/notification/center_stacked_items.css @@ -0,0 +1 @@ +/* Stacked notification group styles removed - unused */ diff --git a/src/styles/notification/index.css b/src/styles/notification/index.css new file mode 100644 index 00000000..202f78c8 --- /dev/null +++ b/src/styles/notification/index.css @@ -0,0 +1,12 @@ +/* Notification styles */ +@import url("notification/notification_base.css"); +@import url("notification/notification_buttons.css"); +@import url("notification/notification_content.css"); +@import url("notification/notification_close.css"); + +/* Notification Center styles */ +@import url("notification/center_base.css"); +@import url("notification/center_stack.css"); +@import url("notification/center_expanded.css"); +@import url("notification/center_stacked_items.css"); +@import url("notification/center_overrides.css"); diff --git a/src/styles/notification/notification_base.css b/src/styles/notification/notification_base.css new file mode 100644 index 00000000..82a7b7c0 --- /dev/null +++ b/src/styles/notification/notification_base.css @@ -0,0 +1,9 @@ +#notification { + padding: 10px; + background-color: @glass-popup; + border-radius: apply(radius-sm); + margin-top: 10px; + margin-right: 10px; + border: 1px solid @glass-border-soft; + /* margin: 5px; */ +} diff --git a/src/styles/notification/notification_buttons.css b/src/styles/notification/notification_buttons.css new file mode 100644 index 00000000..13bc4763 --- /dev/null +++ b/src/styles/notification/notification_buttons.css @@ -0,0 +1,114 @@ +#notification-close-button { + background-color: @glass-panel; + border-radius: 999px; + padding: 4px; + margin-top: 8px; + margin-right: 8px; + margin-left: -32px; + min-width: 24px; + min-height: 24px; + opacity: 0; + transition: background-color apply(transition-fast), opacity apply(transition-fast); +} + +#notification-close-button.hover, +#notification-close-button:hover { + opacity: 1; +} + +#notification-close-button:hover { + background-color: @glass-surface-4; +} + + + +#notification-action-buttons button { + background-color: @glass-hover; + padding: 4px; + border-radius: apply(radius-sm); + transition: background-color apply(transition-fast); +} + +#notification-action-buttons button:hover { + background-color: @glass-active; +} + +#notification-action-buttons button:active { + background-color: var(--primary); +} + +#notification-action-buttons button:active #button-label { + color: var(--shadow); +} + +.mac-options-button { + background-color: @glass-panel; + border-radius: apply(radius-md); + padding: 4px 8px; + margin-top: 4px; + margin-right: 8px; + margin-bottom: 8px; + color: @text-secondary; + font-weight: 500; + transition: background-color apply(transition-fast), color apply(transition-fast); +} + +.mac-options-button:hover { + background-color: @glass-surface-4; + color: @text-primary; +} + +.mac-options-button:active { + background-color: @glass-surface-5; +} + +menu.mac-context-menu { + background-color: @glass-panel; + border-radius: apply(radius-md); + padding: 4px; + border: 1px solid @glass-surface-2; +} + +.mac-context-menu menuitem { + padding: 4px 12px; + border-radius: apply(radius-sm); + color: @text-primary; + transition: background-color apply(transition-fast); +} + +.mac-context-menu menuitem:hover { + background-color: var(--primary); + color: var(--shadow); +} + +.mac-header-pill { + background-color: @glass-surface-4; + border-radius: apply(radius-md); + padding: 2px 8px; + color: @text-secondary; + font-size: 12px; + font-weight: 500; + margin-right: 4px; + transition: background-color apply(transition-fast), color apply(transition-fast); +} + +.mac-header-pill:hover { + background-color: @glass-surface-5; + color: @text-primary; +} + +.mac-header-circle { + background-color: @glass-surface-4; + border-radius: 999px; + padding: 3px; + margin: 0; + min-width: 18px; + min-height: 18px; + color: @text-secondary; + transition: background-color apply(transition-fast); +} + +.mac-header-circle:hover { + background-color: @glass-surface-5; + color: @text-primary; +} diff --git a/src/styles/notification/notification_close.css b/src/styles/notification/notification_close.css new file mode 100644 index 00000000..8deea843 --- /dev/null +++ b/src/styles/notification/notification_close.css @@ -0,0 +1,24 @@ +#notif-close-button { + background-color: @glass-surface-1; + border-radius: apply(radius-lg); + padding: 4px; + transition: background-color apply(transition-fast); +} + +#notif-close-button:hover, +#notif-close-button:focus { + background-color: @glass-active; +} + +#notif-close-button:active { + background-color: @glass-error; +} + +#notif-close-label { + color: @glass-error; + font-size: 20px; +} + +#notif-close-button:active #notif-close-label { + color: var(--shadow); +} diff --git a/src/styles/notification/notification_content.css b/src/styles/notification/notification_content.css new file mode 100644 index 00000000..9157261e --- /dev/null +++ b/src/styles/notification/notification_content.css @@ -0,0 +1,27 @@ +#notification-icon { + border-radius: apply(radius-lg); +} + +#notification-image { + border-radius: apply(radius-lg); + color: var(--on-surface); +} + +#notification-summary, +#button-label { +} + +#notification-summary { + font-weight: bold; + font-size: apply(font-lg); + /* color: var(--primary); */ +} + +#notification-app-name { + color: var(--outline); + font-weight: bold; +} + +#action-button { + margin-top: 8px; +} diff --git a/src/styles/widgets/about.css b/src/styles/widgets/about.css new file mode 100644 index 00000000..40cb143b --- /dev/null +++ b/src/styles/widgets/about.css @@ -0,0 +1,35 @@ +#about-menu { + background-color: @glass-card; + + border: 1px solid @glass-border; + border-top: none; + border-radius: 1.25rem; + padding: 2rem; +} + +#vendor-label { + color: @glass-text-secondary; + margin-top: -1rem; +} + +#info-label { + color: @glass-text-secondary; +} + +#more-info-button { + background-color: @glass-hover; + border-radius: apply(radius-xs); + padding: 0px 10px; + margin: 1px 2px; + transition: all apply(transition-medium) ease-in-out; +} +#more-info-button:hover { + background-color: @glass-accent; +} + +#more-info-button label { + font-size: apply(font-sm); + margin: 0px; + font-weight: 400; + color: @foreground; +} diff --git a/src/styles/widgets/desktop-widgets.css b/src/styles/widgets/desktop-widgets.css new file mode 100644 index 00000000..d8ebaf15 --- /dev/null +++ b/src/styles/widgets/desktop-widgets.css @@ -0,0 +1,247 @@ +#weather-container { + margin: 5px 5px 20px 5px; + min-width: 170px; + min-height: 170px; + border-radius: apply(radius-lg); + transition: all apply(transition-slow); + background: @glass-widget; + border: 1px solid @glass-border-soft; +} + +/* Base Weather Layout */ +#weather-widget { + padding: 12px 14px; +} + +#city { + color: @glass-text; + font-weight: 600; + font-size: 18px; +} + +#temperature { + font-size: 64px; + font-weight: 600; + color: white; + margin-top: -5px; + margin-bottom: -5px; +} + +#condition-emoji { + margin-top: -2px; +} + +#condition { + font-size: apply(font-lg); + font-weight: 600; + color: white; +} + +#feels-like { + font-weight: 500; + font-size: 15px; + color: @glass-text; +} + +/* Weather Condition Gradients */ +.weather-clear { + background: linear-gradient(135deg, @primary 0%, @cyan 100%); +} + +.weather-mostly-clear { + background: linear-gradient(135deg, @primary 0%, @primary 100%); +} + +.weather-partly-cloudy { + background: linear-gradient(135deg, @outline 0%, #bac8e0 100%); +} + +.weather-overcast { + background: linear-gradient(135deg, @glass-surface-4 0%, @glass-surface-4 100%); +} + +.weather-fog { + background: linear-gradient(135deg, @outline 0%, @foreground 100%); +} + +.weather-light-rain { + background: linear-gradient(135deg, @on-primary 0%, @glass-dialog 100%); +} + +.weather-rain { + background: linear-gradient(135deg, @glass-surface-3 0%, @on-tertiary 100%); +} + +.weather-heavy-rain { + background: linear-gradient(135deg, @surface 0%, @glass-dialog 100%); +} + +.weather-snow, +.weather-heavy-snow { + background: linear-gradient(135deg, @secondary 0%, @cyan 100%); +} + +.weather-storm { + background: linear-gradient(135deg, @background 0%, @surface-bright 50%, @outline 100%); +} + +/* Calendar Widget */ +#calendar-box-widget { + background: @glass-widget; + margin: 5px 20px 20px 10px; + min-width: 170px; + min-height: 170px; + border-radius: apply(radius-lg); + border: 1px solid @glass-border-soft; +} + +#calendar-widget { + margin: 5px 10px 0px 10px; +} + +#calendar-month { + font-size: apply(font-lg); + font-weight: 600; + color: @primary; + margin-bottom: 6px; +} + +#calendar-days-header { + margin-bottom: 4px; +} + +#calendar-day-header { + font-size: apply(font-sm); + font-weight: 600; + color: @foreground; + min-width: 18px; + min-height: 10px; +} + +#calendar-day-header-weekend { + font-size: apply(font-sm); + font-weight: 600; + color: @glass-text-secondary; + min-width: 18px; + min-height: 10px; +} + +#calendar-day { + font-size: apply(font-sm); + margin: 1.4px 0px 1.4px 0px; + font-weight: 500; + color: @foreground; + min-width: 18px; + min-height: 10px; +} + +#calendar-day-weekend { + font-size: apply(font-sm); + font-weight: 500; + color: @glass-text-secondary; + min-width: 18px; + min-height: 16px; +} + +#calendar-day-today { + font-size: apply(font-sm); + font-weight: 700; + color: @on-primary; + background-color: @primary; + border-radius: apply(radius-full); + min-width: 18px; + min-height: 16px; + padding: 2px; +} + +#calendar-day-empty { + min-width: 18px; + min-height: 16px; +} + +#calendar-grid { + margin-top: 2px; +} + +/* Date Widget CSS */ +#date-widget label { + margin-top: 10px; +} + +#day label, +#month label { + color: var(--foreground); + padding: 5px; + margin-top: 10px; + font-size: 30px; + color: @glass-text; + font-weight: 600; +} + +#date label { + color: @glass-text; + + margin-top: -10px; + font-size: 90px; + font-weight: 600; +} + +#day label { + color: alpha(@primary, 0.9); +} + +#date-container { + background: @glass-widget; + margin: 5px 10px 20px 5px; + min-width: 170px; + min-height: 170px; + border-radius: apply(radius-lg); + border: 1px solid @glass-border-soft; +} + +/* System Info Widget CSS */ +#info-box-widget { + background: @glass-widget; + margin: 5px 5px 5px 5px; + min-width: 170px; + min-height: 170px; + border-radius: apply(radius-lg); + border: 1px solid @glass-border-soft; +} + +#progress-label { + font-weight: 600; + font-size: apply(font-md); +} + +#progress { + background-color: transparent; + border: solid 8px @primary; +} + + +/* Info text styling */ +#info-text { + font-size: apply(font-md); + font-weight: 500; + color: @glass-text-secondary; + min-width: 32px; +} + +#info-value { + font-size: apply(font-sm); + font-weight: 600; + color: @foreground; +} + +#info-container { + margin-left: -8px; + margin-top: 5px; +} + +#info { + margin-left: -13px; + margin-top: 5px; + font-size: apply(font-base); + font-weight: 600; +} diff --git a/styles/dropdown.css b/src/styles/widgets/dropdown.css similarity index 63% rename from styles/dropdown.css rename to src/styles/widgets/dropdown.css index 2f036add..1c00a8ed 100644 --- a/styles/dropdown.css +++ b/src/styles/widgets/dropdown.css @@ -6,31 +6,34 @@ } #dropdown-options { - background-color: alpha(#000, 0.3); - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); + background-color: @glass-menu; + border: 1px solid @glass-border; border-radius: 0.75rem; padding: 0.5rem; } #dropdown-option { background-color: transparent; - border-radius: 5px; + border-radius: apply(radius-xs); padding: 0px 10px; margin: 1px 2px; transition: all 0ms ease-in-out; } #dropdown-option:hover { + color: @on-primary; transition: all 20ms ease-in-out; - color: #222; - background-color: alpha(#2369ff, 1); + background-color: @glass-accent; +} + +#dropdown-option:hover #dropdown-option-label { + color: @on-primary; } #dropdown-option-label { - font-size: 13px; + font-size: apply(font-md); margin: 0px; - color: #fff; + color: @foreground; font-weight: 400; } @@ -39,23 +42,23 @@ } #dropdown-option:hover #dropdown-option-keybind { - color: #fff; + color: @on-primary; } #dropdown-option-keybind { font-weight: 500; - color: #aaa; + color: @glass-text-secondary; } /* Divider */ #dropdown-divider-box { background-color: transparent; - border-radius: 5px; + border-radius: apply(radius-xs); padding: 0px 10px; margin: 2px; } #dropdown-divider { - border-bottom: 1px solid alpha(#aaa, 0.3); + border-bottom: 1px solid @glass-divider; margin: 2px 0; } diff --git a/src/styles/widgets/index.css b/src/styles/widgets/index.css new file mode 100644 index 00000000..d811204e --- /dev/null +++ b/src/styles/widgets/index.css @@ -0,0 +1,5 @@ +@import url("widgets/dropdown.css"); +@import url("widgets/about.css"); +@import url("widgets/other.css"); +@import url("widgets/desktop-widgets.css"); +@import url("widgets/tray.css"); diff --git a/src/styles/widgets/other.css b/src/styles/widgets/other.css new file mode 100644 index 00000000..979a5595 --- /dev/null +++ b/src/styles/widgets/other.css @@ -0,0 +1,25 @@ +/* FlatScale styling */ +.flat-scale { + margin: 4px 0px; +} + +.flat-scale slider { + background-color: white; + min-height: 24px; + min-width: 24px; + border-radius: apply(radius-full); +} + + +.flat-scale trough { + background-color: @glass-surface-1; + min-height: 25px; + border-radius: apply(radius-full); + border: 1px solid @glass-border-soft; +} + +.flat-scale highlight { + background-color: @glass-text-secondary; + min-height: 25px; + border-radius: apply(radius-full); +} diff --git a/src/styles/widgets/pomodoro.css b/src/styles/widgets/pomodoro.css new file mode 100644 index 00000000..b286711d --- /dev/null +++ b/src/styles/widgets/pomodoro.css @@ -0,0 +1,121 @@ + +#pomodoro-popup { + background-color: @glass-card; + border-radius: apply(radius-md); + padding: 15px; + margin-right: 20px; + border: 1px solid @glass-border; +} + +#pomodoro-container { + padding: 10px; + background-color: transparent; +} + +#pomodoro-time { + font-size: 38px; + font-weight: 800; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +#pomodoro-cycle { + font-size: 13px; + font-weight: 500; + color: @glass-text-secondary; + font-family: "SF Pro Rounded"; +} + +#pomodoro-title { + font-size: 14px; + font-weight: 700; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +/* Base progress bar color */ +#pomodoro-progress { + background-color: transparent; + color: alpha(@primary, 0.3); + border-color: @primary; +} + +/* Short break color */ +#pomodoro-progress-short { + background-color: transparent; + color: alpha(@success, 0.3); + border-color: @success; +} + +/* Long break color */ +#pomodoro-progress-long { + background-color: transparent; + color: alpha(@error, 0.3); + border-color: @error; +} + +#pomodoro-phase-btn { + background-color: @glass-highlight-strong; + color: alpha(@theme_fg_color, 0.7); + border-radius: apply(radius-sm); + padding: 5px 14px; + font-weight: 600; + font-size: 12px; +} + +#pomodoro-phase-btn:hover { + background-color: @glass-active; + color: @theme_fg_color; +} + +#pomodoro-phase-btn.active { + background-color: @primary; + color: white; +} + +#pomodoro-start-btn { + background-color: @primary; + color: white; + border-radius: apply(radius-sm); + padding: 6px 20px; + font-weight: bold; + font-size: 14px; +} + +#pomodoro-start-btn:hover { + background-color: alpha(@primary, 0.8); +} + +#pomodoro-reset-btn, #pomodoro-secondary-btn { + background-color: @glass-highlight-strong; + color: @theme_fg_color; + border-radius: apply(radius-sm); + padding: 6px 15px; + font-weight: 600; + font-size: 14px; +} + +#pomodoro-reset-btn:hover, #pomodoro-secondary-btn:hover { + background-color: @glass-active; +} + +#pomodoro-spin-box { + background-color: @glass-highlight-strong; + border-radius: apply(radius-sm); + padding: 2px; +} + +#pomodoro-spin-btn { + background: transparent; + border: none; + box-shadow: none; + padding: 2px 4px; + min-height: 16px; + color: @theme_fg_color; + border-radius: apply(radius-sm); + font-size: 10px; +} + +#pomodoro-spin-btn:hover { + background-color: @glass-active; +} diff --git a/src/styles/widgets/todo.css b/src/styles/widgets/todo.css new file mode 100644 index 00000000..f4e66d7f --- /dev/null +++ b/src/styles/widgets/todo.css @@ -0,0 +1,263 @@ + +/* === Main Todo List === */ +#todo-list-menu { + background-color: @glass-card; + border-radius: apply(radius-md); + padding: 15px; + margin-right: 20px; + border: 1px solid @glass-border; +} + +#todo-main-container { + padding: 5px; + background-color: transparent; +} + +#todo-header { + padding: 0; + margin-bottom: 5px; +} + +#todo-title { + font-size: 16px; + font-weight: 700; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +#todo-stats { + font-size: 12px; + color: @glass-text-secondary; + font-family: "SF Pro Rounded"; +} + +/* === Add Task Section === */ +#todo-add-section { + background-color: @glass-hover; + border-radius: apply(radius-sm); + padding: 8px 10px; +} + +#new-todo-entry { + background: transparent; + border: none; + color: @foreground; + font-size: 13px; + font-family: "SF Pro Rounded"; + min-height: 24px; +} + +#new-todo-entry placeholder { + color: @glass-text-disabled; +} + +#add-todo-button { + background-color: @glass-accent; + border-radius: apply(radius-full); + padding: 6px; + min-width: 24px; + min-height: 24px; +} + +#add-todo-button:hover { + background-color: alpha(@glass-accent, 0.8); +} + +/* === Todo Items === */ +#todos-container { + padding: 0; +} + +#todo-item { + background-color: transparent; + border-radius: apply(radius-sm); + padding: 8px 6px; + transition: background-color 0.2s ease; +} + +#todo-item:hover { + background-color: @glass-hover; +} + +#todo-divider { + min-height: 1px; + background-color: @glass-highlight-soft; + margin: 0 4px; +} + +/* === Progress Ring === */ +#todo-progress-ring { + background-color: transparent; + color: alpha(@foreground, 0.15); + border-color: @foreground; +} + +/* === Checkbox === */ +#todo-checkbox { + background: transparent; + border: none; + box-shadow: none; + padding: 0; + min-width: 18px; + min-height: 18px; +} + +#todo-checkbox:hover { + background-color: @glass-hover; + border-radius: apply(radius-sm); +} + +/* === Task Text === */ +#todo-text { + font-size: 14px; + font-weight: 500; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +/* === Options Button === */ +#todo-options-btn { + background: transparent; + border: none; + box-shadow: none; + padding: 4px 8px; + min-width: 24px; + min-height: 24px; + font-size: 16px; + color: @glass-text-secondary; + opacity: 0; + transition: opacity 0.2s ease; +} + +#todo-item:hover #todo-options-btn { + opacity: 1; +} + +#todo-options-btn:hover { + color: @foreground; + background-color: @glass-hover; + border-radius: apply(radius-sm); +} + +/* === Clear + Footer === */ +#clear-completed-button { + background: transparent; + border: none; + font-size: 12px; + color: @glass-text-disabled; + padding: 4px; +} + +#clear-completed-button:hover { + color: @foreground; +} + +#todo-footer { + font-size: 11px; + color: @glass-text-secondary; + font-family: "SF Pro Rounded"; + margin-top: 4px; +} + +/* === Options Popover === */ +#todo-options-popover { + background-color: @glass-card; + border-radius: apply(radius-md); + padding: 10px; + margin-right: 20px; + border: 1px solid @glass-border; + min-width: 340px; +} + +#todo-options-container { + background-color: transparent; +} + +#todo-opt-title { + font-size: 14px; + font-weight: 700; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +#todo-opt-subtitle { + font-size: 11px; + color: @glass-text-secondary; + font-family: "SF Pro Rounded"; +} + +/* === Color Swatches === */ +#todo-color-swatch { + background-color: @glass-hover; + border-radius: apply(radius-full); + padding: 0; + min-width: 28px; + min-height: 28px; + border: 2px solid transparent; + transition: border-color 0.15s ease; +} + +#todo-color-swatch:hover { + border-color: @glass-border; +} + +#todo-color-swatch.selected { + border-color: @foreground; + box-shadow: 0 0 0 2px alpha(@foreground, 0.2); +} + +/* === Deadline Pill === */ +#todo-deadline-pill { + background-color: @glass-hover; + border-radius: apply(radius-full); + padding: 6px 12px; +} + +#todo-deadline-value { + font-size: 12px; + color: @foreground; + font-family: "SF Pro Rounded"; + min-width: 70px; +} + +/* === Stepper (reuse pomodoro style) === */ +#todo-spin-box { + background-color: @glass-hover; + border-radius: apply(radius-sm); + padding: 2px; +} + +#todo-spin-btn { + background: transparent; + border: none; + box-shadow: none; + padding: 2px 4px; + min-height: 14px; + color: @theme_fg_color; + border-radius: apply(radius-sm); + font-size: 8px; +} + +#todo-spin-btn:hover { + background-color: @glass-active; +} + +#todo-spin-value { + font-size: 12px; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +/* === Preset Buttons === */ +#todo-preset-btn { + background-color: @glass-hover; + border-radius: apply(radius-full); + padding: 6px 12px; + font-size: 11px; + color: @foreground; + font-family: "SF Pro Rounded"; +} + +#todo-preset-btn:hover { + background-color: @glass-active; +} diff --git a/styles/tray.css b/src/styles/widgets/tray.css similarity index 52% rename from styles/tray.css rename to src/styles/widgets/tray.css index d10b0eff..564d647d 100644 --- a/styles/tray.css +++ b/src/styles/widgets/tray.css @@ -1,7 +1,7 @@ tooltip { - border: #474747 solid 1px; + border: 1px solid @glass-border; border-radius: 0.75rem; - background-color: alpha(#fff, 0.05); + background-color: @glass-tooltip; } @keyframes tooltipShow { @@ -20,17 +20,17 @@ tooltip > * { } menu { - border: #474747 solid 1px; + border: 1px solid @glass-border; border-radius: 0.75rem; - transition: all 100ms ease-in-out; - background-color: alpha(#fff, 0.05); + transition: all apply(transition-xfast) ease-in-out; + background-color: @glass-menu; } menu > menuitem { padding: 5px 10px; margin: 1px 2px; - font-size: 13px; - border-bottom: #474747 solid 1px; + font-size: apply(font-md); + border-bottom: 1px solid @glass-divider; } menu > menuitem:last-child { @@ -38,13 +38,13 @@ menu > menuitem:last-child { } menu > menuitem:hover { transition: all 20ms ease-in-out; - font-size: 13px; - border-radius: 4px; - color: #222; - background-color: alpha(#2369ff, 1); + font-size: apply(font-md); + border-radius: apply(radius-xs); + color: @on-primary; + background-color: @glass-accent; } menu > menuitem:hover > label { - color: #fff; + color: @on-primary; font-weight: 400; } diff --git a/styles/battery-widget.css b/src/styles/window/battery.css similarity index 56% rename from styles/battery-widget.css rename to src/styles/window/battery.css index 763cfe46..cfb8cdc7 100644 --- a/styles/battery-widget.css +++ b/src/styles/window/battery.css @@ -1,105 +1,95 @@ #battery-widget { - background: rgba(0, 0, 0, 0); padding-left: 5px; padding-right: 5px; } .battery-main-title { - font-size: 16px; + font-size: apply(font-lg); font-weight: bold; - color: rgba(255, 255, 255, 0.95); + color: @glass-text; margin: 6px 10px; } .battery-power-source { font-size: 0.9em; font-weight: 500; - color: rgba(255, 255, 255, 0.8); + color: @glass-text-secondary; margin: 0px 12px; } .battery-section-title { font-size: 0.9em; font-weight: 500; - color: rgba(255, 255, 255, 0.9); + color: @glass-text; margin: 8px 12px 4px 12px; } -.battery-energy-item { - margin: 4px 12px; - padding: 4px 0; -} - .battery-status-section { - /* background: rgba(255, 255, 255, 0.06); */ - /* border-radius: 12px; */ padding: 2px; - /* margin: 8px 0; */ - /* border: 1px solid rgba(255, 255, 255, 0.08); */ } .battery-percentage { - font-size: 16px; + font-size: apply(font-lg); margin-right: 8px; font-weight: 500; - color: rgba(255, 255, 255, 0.95); + color: @glass-text; } #energy-mode-button-clickable { background: transparent; border: none; - border-radius: 8px; + border-radius: apply(radius-sm); padding: 6px 12px; margin: 1px 0; } #energy-mode-button-clickable:hover { - background: rgba(255, 255, 255, 0.1); + background: @glass-hover; +} + +#energy-mode-button-clickable:hover #energy-mode-icon { + background-color: @glass-active; } #energy-mode-icon { - border-radius: 50%; + border-radius: apply(radius-full); padding: 4px; margin-top: -2px; margin-bottom: -2px; margin-right: 6px; margin-left: -6px; - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); + background-color: @glass-hover; + color: @glass-text-secondary; } -#energy-mode-icon.connected { - background-color: #007aff; - color: #ffffff; -} .battery-power-source { - font-size: 12px; + font-size: apply(font-sm); margin-bottom: -4px; - color: alpha(#ffffff, 0.5); + color: @glass-text-secondary; } .battery-section-title { - font-size: 14px; - color: alpha(#ffffff, 0.7); + font-size: apply(font-base); + color: @glass-text-secondary; margin: 2px 0 0 8px; } .battery-power-mode { - font-size: 13px; + font-size: apply(font-md); } .battery-settings-button:hover { margin-top: -5px; - background: alpha(#ffffff, 0.1); + background: @glass-hover; min-height: 30px; - border-radius: 8px; + border-radius: apply(radius-sm); } .battery-settings-button { margin-top: -5px; min-height: 30px; - border-radius: 8px; + border-radius: apply(radius-sm); } .battery-settings-button label { font-weight: 500; - color: alpha(#ffffff, 1); + color: @foreground; } #energy-mode-button:first-child { margin-top: 0; @@ -107,12 +97,12 @@ #energy-mode-button { margin-top: -8px; margin-bottom: 4px; - color: rgba(255, 255, 255, 0.85); + color: @glass-text-secondary; } .gamemode-button { - font-size: 12px; + font-size: apply(font-sm); margin-left: -6px; - color: rgba(255, 255, 255, 0.85); + color: @glass-text-secondary; } #game-mode-button { @@ -125,25 +115,33 @@ #game-mode-button-clickable { background: transparent; border: none; - border-radius: 8px; + border-radius: apply(radius-sm); padding: 6px 12px; margin: 1px 0; - /* min-height: 36px; */ } #game-mode-button-clickable:hover { - background: rgba(255, 255, 255, 0.1); + background: @glass-hover; +} + +#game-mode-button-clickable:hover #game-mode-icon { + background-color: @glass-active; } #game-mode-icon { - border-radius: 50%; + border-radius: apply(radius-full); padding: 4px; margin-right: 8px; - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); + background-color: @glass-hover; + color: @glass-text-secondary; +} + +#energy-mode-icon.connected { + background-color: @glass-accent; + color: @on-primary; } #game-mode-icon.connected { - background-color: #007aff; - color: #ffffff; + background-color: @glass-accent; + color: @on-primary; } diff --git a/src/styles/window/dock.css b/src/styles/window/dock.css new file mode 100644 index 00000000..d7c0d669 --- /dev/null +++ b/src/styles/window/dock.css @@ -0,0 +1,25 @@ +#dock { + background: transparent; + border: none; + padding: 0; + margin: 0; +} + +.dock-background { + background-color: @glass-dock; + background-image: linear-gradient(to bottom, @glass-highlight-strong 0%, @glass-highlight-soft 30%, transparent 100%); + border: 1px solid @glass-border; + border-radius: 20px; + /* You can add a box-shadow here if desired, e.g.: + box-shadow: 0 4px 6px @desktop-shadow; */ +} + +#dock-outer-box { + background: transparent; +} + +#dock-hover-strip { + min-height: 2px; + min-width: 10px; + background: transparent; +} diff --git a/src/styles/window/index.css b/src/styles/window/index.css new file mode 100644 index 00000000..a17db892 --- /dev/null +++ b/src/styles/window/index.css @@ -0,0 +1,9 @@ +@import url("window/panel.css"); +@import url("window/spotlight.css"); +@import url("window/battery.css"); +@import url("window/settings.css"); +@import url("window/lock.css"); +@import url("window/dock.css"); +@import url("window/switcher.css"); +@import url("window/osd.css"); +@import url("window/screencapture.css"); diff --git a/src/styles/window/lock.css b/src/styles/window/lock.css new file mode 100644 index 00000000..1671e787 --- /dev/null +++ b/src/styles/window/lock.css @@ -0,0 +1,109 @@ +#lockscreen-bg { + background-size: cover; +} + +#lock-notch { + margin: 0 20px; +} + +#lock-notch-stack { + background-color: @shadow; + border-radius: 0 0 apply(radius-lg) apply(radius-lg); + min-width: 200px; + min-height: 34px; +} + +#lock-notch-corner { + background-color: @shadow; + border-radius: 0; + min-width: 20px; + min-height: 20px; +} + +#indicator-box { + margin-right: 10px; + margin-top: 4px; +} + +#indicator-box #bt-button, +#indicator-box #network-button, +#indicator-box #battery-button { + background-color: transparent; +} + +#indicator-box #bt-button *, +#indicator-box #network-button *, +#indicator-box #battery-button * { + color: @glass-text-secondary; +} + +#indicator-box #bt-button:hover, +#indicator-box #network-button:hover, +#indicator-box #battery-button:hover { + background-color: transparent; +} + +#indicator-box #bt-button:hover *, +#indicator-box #network-button:hover *, +#indicator-box #battery-button:hover * { + color: @glass-text-secondary; +} +#container-box { + min-height: 50px; + min-width: 250px; + transition: all 0.7s ease-in-out; +} +#password-entry { + background-color: @glass-surface-2; + border-radius: apply(radius-xl); + color: white; + min-width: 220px; + min-height: 40px; + padding-right: 5px; + padding-left: 5px; + font-size: apply(font-base); + font-weight: 500; + transition: all 0.7s ease-in-out; +} +#profile-box { + margin-bottom: 10px; +} +#username { + font-size: 21px; + font-weight: bold; + margin-top: 5px; + transition: all 0.7s ease-in-out; +} +#lock-date label { + background-color: transparent; + color: @glass-text-secondary; + border: none; + font-size: 30px; + margin-top: 80px; + font-weight: bold; +} +#lock-clock label { + background-color: transparent; + color: @glass-text-secondary; + border: none; + font-size: 120px; + margin-top: -20px; + font-weight: bold; +} + +#face-icon image { + margin-bottom: 10px; + border-radius: apply(radius-full); +} +#face-icon { + padding-bottom: 10px; +} + +#unlock-box { + margin-bottom: 40px; +} +#unlock-text { + font-size: 15px; + color: @glass-text-secondary; + font-weight: bold; +} diff --git a/src/styles/window/osd.css b/src/styles/window/osd.css new file mode 100644 index 00000000..fdfa1854 --- /dev/null +++ b/src/styles/window/osd.css @@ -0,0 +1,27 @@ +#osd-container { + background-color: @glass-popup; + padding: 12px 20px; + margin: 70px; + min-width: 180px; + min-height: 200px; + border-radius: apply(radius-lg); + border: 1px solid @glass-border-soft; +} + +#osd-container scale { + min-width: 180px; +} + +#osd-container trough { + background: @glass-surface-1; + min-height: 15px; + margin-right: 4px; + transition: all apply(transition-slower) cubic-bezier(0.175, 0.885, 0.32, 1.275); + border-radius: apply(radius-lg); +} + +#osd-container trough highlight { + border-radius: apply(radius-full); + background: @glass-accent; + transition: all apply(transition-slower) cubic-bezier(0.175, 0.885, 0.32, 1.275); +} diff --git a/src/styles/window/panel.css b/src/styles/window/panel.css new file mode 100644 index 00000000..44b3c694 --- /dev/null +++ b/src/styles/window/panel.css @@ -0,0 +1,163 @@ +#panel { + background-color: @glass-panel; + margin: -4px 0; + transition: all 120ms ease-in-out; +} + +#panel-button:hover, +#panel-button.active, +#battery-button:hover, +#battery-button.active, +#network-button:hover, +#network-button.active, +#bt-button:hover, +#bt-button.active, +#global-menu:hover, +#global-menu.active, +#workspaces>button:hover { + background-color: @glass-accent; + color: @on-primary; +} + +#panel-button, +#battery-button, +#network-button, +#bt-button { + margin: 5px 0; + padding: 0 2px; + border-radius: apply(radius-sm); +} + +#battery-label, +#date-time { + font-weight: 500; +} + +#global-menu { + margin: 5px 2px; + padding: 0 5px; + border-radius: apply(radius-xs); +} + +#date-time { + margin: 0 4px; +} + +#workspaces>button { + margin: 6px 0; + padding: 0 16px; + border-radius: apply(radius-sm); + min-width: 12px; + border: 1px solid @outline; +} + +#workspaces>button.active { + background-color: @glass-active; +} + +#workspaces>button.active label { + color: @foreground; +} + +#workspaces>button.active:hover { + background-color: @glass-accent; +} + +#workspaces>button.active:hover label { + color: @on-primary; +} + +#workspaces>button.urgent { + background-color: @glass-error; +} + + +#panel-button:hover *, +#panel-button.active *, +#battery-button:hover *, +#battery-button.active *, +#network-button:hover *, +#network-button.active *, +#bt-button:hover *, +#bt-button.active *, +#global-menu:hover *, +#global-menu.active *, +#workspaces>button:hover * { + color: @on-primary; + +} + +#panel-notch { + margin: 0 20px; +} + +#panel-notch-stack { + background-color: @shadow; + border-radius: 0 0 apply(radius-lg) apply(radius-lg); + min-width: 200px; + min-height: 34px; +} + + +#panel-notch-corner { + background-color: @shadow; + border-radius: 0; + min-width: 20px; + min-height: 20px; +} + +#panel-notch-stack #panel-button { + margin: 0; + padding: 0 4px; + margin-left: 8px; + background: none; + border: none; + border-radius: 0; +} + +#panel-notch-stack #panel-button:hover { + background: none; +} + +#panel-notch-stack #panel-button:hover * { + color: inherit; +} + +#notch-recording-time { + font-size: apply(font-xs); + font-weight: 500; + padding: 0 8px 0 4px; +} + +#panel-notch-stack #notch-player-art { + min-width: 18px; + min-height: 18px; + background-size: cover; + background-position: center; + border-radius: apply(radius-sm); + margin-left: 6px; +} + +/* ── Notch transient indicators (kbd layout + capslock + numlock + charging) ── */ +#notch-kbd-layout, +#notch-capslock, +#notch-numlock, +#notch-charging, +#notch-mic { + padding: 0 8px; +} + +#notch-indicator-icon { + min-width: 16px; + min-height: 16px; + color: @glass-text-secondary; + margin-left: 2px; +} + +#notch-indicator-label { + font-size: apply(font-xs); + font-weight: 600; + color: @glass-text-secondary; + letter-spacing: 0.5px; + margin-right: 2px; +} diff --git a/src/styles/window/screencapture.css b/src/styles/window/screencapture.css new file mode 100644 index 00000000..a094a415 --- /dev/null +++ b/src/styles/window/screencapture.css @@ -0,0 +1,171 @@ +#sc-toolbar { + background-color: @glass-popup; + border: 1px solid @glass-border; + border-radius: apply(radius-sm); + padding: 5px 6px 5px 8px; +} + +#sc-close-btn { + min-width: 22px; + min-height: 22px; + padding: 3px 6px; + border-radius: apply(radius-sm); + transition: background-color apply(transition-fast) ease, + color apply(transition-fast) ease; +} + +#sc-close-btn:hover { + background-color: @glass-error; + color: @on-error; + padding: 3px 6px; +} + +#sc-close-label { + font-size: apply(font-xs); + font-weight: 700; + color: inherit; +} + + +#sc-tool-bar { + margin: 0 2px; +} + +#sc-tool-btn { + min-width: 34px; + min-height: 28px; + padding: 3px 5px; + border-radius: apply(radius-sm); + background-color: transparent; + transition: background-color apply(transition-fast) ease; +} + +#sc-tool-btn:hover { + background-color: @glass-hover; +} + +#sc-tool-btn.active { + background-color: @glass-active; +} + + +#sc-separator { + min-width: 1px; + min-height: 18px; + background-color: @glass-divider; + margin: 5px 8px; +} + + +#sc-options-btn { + padding: 5px 10px; + border-radius: apply(radius-sm); + background-color: transparent; + margin: 0 4px 0 6px; + transition: background-color apply(transition-fast) ease; +} + +#sc-options-btn:hover { + background-color: @glass-hover; +} + +#sc-options-label { + font-size: apply(font-sm); + font-weight: 500; + color: @foreground; +} + +#sc-options-arrow { + font-size: apply(font-xs); + color: @glass-text-secondary; + margin-left: 2px; +} + + +#sc-action-btn { + padding: 5px 20px; + border-radius: apply(radius-full); + background-color: @glass-accent; + color: @on-primary; + font-size: apply(font-sm); + font-weight: 600; + margin-left: 2px; + transition: background-color apply(transition-fast) ease, + box-shadow apply(transition-fast) ease; +} + +/* GTK3 doesn't reliably inherit into child Label with all:unset — force it */ +#sc-action-btn label { + color: @on-primary; + font-size: apply(font-sm); + font-weight: 600; +} + +#sc-action-btn:hover { + background-color: alpha(@glass-accent, 0.8); +} + +#sc-action-btn.recording { + background-color: @glass-error; + color: @on-error; +} + +#sc-action-btn.recording label { + color: @on-error; +} + +#sc-action-btn.recording:hover { + background-color: alpha(@glass-error, 0.8); +} + + +#sc-menu { + background-color: @glass-menu; + border: 1px solid @glass-border; + border-radius: apply(radius-md); + padding: 4px 0; +} + +#sc-menu-item { + padding: 6px 16px; + border-radius: apply(radius-xs); + font-size: apply(font-sm); + transition: background-color apply(transition-fast) ease; +} + +#sc-menu-item:hover { + background-color: @glass-hover; +} + +#sc-menu-section { + font-size: apply(font-xs); + font-weight: 700; + color: @glass-text-secondary; + padding: 8px 16px 2px 16px; + letter-spacing: 0.5px; +} + +/* Dim hint on the right of submenu parent items (Timer / Microphone) */ +.sc-menu-hint { + font-size: apply(font-xs); + color: @glass-text-secondary; + margin-left: 12px; +} + +#sc-menu-sep { + background-color: @glass-divider; + min-height: 1px; + margin: 4px 10px; +} + + +.sc-switch { + background-color: @glass-border; + border-radius: apply(radius-full); + transition: background-color apply(transition-slow) ease; +} + +.sc-switch.checked { + background-color: @glass-accent; + color: @on-primary; +} diff --git a/src/styles/window/settings.css b/src/styles/window/settings.css new file mode 100644 index 00000000..13215f1d --- /dev/null +++ b/src/styles/window/settings.css @@ -0,0 +1,157 @@ +#settings-window { + background-color: @glass-card; + border-radius: apply(radius-md); + border: 1px solid @glass-border; + min-width: 850px; + min-height: 600px; +} + +#settings-main-box { + padding: 0; +} + +#settings-sidebar { + background-color: @glass-surface-2; + padding: 20px 10px; + min-width: 200px; + border-right: 1px solid @glass-divider; +} + +#settings-sidebar-button { + padding: 10px 15px; + border-radius: apply(radius-sm); + margin-bottom: 2px; +} + +#settings-sidebar-button:hover { + background-color: @glass-accent; + color: @on-primary; +} + +#settings-sidebar-button Label { + font-size: apply(font-base); + font-weight: 500; +} + +#settings-sidebar-button.active { + background-color: @glass-accent; +} + +#settings-sidebar-button.active Label { + color: @on-primary; +} + +#settings-sidebar-button:hover *, +#settings-sidebar-button.active * { + color: @on-primary; +} + +#settings-content-wrapper { + padding: 30px; +} + +#settings-page-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 20px; + color: @foreground; +} + +#settings-row { + padding: 12px 15px; + border-radius: apply(radius-sm); + background-color: @glass-hover; + margin-bottom: 2px; +} + +#settings-row:hover { + background-color: @glass-active; +} + +#settings-row-label { + font-size: 15px; + font-weight: 500; +} + +#settings-row-description { + font-size: apply(font-sm); + color: @glass-text-secondary; +} + +#settings-switch { + min-width: 40px; + min-height: 22px; + background-color: @glass-border; + border-radius: apply(radius-full); + padding: 3px; + transition: background-color apply(transition-slow) ease; +} + +#settings-switch.active { + background-color: @glass-accent; + color: @on-primary; +} + +#settings-entry { + background-color: @glass-surface-1; + padding: 8px 12px; + border-radius: apply(radius-xs); + border: 1px solid @glass-border; + min-width: 200px; + color: @foreground; +} + +#settings-entry:focus { + border-color: @glass-ring; +} + +#settings-combo { + background-color: @glass-surface-1; + padding: 4px 8px; + border-radius: apply(radius-xs); + border: 1px solid @glass-border; + min-width: 150px; + color: @foreground; +} + +#settings-combo:hover { + background-color: @glass-active; +} + +#settings-list-tags { + margin-bottom: 5px; +} + +#settings-list-tag { + background-color: @glass-hover; + border-radius: apply(radius-md); + padding: 4px 10px; + margin-right: 5px; + margin-bottom: 5px; +} + +#settings-list-tag:hover { + background-color: @glass-active; +} + +#settings-list-tag Label { + font-size: apply(font-md); +} + +#settings-list-tag-close { + color: @glass-text-secondary; + font-size: apply(font-2xs); + margin-left: 3px; +} + +#settings-list-tag-close:hover { + color: @glass-accent; +} + +#settings-list-entry { + background-color: @glass-hover; + border: 1px dashed @glass-border; + padding: 6px 12px; + border-radius: apply(radius-xs); + font-size: apply(font-md); +} diff --git a/src/styles/window/spotlight.css b/src/styles/window/spotlight.css new file mode 100644 index 00000000..87dd781a --- /dev/null +++ b/src/styles/window/spotlight.css @@ -0,0 +1,149 @@ +#spotlight { + margin: 8px; + padding: 5px; + border-radius: apply(radius-lg); + background-color: @glass-popup; + border: 1px solid @glass-border; +} + +.spotlight-scroll-clip { + border-radius: apply(radius-md); +} + +.app-search-entry { + color: @foreground; +} + +.app-search-header { + padding: 8px; + border: solid 1px transparent; + border-radius: apply(radius-md); + background-color: @glass-hover; + transition: background-color apply(transition-medium) ease-in-out; +} + +.app-slot { + padding: 8px; + border: solid 1px transparent; + border-radius: apply(radius-md); + background-color: @glass-hover; + transition: border apply(transition-fast) ease-in-out, + background-color apply(transition-medium) ease-in-out; +} + +.app-slot:hover { + border-color: @glass-border; + background-color: @glass-active; +} + +.app-slot:focus, +.app-slot.selected { + border-color: @glass-ring; + background-color: @glass-selected; +} + +.app-title { + color: @foreground; + font-size: apply(font-base); + font-weight: 600; +} + +.app-description { + color: @glass-text-secondary; + font-size: apply(font-sm); +} + +.calc-expression { + color: @glass-text-secondary; + font-size: apply(font-base); +} + +.calc-result-text { + color: @foreground; + font-size: apply(font-lg); + font-weight: bold; +} + +.power-menu-name { + color: @foreground; + font-size: apply(font-lg); + font-weight: 600; +} + +.power-menu-description { + color: @glass-text-secondary; + font-size: apply(font-sm); +} + +.app-slot.wallpaper>box { + min-height: 4rem; + transition: all 0.2s cubic-bezier(0.2, 1, 0.8, 1); + background-size: cover; +} + +.app-slot.wallpaper:focus > box, +.app-slot.wallpaper.selected > box { + min-height: 12rem; +} + +.emoji-slot { + min-height: 3rem; +} + +.emoji-char { + font-size: 24px; + min-width: 2rem; +} + +.emoji-name { + color: var(--foreground); + font-size: 14px; + font-weight: normal; +} + +.emoji-no-results { + min-height: 3rem; +} + +.emoji-no-results-text { + color: darker(var(--foreground)); + font-size: 14px; + font-style: italic; +} + +.clipboard-no-results { + min-height: 3rem; +} + +.clipboard-no-results-text { + color: darker(var(--foreground)); + font-size: 14px; + font-style: italic; +} + +.clipboard-image { + min-height: 4rem; + transition: all 0.2s cubic-bezier(0.2, 1, 0.8, 1); + background-size: cover; +} + +.clipboard-image:focus, +.clipboard-image.selected { + min-height: 12rem; +} + +.clipboard-text { + min-height: 3rem; +} + +.clipboard-icon { + font-size: 20px; + min-width: 2rem; +} + +.clipboard-content { + color: var(--foreground); + font-size: 14px; + font-weight: normal; + margin-left: 1rem; +} diff --git a/src/styles/window/switcher.css b/src/styles/window/switcher.css new file mode 100644 index 00000000..9d96b599 --- /dev/null +++ b/src/styles/window/switcher.css @@ -0,0 +1,51 @@ +#app-switcher-container { + background-color: @glass-popup; + border-radius: apply(radius-xl); + padding: 16px 24px; + border: 1px solid @glass-border; +} + +#app-switcher-view { + padding: 0px; +} + +#app-switcher-button { + background-color: transparent; + border-radius: apply(radius-lg); + padding: 8px 10px; + transition: all apply(transition-fast) ease-out; +} + +#app-switcher-button.hovered { + background-color: @glass-hover; +} + +#app-switcher-button.selected { + background-color: @glass-selected; +} + +#app-switcher-icon-box { + border-radius: apply(radius-md); + padding: 4px; +} + +#app-switcher-item-label { + color: @foreground; + font-size: 15px; + font-weight: 600; + margin-top: -2px; +} + +#app-switcher-workspace-badge { + background-color: rgba(0, 0, 0, 0.7); + border-radius: 4px; + padding: 0px 4px; + margin-bottom: 2px; + margin-right: 2px; +} + +#app-switcher-workspace-badge-text { + color: white; + font-size: 11px; + font-weight: 700; +} diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 00000000..6301b5d7 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,4 @@ +""" +Modus utilities package. +Contains helper functions and utility modules for the shell. +""" diff --git a/utils/app_name_resolver.py b/src/utils/app_name_resolver.py similarity index 51% rename from utils/app_name_resolver.py rename to src/utils/app_name_resolver.py index 13b76dff..5057b042 100644 --- a/utils/app_name_resolver.py +++ b/src/utils/app_name_resolver.py @@ -1,59 +1,39 @@ import os -from utils.roam import modus_service class AppName: def __init__(self, path="/usr/share/applications"): self.files = os.listdir(path) self.path = path + self._cache: dict[str, str | None] = {} + + def get_app_name(self, wmclass, _format_=False): + if wmclass in self._cache: + return self._cache[wmclass] - def get_app_name(self, wmclass, format_=False): desktop_file = "" for f in self.files: if f.startswith(wmclass + ".desktop"): desktop_file = f - desktop_app_name = wmclass - if desktop_file == "": - return wmclass - with open(os.path.join(self.path, desktop_file), "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("Name="): - desktop_app_name = line.split("=")[1].strip() - break - return desktop_app_name - - def get_app_exec(self, wmclass, format_=False): - desktop_file = "" - for f in self.files: - if f.startswith(wmclass + ".desktop"): - desktop_file = f + self._cache[wmclass] = None + return None desktop_app_name = wmclass - - if desktop_file == "": - return wmclass - with open(os.path.join(self.path, desktop_file), "r") as f: + with open(os.path.join(self.path, desktop_file)) as f: lines = f.readlines() for line in lines: - if line.startswith("Exec="): + if line.startswith("Name="): desktop_app_name = line.split("=")[1].strip() break + self._cache[wmclass] = desktop_app_name return desktop_app_name - def get_desktop_file(self, wmclass): - desktop_file = "" - for f in self.files: - if f.startswith(wmclass + ".desktop"): - desktop_file = f - return desktop_file - def format_app_name(self, title, wmclass, update=False): # Handle case when both title and wmclass are empty (no active window) if not title and not wmclass: - name = "Finder" + name = "Modus" else: name = wmclass if name == "": @@ -61,7 +41,11 @@ def format_app_name(self, title, wmclass, update=False): # Try to get the proper app name from desktop file only if wmclass is not empty if wmclass: - name = self.get_app_name(wmclass=wmclass) + resolved = self.get_app_name(wmclass=wmclass) + if resolved is not None: + name = resolved + elif title: + name = title # Smart title formatting (capitalize first letter) name = str(name).title() @@ -69,6 +53,9 @@ def format_app_name(self, title, wmclass, update=False): name = name.split(".")[-1] if update: + from utils.roam import modus_service + + modus_service.current_active_wm_class = wmclass modus_service.current_active_app_name = name return name @@ -78,16 +65,11 @@ def format_app_name(self, title, wmclass, update=False): def format_window(title, wmclass): - # Handle the case when HyprlandActiveWindow passes "unknown" instead of empty strings - if (not title or title == "unknown") and (not wmclass or wmclass == "unknown"): - return "Finder" - - # Clean up "unknown" values + # Clean up "unknown" values to ensure they are treated as empty if title == "unknown": title = "" if wmclass == "unknown": wmclass = "" - - name = app_name_resolver.format_app_name(title, wmclass, True) - return name + # Always call format_app_name with update=True to keep service state in sync + return app_name_resolver.format_app_name(title, wmclass, True) diff --git a/src/utils/constants.py b/src/utils/constants.py new file mode 100644 index 00000000..a9521ad2 --- /dev/null +++ b/src/utils/constants.py @@ -0,0 +1,31 @@ +DEFAULT = { + "dock_enabled": True, + "dock_auto_hide": True, + "dock_always_occluded": False, + "dock_icon_size": 52, + "hide_special_workspace": True, + "dock_hide_special_workspace_apps": True, + "notification_timeout": "5s", + "notification_ignored_apps": ["Hyprshot"], + "notification_limited_apps_history": ["Spotify"], + "imac_button": True, + "systray": True, + "control_center": True, + "search": True, + "global_menu": True, + "network": True, + "battery": True, + "notification_center": True, + "workspace_indicator": True, + "bluetooth": True, + "date_time": True, + "night_light_temperature": 4500, + "custom_mods": True, + "keyboard_layouts": ["us", "np"], + "switcher_live_preview": True, + "switcher_live_preview_delay_ms": 200, + "systray_ignore": ["blueman", "network"], + "wallpapers_dir": "~/Pictures/Wallpapers", + "osd": True, + "window_switcher": True, +} diff --git a/src/utils/debounce.py b/src/utils/debounce.py new file mode 100644 index 00000000..98b8055a --- /dev/null +++ b/src/utils/debounce.py @@ -0,0 +1,70 @@ +import functools +import typing as t +from collections.abc import Callable + +from fabric.utils import GLib + + +def debounce(ms: int): + """Debounce a class method using GLib timers. + + Stores timer state on the instance (`self`), so this only works + as a method decorator. + """ + + def decorator(func: Callable): + timer_id_attr = f"_debounce_timer_{func.__name__}" + + def wrapper(self, *args, **kwargs): + if existing_timer := getattr(self, timer_id_attr, None): + GLib.source_remove(existing_timer) + + def timeout_cb(): + setattr(self, timer_id_attr, 0) + func(self, *args, **kwargs) + return False + + setattr(self, timer_id_attr, GLib.timeout_add(ms, timeout_cb)) + + return wrapper + + return decorator + + +def sync_debounce( + delay: int, min_n_times: int = 0, immediate: bool = False +) -> t.Callable[..., t.Any]: + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + n_times = 0 + timeout_id: int | None = None + last_args: tuple[t.Any, ...] = () + last_kwargs: dict[str, t.Any] = {} + + def call_func() -> bool: + nonlocal timeout_id, n_times + if n_times > min_n_times: + func(*last_args, **last_kwargs) + n_times = 0 + timeout_id = None + return False + + def wrapper(*args: t.Any, **kwargs: t.Any) -> None: + nonlocal timeout_id, last_args, last_kwargs, n_times + + last_args = args + last_kwargs = kwargs + + if timeout_id is not None: + GLib.source_remove(timeout_id) + + if not immediate or n_times < min_n_times: + n_times += 1 + timeout_id = GLib.timeout_add(delay, call_func) + else: + n_times = 0 + timeout_id = None + func(*last_args, **last_kwargs) + + return functools.wraps(func)(wrapper) + + return decorator diff --git a/src/utils/evdev_lock.py b/src/utils/evdev_lock.py new file mode 100644 index 00000000..74ed2ec8 --- /dev/null +++ b/src/utils/evdev_lock.py @@ -0,0 +1,90 @@ +"""Parameterised evdev LED-lock service base. + +NumLock and CapsLock services are structurally identical; only the LED +glob pattern, LED index, and service name differ. This module provides a +single base class that both services can inherit from. +""" + +from pathlib import Path + +from fabric.core.service import Property, Service, Signal +from fabric.utils import logger + +from utils.gtk_utils import EvdevLEDMonitor + + +class EvdevLockService(Service): + """Base service for keyboard lock-key monitoring via evdev LEDs. + + Subclasses must set **class-level** attributes: + + * ``_LED_GLOB`` – sysfs glob for the LED device + (e.g. ``"input*::numlock"``). + * ``_LED_INDEX`` – integer LED code passed to :class:`EvdevLEDMonitor` + (0 = NUML, 1 = CAPSL). + * ``_SERVICE_NAME`` – human-readable name for log messages. + """ + + _LED_GLOB: str = "" + _LED_INDEX: int = 0 + _SERVICE_NAME: str = "EvdevLock" + + _instance = None + + @Signal + def state_changed(self, is_on: bool) -> None: + """Emitted when the lock-key state changes.""" + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + leds = list(Path("/sys/class/leds").glob(self._LED_GLOB)) + self._led_path = leds[0] if leds else None + self._last_state = None + self._monitoring_started = False + + if self._led_path is None: + logger.warning(f"[{self._SERVICE_NAME}] Device not found, service disabled") + return + + self._monitor = EvdevLEDMonitor( + self._led_path, self._LED_INDEX, self._on_state_changed + ) + + def _on_state_changed(self, is_on: bool): + if is_on != self._last_state: + self._last_state = is_on + self.emit("state_changed", is_on) + + def stop(self): + if hasattr(self, "_monitor"): + self._monitor.stop() + + def _ensure_monitoring_started(self): + if not self._monitoring_started and self._led_path: + self._monitoring_started = True + self._last_state = self._get_current_state() + self._monitor._last_state = self._last_state + self._monitor.start() + + def _get_current_state(self) -> bool: + if not self._led_path: + return False + brightness_path = self._led_path / "brightness" + if brightness_path.exists(): + try: + return bool(int(brightness_path.read_text().strip())) + except (ValueError, OSError) as e: + logger.warning(f"[{self._SERVICE_NAME}] Failed to read brightness: {e}") + return False + return False + + @Property(bool, "read-write", default_value=False) + def is_on(self) -> bool: + self._ensure_monitoring_started() + return self._get_current_state() diff --git a/src/utils/functions.py b/src/utils/functions.py new file mode 100644 index 00000000..fe404a3c --- /dev/null +++ b/src/utils/functions.py @@ -0,0 +1,548 @@ +import ctypes +import html +import json +import os +import re +import subprocess +import threading +import time +from collections.abc import Callable +from functools import reduce +from typing import Dict, NamedTuple, Optional, TypeVar, cast + +from fabric.utils import ( + Gdk, + GdkPixbuf, + Gtk, + cairo, + exec_shell_command_async, + logger, +) +from fabric.widgets.box import Box + +T = TypeVar("T") + + +class Rectangle(NamedTuple): + x: float + y: float + width: float + height: float + + +def get_children_height_limit( + viewport: Box, + max_n_children: int, + transform_func: Callable[[Gtk.Widget], cairo.RectangleInt] | None = None, +) -> int: + spacing: int = viewport.get_spacing() + + children = viewport.children + children_len = len(viewport.children) + + if children_len < 1: + return 0 + + if children_len > max_n_children: + children_len = max_n_children + + # calculate the new height + # ( ) + ( ) + return (spacing * (children_len - 1)) + reduce( + lambda x, y: x + y, + ( + ( + transform_func(children[i]) + if transform_func + else cast( + "cairo.RectangleInt", + children[i].get_preferred_size().minimum_size, # type: ignore + ) + ).height # type: ignore + for i in range(children_len) + ), + ) + + +def set_process_name(name: str): + libc = ctypes.CDLL("libc.so.6") + libc.prctl(15, name.encode("utf-8"), 0, 0, 0) # 15 = PR_SET_NAME + + +_DURATION_RE = re.compile( + r"^\s*(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|hour|hours)?\s*$", + re.IGNORECASE, +) +_UNIT_SECS = { + "s": 1, + "sec": 1, + "secs": 1, + "m": 60, + "min": 60, + "mins": 60, + "h": 3600, + "hr": 3600, + "hrs": 3600, + "hour": 3600, + "hours": 3600, +} + + +def parse_timeout_string(timeout_str, in_seconds=False): + """ + Parse timeout string in format like '5s', '10m', '2hr', '1hour' etc. + + Args: + timeout_str: String to parse (e.g., "5s", "10m", "2hr", "1hour") + in_seconds: If True, return seconds; if False, return milliseconds + + Returns: + Parsed time value (milliseconds by default, seconds if in_seconds=True) + Returns 5000 (ms) or 5 (s) as default on parse failure. + """ + if not timeout_str or not isinstance(timeout_str, str): + return 5 if in_seconds else 5000 + + m = _DURATION_RE.match(timeout_str.strip()) + if not m: + logger.warning(f"[functions] Could not parse timeout string: {timeout_str}") + return 5 if in_seconds else 5000 + + value = int(m.group(1)) + unit = m.group(2) # None means bare number (seconds) + + if unit is None: + seconds = value + else: + seconds = value * _UNIT_SECS.get(unit.lower(), 1) + + return seconds if in_seconds else seconds * 1000 + + +# Threading helper functions +def thread(target, *args, **kwargs) -> threading.Thread: + """ + Simply run the given function in a thread. + The provided args and kwargs will be passed to the function. + """ + th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + th.start() + return th + + +def copy_text(text: str) -> bool: + try: + result = subprocess.run( + ["wl-copy"], + input=text.encode(), + timeout=5, + ) + return result.returncode == 0 + except FileNotFoundError: + try: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(text, -1) + clipboard.store() + return True + except Exception as e: + logger.warning( + f"[functions] clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) failed: {e}" + ) + return False + except Exception as e: + logger.warning(f"[functions] wl-copy failed: {e}") + return False + + +def copy_image(image_path: str) -> bool: + try: + with open(image_path, "rb") as f: + image_data = f.read() + result = subprocess.run( + ["wl-copy", "--type", "image/png"], + input=image_data, + timeout=5, + ) + return result.returncode == 0 + except FileNotFoundError: + try: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + image = GdkPixbuf.Pixbuf.new_from_file(image_path) + clipboard.set_image(image) + clipboard.store() + return True + except Exception as e: + logger.warning( + f"[functions] clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) failed: {e}" + ) + return False + except Exception as e: + logger.warning(f"[functions] wl-copy (image) failed: {e}") + return False + + +def read_json_file(file_path: str) -> dict | None: + if not os.path.exists(file_path): + logger.error(f"JSON file {file_path} does not exist.") + return None + + with open(file_path) as file: + try: + return json.load(file) + except json.JSONDecodeError as e: + logger.error(f"Failed to read JSON file {file_path}: {e}") + return None + + +def trigger_paste_shortcut(delay_ms: int = 50) -> bool: + time.sleep(delay_ms / 1000) + result = run_command(["wtype", "-M", "ctrl", "v", "-m", "ctrl"], timeout=1) + return result.returncode == 0 + + +def get_wifi_icon_for_strength(strength: int) -> str: + """ + Get the appropriate WiFi icon based on signal strength. + + Args: + strength: Signal strength from 0-100 + + Returns: + Relative path to the appropriate WiFi icon + """ + if strength >= 80: + icon_name = "network-wireless-100.svg" + elif strength >= 60: + icon_name = "network-wireless-80.svg" + elif strength >= 40: + icon_name = "network-wireless-60.svg" + elif strength >= 20: + icon_name = "network-wireless-40.svg" + elif strength > 0: + icon_name = "network-wireless-20.svg" + else: + icon_name = "network-wireless-0.svg" + + return f"wifi/{icon_name}" + + +def get_wifi_connecting_icon() -> str: + return "wifi/wifi-connecting.svg" + + +def is_special_workspace_id(ws_id) -> bool: + """ + Check if a workspace ID represents a special workspace. + + Args: + ws_id: Workspace ID (can be int, string, or other types) + + Returns: + True if the workspace is special, False otherwise + """ + try: + # Convert to int if it's a string + workspace_id = int(ws_id) + # Special workspaces have negative IDs + return workspace_id < 0 + except (ValueError, TypeError) as e: + # If it's a string, check if it starts with "special:" + logger.warning(f"[functions] workspace_id = int(ws_id) failed: {e}") + return bool(isinstance(ws_id, str) and ws_id.startswith("special:")) + + +def is_special_workspace(client: dict) -> bool: + """ + Check if a client is in a special workspace. + + Args: + client: Client data dictionary from Hyprland + + Returns: + True if the client is in a special workspace, False otherwise + """ + if "workspace" not in client: + return False + + workspace = client["workspace"] + + # Check workspace name first + if "name" in workspace: + workspace_name = str(workspace["name"]) + # Special workspaces typically start with "special:" or have negative IDs + if workspace_name.startswith("special:"): + return True + + # Check workspace ID + if "id" in workspace: + workspace_id = workspace["id"] + # Special workspaces have negative IDs + if workspace_id < 0: + return True + + return False + + +def escape_markup_text(text: str) -> str: + import re + + clean = re.sub(r"<[^>]+>", "", text) + return html.escape(clean.replace("\n", " ")) + + +# Process management + + +def spawn_detached(args: list[str]) -> subprocess.Popen: + return subprocess.Popen( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +def kill_process(process_name: str): + exec_shell_command_async(f"pkill {process_name}", lambda *_: None) + + +def is_process_running(process_name: str) -> bool: + try: + result = subprocess.run(["pidof", process_name], capture_output=True, text=True) + return bool(result.stdout.strip()) + except Exception as e: + logger.warning( + f"[functions] result = subprocess.run(['pidof', process_name], capture_... failed: {e}" + ) + return False + + +def is_app_running(app_name: str) -> bool: + return is_process_running(app_name) + + +def find_process_pid(process_name: str, timeout: float | None = None) -> list[str]: + try: + result = subprocess.run( + ["pgrep", "-f", process_name], + capture_output=True, + text=True, + timeout=timeout, + ) + return [pid for pid in result.stdout.strip().split() if pid] + except Exception as e: + logger.warning( + f"[functions] result = subprocess.run( ['pgrep', '-f', process_name], c... failed: {e}" + ) + return [] + + +# Binary lookup +def find_binary(name: str) -> str | None: + try: + result = subprocess.run(["which", name], capture_output=True, text=True) + return result.stdout.strip() or None + except Exception as e: + logger.warning( + f"[functions] result = subprocess.run(['which', name], capture_output=T... failed: {e}" + ) + return None + + +# General utilities +def format_duration(seconds: int) -> str: + """ + Convert a duration in seconds to a compact human-friendly string. + + Examples: + 0 -> "N/A" + 59 -> "0m" + 61 -> "1m" + 3600 -> "1h 0m" + """ + try: + total = int(seconds) + except (TypeError, ValueError) as e: + logger.warning(f"[functions] total = int(seconds) failed: {e}") + return "N/A" + + if total <= 0: + return "N/A" + + hours = total // 3600 + minutes = (total % 3600) // 60 + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + +def clear_children(container) -> None: + """ + Destroy all children of a GTK container-like widget which exposes get_children(). + """ + try: + for child in list(container.get_children()): + child.destroy() + except Exception as e: + logger.warning(f"clear_children failed: {e}") + + +class CommandResult(NamedTuple): + returncode: int + stdout: bytes | str + stderr: bytes | str + + +def run_command( + args: list[str], + timeout: float | None = None, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + *, + input: bytes | str | None = None, + text: bool = True, +) -> CommandResult: + try: + result = subprocess.run( + args, + capture_output=True, + text=text, + timeout=timeout, + cwd=cwd, + env=env, + input=input, + ) + return CommandResult(result.returncode, result.stdout, result.stderr) + except subprocess.TimeoutExpired as e: + logger.warning( + f"[functions] result = subprocess.run( args, capture_output=True, text=... failed: {e}" + ) + return CommandResult( + -1, + (e.stdout or b"" if not text else e.stdout or ""), + (e.stderr or (b"timeout" if not text else "timeout")), + ) + except FileNotFoundError as e: + logger.warning( + f"[functions] result = subprocess.run( args, capture_output=True, text=... failed: {e}" + ) + return CommandResult(127, "", str(e)) + except Exception as e: + logger.warning( + f"[functions] result = subprocess.run( args, capture_output=True, text=... failed: {e}" + ) + return CommandResult(1, "", str(e)) + + +def resolve_monitor(target: str) -> str | None: + """Resolve the ``"active"`` capture target to a concrete monitor name. + + Returns ``None`` for non-active targets (the capture backend handles + region/window/output directly). + """ + if target != "active": + return None + from services.modus import get_active_monitor_name + + return get_active_monitor_name() + + +def fuzzy_score(query: str, text: str) -> int: + """ + Fuzzy match scoring. Returns score > 0 if all query chars found in order. + Heavily rewards prefix matches, word-boundary matches, and consecutive runs. + Returns 0 if no match. + """ + if not query or not text: + return 0 + + query = query.lower() + text = text.lower() + + # Exact match + if query == text: + return 10000 + + # Prefix match + if text.startswith(query): + return 5000 + (100 - len(text)) + + # Check all query chars exist in order + qi = 0 + for char in text: + if qi < len(query) and char == query[qi]: + qi += 1 + if qi < len(query): + return 0 + + # Find best alignment — try each possible start position in text + best_score = 0 + + for start in range(len(text)): + if text[start] != query[0]: + continue + + qi = 0 + score = 0 + prev = start - 1 + + for ti in range(start, len(text)): + if qi >= len(query): + break + if text[ti] == query[qi]: + # Word boundary bonus (huge) + if ( + prev < 0 + or text[prev] == " " + or text[prev] == "-" + or text[prev] == "_" + ): + score += 50 + + # Consecutive bonus + if prev == ti - 1: + score += 20 + + # Exact position in query bonus + score += 5 + + prev = ti + qi += 1 + + if qi < len(query): + continue + + # How much of the text was consumed (tighter = better) + span = prev - start + 1 + score += max(0, 200 - span * 3) + + # Prefer shorter texts + score += max(0, 100 - len(text)) + + # Prefer matches at start + score += max(0, 50 - start) + + if score > best_score: + best_score = score + + return best_score + + +def fuzzy_filter(query: str, items: list, key=None, limit: int = 50) -> list: + """ + Filter and rank items by fuzzy match score. + Returns top `limit` items sorted by score (best first). + """ + if not query: + return items[:limit] + + scored = [] + for item in items: + text = key(item) if key else str(item) + score = fuzzy_score(query, text) + if score > 0: + scored.append((score, item)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [item for _, item in scored[:limit]] diff --git a/src/utils/google_lens_cli.py b/src/utils/google_lens_cli.py new file mode 100644 index 00000000..e4ab86cd --- /dev/null +++ b/src/utils/google_lens_cli.py @@ -0,0 +1,4 @@ +from services.google_lens import GoogleLens + +if __name__ == "__main__": + GoogleLens.get_initial().search() diff --git a/src/utils/gtk_utils.py b/src/utils/gtk_utils.py new file mode 100644 index 00000000..04311c08 --- /dev/null +++ b/src/utils/gtk_utils.py @@ -0,0 +1,195 @@ +import os +import struct +from pathlib import Path +from typing import Literal + +from fabric.utils import ( + Gdk, + GdkPixbuf, + GLib, + bulk_connect, + exec_shell_command_async, + logger, +) +from fabric.widgets.svg import Svg + + +# Function to set up cursor hover +def setup_cursor_hover( + widget, cursor_name: Literal["pointer", "crosshair", "grab"] = "pointer" +): + display = Gdk.Display.get_default() + cursor = Gdk.Cursor.new_from_name(display, cursor_name) + + def on_enter_notify_event(widget, _): + widget.get_window().set_cursor(cursor) + + def on_leave_notify_event(widget, _): + # Restore default cursor when leaving the widget's area + widget.get_window().set_cursor(None) + + bulk_connect( + widget, + { + "enter-notify-event": on_enter_notify_event, + "leave-notify-event": on_leave_notify_event, + }, + ) + + +# Base path to config directory +BASE_CONFIG_PATH = (Path(__file__).resolve().parent.parent.parent / "config").resolve() + +# Base path to SVG assets +BASE_SVG_PATH = (Path(__file__).resolve().parent.parent / "assets" / "icons").resolve() + + +def svg_file(relative_path: str, **kwargs) -> Svg: + """ + Return a fresh Svg widget for a given relative path. + + Avoids reusing the same widget instance across multiple containers, which + can cause GTK warnings. Example: + svg_file("misc/logo.svg", size=16) + """ + + def _resolve_path(path_like): + s = str(path_like) + marker = "/src/assets/icons/" + if marker in s: + rel = s.split(marker, 1)[1] + return (BASE_SVG_PATH / rel).resolve() + p = Path(s) + if p.is_absolute(): + return p + return (BASE_SVG_PATH / p).resolve() + + full_path = _resolve_path(relative_path) + svg = Svg(svg_file=str(full_path), **kwargs) + + original_set_from_file = svg.set_from_file + + def _set_from_file_resolved(file_path): + return original_set_from_file(str(_resolve_path(file_path))) + + svg.set_from_file = _set_from_file_resolved # type: ignore[attr-defined] + + # Provide a convenience alias for dynamic updates + def _dynamic_file(file_path): + _set_from_file_resolved(file_path) + return svg + + svg.dynamic_file = _dynamic_file # type: ignore[attr-defined] + + return svg + + +def toml_file(relative_path: str) -> str: + """Return absolute path to a TOML file in config/.""" + return str(BASE_CONFIG_PATH / relative_path) + + +def generate_colors_from_wallpaper(image_path: str) -> bool: + cmd = f'matugen image "{image_path}" --source-color-index 0' + return bool(exec_shell_command_async(cmd)) + + +class EvdevLEDMonitor: + """Helper to monitor EV_LED state changes without polling.""" + + def __init__(self, led_path: Path, target_code: int, on_state_changed_cb): + self.led_path = led_path + self.target_code = target_code + self.on_state_changed_cb = on_state_changed_cb + self._fd = None + self._watch_id = None + self._last_state = None + + def _get_evdev_path(self) -> Path | None: + if not self.led_path: + return None + try: + device_path = self.led_path / "device" + for child in device_path.iterdir(): + if child.name.startswith("event"): + return Path("/dev/input") / child.name + except Exception as e: + logger.warning( + f"[utils] device_path = self.led_path / 'device' failed: {e}" + ) + return None + + def start(self): + evdev_path = self._get_evdev_path() + if not evdev_path or not evdev_path.exists(): + logger.warning(f"Evdev path not found for {self.led_path}") + return + + try: + self._fd = os.open(evdev_path, os.O_RDONLY | os.O_NONBLOCK) + self._watch_id = GLib.io_add_watch( + self._fd, + GLib.PRIORITY_DEFAULT, + GLib.IOCondition.IN, + self._on_evdev_data, + ) + except PermissionError: + logger.warning( + f"Permission denied to read {evdev_path}. Add user to 'input' group." + ) + except Exception as e: + logger.error(f"Failed to start evdev monitoring: {e}") + + def _on_evdev_data(self, fd, condition): + try: + event_size = struct.calcsize("llHHi") + while True: + try: + data = os.read(fd, event_size) + except BlockingIOError as e: + logger.warning( + f"[utils] data = os.read(fd, event_size) failed: {e}" + ) + break + + if len(data) == event_size: + _tv_sec, _tv_usec, type_, code, value = struct.unpack("llHHi", data) + if type_ == 17 and code == self.target_code: + current_state = bool(value) + if current_state != self._last_state: + self._last_state = current_state + self.on_state_changed_cb(current_state) + except Exception as e: + logger.error(f"Evdev error: {e}") + self.stop() + return False + return True + + def stop(self): + if self._watch_id is not None: + GLib.source_remove(self._watch_id) + self._watch_id = None + if self._fd is not None: + try: + os.close(self._fd) + except OSError as e: + logger.warning(f"[utils] os.close(self._fd) failed: {e}") + self._fd = None + + +def load_pixbuf_thumbnail( + path: str, width: int, height: int, *, fallback: str | None = None +) -> GdkPixbuf.Pixbuf | None: + """Load a pixbuf scaled to fit within *width* x *height*. + + Returns ``None`` on failure (file missing, corrupt, etc.). Pass a + *fallback* path to try that on failure too. + """ + for p in (path, fallback): + if p is None: + continue + try: + return GdkPixbuf.Pixbuf.new_from_file_at_scale(p, width, height, True) + except Exception as e: + logger.warning(f"[gtk_utils] Failed to load pixbuf from {p}: {e}") + return None diff --git a/src/utils/icon_resolver.py b/src/utils/icon_resolver.py new file mode 100644 index 00000000..977b17ec --- /dev/null +++ b/src/utils/icon_resolver.py @@ -0,0 +1,172 @@ +import json + +from fabric.utils import GLib, Gtk, logger, os + +import shared.data as data + +ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json" +ICON_DICT_MAX = 500 +if not os.path.exists(data.CACHE_DIR): + os.makedirs(data.CACHE_DIR) + + +class IconResolver: + def __init__( + self, default_application_icon: str = "application-x-executable-symbolic" + ): + from utils.functions import read_json_file + + if os.path.exists(ICON_CACHE_FILE): + self._icon_dict = read_json_file(ICON_CACHE_FILE) or {} + else: + self._icon_dict = {} + + self._icon_dict_order: list[str] = [] + self.default_application_icon = default_application_icon + + def get_icon_name(self, app_id: str): + if app_id in self._icon_dict: + return self._icon_dict[app_id] + new_icon = self._compositor_find_icon(app_id) + logger.info( + f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..." + ) + self._store_new_icon(app_id, new_icon) + return new_icon + + def get_icon_pixbuf(self, app_id: str, size: int = 16): + icon_theme = Gtk.IconTheme.get_default() + icon_name = self.get_icon_name(app_id) + try: + # Try to load the resolved icon. + return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE) + except GLib.Error as primary_error: + logger.warning( + f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}" + ) + try: + # Fallback to the default application icon. + return icon_theme.load_icon( + self.default_application_icon, size, Gtk.IconLookupFlags.FORCE_SIZE + ) + except GLib.Error as fallback_error: + logger.error( + f"Error: Fallback icon '{self.default_application_icon}' also not found. Error: {fallback_error}" + ) + return None + + def _store_new_icon(self, app_id: str, icon: str): + self._icon_dict[app_id] = icon + self._icon_dict_order.append(app_id) + while len(self._icon_dict) > ICON_DICT_MAX: + oldest = self._icon_dict_order.pop(0) + self._icon_dict.pop(oldest, None) + with open(ICON_CACHE_FILE, "w") as f: + json.dump(self._icon_dict, f) + + def _get_icon_from_desktop_file(self, desktop_file_path: str): + # Retrieve the icon specified in the [Desktop Entry] section. + try: + with open(desktop_file_path) as f: + in_desktop_entry = False + for line in f: + line = line.strip() + if line == "[Desktop Entry]": + in_desktop_entry = True + elif line.startswith("[") and line.endswith("]"): + in_desktop_entry = False + + if in_desktop_entry and line.startswith("Icon="): + return line[5:].strip() + except Exception as e: + logger.error(f"[ICONS] Error reading desktop file {desktop_file_path}: {e}") + + return self.default_application_icon + + def _get_desktop_file(self, app_id: str) -> str | None: + if not app_id: + return None + + search_dirs = [os.path.join(GLib.get_user_data_dir(), "applications")] + [ + os.path.join(d, "applications") for d in GLib.get_system_data_dirs() + ] + + # Normalize search IDs (full ID and parts for Reverse DNS or decorated names) + search_ids = [app_id.lower()] + for sep in [".", "_", "-"]: + if sep in app_id: + parts = app_id.split(sep) + for part in parts: + if part and len(part) > 2 and part.lower() not in search_ids: + search_ids.append(part.lower()) + + # 1. Try exact filename matches first (highest priority) + for data_dir in search_dirs: + if not os.path.exists(data_dir): + continue + + files = os.listdir(data_dir) + for sid in search_ids: + target = f"{sid}.desktop" + for f in files: + if f.lower() == target: + return os.path.join(data_dir, f) + + # Try basename match without .desktop + for f in files: + basename = f[:-8] if f.lower().endswith(".desktop") else f + if basename.lower() == sid: + return os.path.join(data_dir, f) + + # 2. Try matching StartupWMClass or Name inside desktop files (more expensive) + for data_dir in search_dirs: + if not os.path.exists(data_dir): + continue + + for f in os.listdir(data_dir): + if not f.endswith(".desktop"): + continue + path = os.path.join(data_dir, f) + try: + with open(path, errors="ignore") as file: + for line in file: + line = line.strip() + if line.startswith("StartupWMClass="): + wm_class = line[15:].strip() + if wm_class.lower() == app_id.lower(): + return path + elif line.startswith("Name="): + name = line[5:].strip().lower() + if any(sid == name for sid in search_ids): + return path + except Exception as e: + logger.warning( + f"[icon_resolver] with open(path, errors='ignore') as file: for line in fil... failed: {e}" + ) + continue + + return None + + def _compositor_find_icon(self, app_id: str): + if not app_id: + return self.default_application_icon + + icon_theme = Gtk.IconTheme.get_default() + + # Try direct icon name match + if icon_theme.has_icon(app_id): + return app_id + + # Try with common suffixes + for suffix in ["-desktop", "-symbolic"]: + if icon_theme.has_icon(app_id + suffix): + return app_id + suffix + + # Try finding desktop file + desktop_file = self._get_desktop_file(app_id) + if desktop_file: + icon = self._get_icon_from_desktop_file(desktop_file) + if icon and icon_theme.has_icon(icon): + return icon + + return self.default_application_icon diff --git a/src/utils/roam.py b/src/utils/roam.py new file mode 100644 index 00000000..297036bd --- /dev/null +++ b/src/utils/roam.py @@ -0,0 +1,20 @@ +_services_mod = None + + +def _mod(): + global _services_mod + if _services_mod is None: + import services.modus as _services_mod + return _services_mod + + +def __getattr__(name): + if name in ( + "modus_service", + "notification_service", + "audio_service", + "screenshot_service", + "screen_recorder_service", + ): + return getattr(_mod(), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/utils/timer.py b/src/utils/timer.py new file mode 100644 index 00000000..347352c7 --- /dev/null +++ b/src/utils/timer.py @@ -0,0 +1,56 @@ +"""Managed GLib timer helper. + +Replaces the repeated schedule/cancel/cleanup pattern:: + + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = GLib.timeout_add(ms, self._on_timeout) + + # in destroy(): + if self._timer_id: + GLib.source_remove(self._timer_id) + +Usage:: + + self._hide_timer = ManagedTimer() + # schedule + self._hide_timer.schedule(DISPLAY_MS, self._do_hide) + # cancel + self._hide_timer.cancel() + # cleanup in destroy() + self._hide_timer.cancel() +""" + +from fabric.utils import GLib + + +class ManagedTimer: + """A single-shot GLib timer that auto-cancels any pending source.""" + + __slots__ = ("_source_id",) + + def __init__(self): + self._source_id: int | None = None + + def schedule(self, milliseconds: int, callback) -> None: + """Schedule *callback* after *milliseconds*. + + If a timer is already pending it is cancelled first. + """ + self.cancel() + self._source_id = GLib.timeout_add(milliseconds, self._on_fire, callback) + + def _on_fire(self, callback): + self._source_id = None + callback() + return False # one-shot + + def cancel(self) -> None: + """Cancel the pending timer, if any.""" + if self._source_id is not None: + GLib.source_remove(self._source_id) + self._source_id = None + + @property + def is_pending(self) -> bool: + return self._source_id is not None diff --git a/src/window/__init__.py b/src/window/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/controlcenter/__init__.py b/src/window/controlcenter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py new file mode 100644 index 00000000..2b7735de --- /dev/null +++ b/src/window/controlcenter/bluetooth.py @@ -0,0 +1,797 @@ +from enum import Enum, auto + +from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.separator import Separator + +from services.bluetooth import BluetoothClient, BluetoothDevice +from shared.widgets.smooth_switch import SmoothSwitch +from shared.window.animated_scrollwindow import AnimatedScrollable +from utils.functions import spawn_detached + + +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. + + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) + + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = int((clamped // 10) * 10) + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + + +def get_battery_theme_icon(percentage: float, charging: bool = False) -> str: + level = round(percentage / 10.0) * 10 + if level <= 0: + return ( + "battery-empty-charging-symbolic" if charging else "battery-empty-symbolic" + ) + if level >= 100: + return "battery-full-charged-symbolic" if charging else "battery-full-symbolic" + return f"battery-level-{level:02d}{'-charging' if charging else ''}-symbolic" + + +def set_bluetooth_enabled_with_fallback(client, enabled: bool): + if enabled: + command = "rfkill unblock bluetooth" + else: + command = "rfkill block bluetooth" + + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + logger.warning(f"rfkill command output: {result.strip()}") + + try: + if hasattr(client, "set_enabled"): + client.set_enabled(enabled) + else: + client.enabled = enabled + except Exception as e: + logger.warning(f"Bluetooth set_enabled({enabled}) failed: {e}") + + +class BTState(Enum): + IDLE = auto() + CONNECTED = auto() + CONNECTING = auto() + FAILED = auto() + + +class BluetoothDeviceSlot(Box): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(h_expand=True, name="device-button", **kwargs) + self.device = device + self._destroyed = False + self._state = BTState.IDLE + self._signal_ids = [] + self._signal_ids.append(self.device.connect("changed", self.on_changed)) + self._signal_ids.append( + self.device.connect( + "notify::closed", + lambda *_: getattr(self.device, "closed", False) and self.destroy(), + ) + ) + + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.dimage = Image( + icon_name=self._get_icon_for_device(self.device), + pixel_size=20, + name="device-icon", + style_classes=" ".join(self.styles), + ) + + self.status_icon = Image( + icon_name="process-working-symbolic", + pixel_size=16, + name="device-icon", + ) + self.status_icon.set_visible(False) + + self.battery_icon = None + self.battery_label = None + if hasattr(device, "battery_percentage") and device.battery_percentage > 0: + self.battery_icon = Image( + icon_name=get_battery_theme_icon(device.battery_percentage, False), + pixel_size=16, + ) + self.battery_label = Label( + label=f"{device.battery_percentage:.0f}%", name="battery-label" + ) + + button_children = [ + self.dimage, + Label(label=device.name), + Box(h_expand=True), # Spacer + self.status_icon, + ] + if self.battery_icon and self.battery_label: + button_children.insert(-1, self.battery_icon) + button_children.insert(-1, self.battery_label) + + self.device_button = Button( + on_clicked=lambda *_: self.toggle_connecting(), + h_expand=True, + h_align="fill", + child=Box( + orientation="h", + h_expand=True, + spacing=8, + children=button_children, + ), + ) + self.children = [self.device_button] + + self.device_button.connect("enter-notify-event", self.on_button_enter) + self.device_button.connect("leave-notify-event", self.on_button_leave) + # Right-click for pair / trust / remove actions + self.device_button.connect("button-press-event", self._on_button_press) + + self._refresh_state() + self.device.emit("changed") + + def _get_icon_for_device(self, device) -> str: + return getattr(device, "type", None) or getattr( + device, "icon_name", "bluetooth" + ) + + def destroy(self): + """Clean up device signal connections to prevent memory leaks.""" + if self._destroyed: + return + self._destroyed = True + for sig_id in self._signal_ids: + try: + self.device.disconnect(sig_id) + except Exception as e: + logger.error(f"[Bluetooth] Failed to disconnect signal {sig_id}: {e}") + self._signal_ids.clear() + self.device = None + super().destroy() + + def on_button_enter(self, widget, event): + self.add_style_class("button-hovered") + + def on_button_leave(self, widget, event): + self.remove_style_class("button-hovered") + + def toggle_connecting(self): + if self._destroyed: + return + if self._state == BTState.CONNECTED: + self.device.connecting = False + self._set_state(BTState.IDLE) + elif self._state != BTState.CONNECTING: + self._set_state(BTState.CONNECTING) + self.device.connecting = True + self.device.emit("changed") + + def on_changed(self, *_): + if self._destroyed or self.device is None: + return + + if getattr(self.device, "connected", False): + self._set_state(BTState.CONNECTED) + elif self._state == BTState.CONNECTING: + self._set_state(BTState.FAILED) + else: + self._set_state(BTState.IDLE) + + try: + new_styles = [ + "connected" if getattr(self.device, "connected", False) else "", + "paired" if getattr(self.device, "paired", False) else "", + ] + self.styles = new_styles + self.dimage.set_property("style-classes", " ".join(self.styles)) + except Exception as e: + logger.error( + f"[Bluetooth] Failed to update styles for {self.device.name}: {e}" + ) + + if ( + hasattr(self.device, "battery_percentage") + and self.device.battery_percentage > 0 + ): + if self.battery_icon and self.battery_label: + self.battery_icon.set_visible(True) + self.battery_label.set_visible(True) + self.battery_icon.set_property( + "icon-name", + get_battery_theme_icon(self.device.battery_percentage, False), + ) + self.battery_label.set_label(f"{self.device.battery_percentage:.0f}%") + else: + self.battery_icon = Image( + icon_name=get_battery_theme_icon( + self.device.battery_percentage, False + ), + pixel_size=16, + ) + self.battery_label = Label( + label=f"{self.device.battery_percentage:.0f}%", + name="battery-label", + ) + btn_box = self.device_button.get_child() + if btn_box: + btn_box.children = [ + *btn_box.children[:-1], + self.battery_icon, + self.battery_label, + btn_box.children[-1], + ] + elif self.battery_icon or self.battery_label: + if self.battery_icon: + self.battery_icon.set_visible(False) + if self.battery_label: + self.battery_label.set_visible(False) + + def _refresh_state(self): + self._set_state( + BTState.CONNECTED + if getattr(self.device, "connected", False) + else BTState.IDLE + ) + + def _set_state(self, state: BTState): + if self._destroyed: + return + self._state = state + for cls in ["active", "connecting", "failed"]: + self.remove_style_class(cls) + + if state == BTState.CONNECTED: + self.add_style_class("active") + self.status_icon.set_visible(False) + elif state == BTState.CONNECTING: + self.add_style_class("connecting") + self.status_icon.set_visible(False) + elif state == BTState.FAILED: + self.add_style_class("failed") + self.status_icon.set_visible(False) + GLib.timeout_add(5000, self._reset_from_failed) + else: + self.status_icon.set_visible(False) + + def _reset_from_failed(self): + if not self._destroyed: + self._set_state(BTState.IDLE) + return False + + def _on_button_press(self, widget, event): + """Right-click opens a context menu with pair/trust/remove actions.""" + if self._destroyed or self.device is None: + return False + if event.button != 3: # only right-click + return False + + menu = Gtk.Menu() + + if not self.device.paired: + item_pair = Gtk.MenuItem(label="Pair") + item_pair.connect("activate", lambda *_: self.device.pair()) + menu.append(item_pair) + else: + if self.device.trusted: + item_trust = Gtk.MenuItem(label="Untrust") + item_trust.connect("activate", lambda *_: self.device.untrust()) + else: + item_trust = Gtk.MenuItem(label="Trust") + item_trust.connect("activate", lambda *_: self.device.trust()) + menu.append(item_trust) + + item_remove = Gtk.MenuItem(label="Remove / Forget") + item_remove.connect( + "activate", + lambda *_: self._remove_device(), + ) + menu.append(item_remove) + + menu.show_all() + menu.popup_at_pointer(event) + return True + + def _remove_device(self): + """Ask the client to remove (unpair/forget) this device.""" + if self._destroyed or self.device is None: + return + try: + # Walk up to find a BluetoothConnections parent that holds the client + parent = self.get_parent() + while parent is not None: + if isinstance(parent, BluetoothConnections): + parent.client.remove_device(self.device) + return + parent = parent.get_parent() + # Fallback: use device.remove() which sets a flag + self.device.remove() + except Exception as e: + logger.warning(f"[Bluetooth] Failed to remove device: {e}") + + +class BluetoothConnections(Box): + def __init__( + self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs + ): + super().__init__( + spacing=8, + orientation="vertical", + name="bluetooth-connections", + **kwargs, + ) + + self.parent = parent + self.show_hidden_devices = show_hidden_devices + self.is_scanning = False # Track scanning state + self._update_in_progress = False # Prevent concurrent updates + self._destroyed = False # Track if widget is destroyed + self._client_signal_ids = [] # Track BluetoothClient signal IDs + + self.client = BluetoothClient() + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="bluetooth-refresh-indicator", + label="↓ Pull to scan for devices", + h_align="center", + visible=False, + style="color: #fff; font-size: 12px; padding: 5px;", + ) + + title_children = [] + if show_back_button: + title_children.append( + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_bluetooth(), + ) + ) + title_children.append(Label("Bluetooth", name="bluetooth-title")) + + self.title = Box( + orientation="h", + children=title_children, + ) + + self._switch_lock = False + + self.toggle_button = SmoothSwitch( + name="toggle-button", + active=self.client.enabled, + on_user_toggle=self.handle_user_toggle, + ) + + self._client_signal_ids.append( + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + ) + self._client_signal_ids.append( + self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) + ) + + self._client_signal_ids.append( + self.client.connect("device-added", self.update_devices) + ) + self._client_signal_ids.append( + self.client.connect("device-removed", self.update_devices) + ) + self._client_signal_ids.append( + self.client.connect("notify::connected-devices", self.update_devices) + ) + + self._client_signal_ids.append( + self.client.connect("changed", self.on_client_changed) + ) + + # Create Devices section + self.paired_devices_label = Label( + label="Devices", h_align="start", name="networks-title" + ) + self.paired_devices = Box( + spacing=4, orientation="vertical", name="known-networks" + ) + self.paired_devices_scrolled = AnimatedScrollable( + min_content_size=(303, 0), + max_content_size=(303, 200), + child=self.paired_devices, + overlay_scroll=True, + animate=False, + ) + + self.no_devices_label = Label( + label="No devices available", + h_align="center", + name="no-networks-label", + visible=False, + ) + + self.other_devices_button = Button( + child=CenterBox( + start_children=Label("Other Devices", h_align="start"), + end_children=self.refresh_indicator, + ), + name="wifi-other-button", + on_clicked=self.toggle_other_devices, + ) + self.other_devices = Box(spacing=4, orientation="vertical") + + self.other_devices_scrolled = AnimatedScrollable( + min_content_size=(303, 0), + max_content_size=(303, 300), + child=self.other_devices, + overlay_scroll=True, + animate=False, + ) + self.other_devices.set_visible(False) + + # Add pull-to-refresh functionality to scrolled window + self.setup_pull_to_refresh() + + self.more_settings_button = Button( + child=Label("More Settings", h_align="start"), + name="wifi-other-button", + on_clicked=self.open_bluetooth_settings, + ) + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="bluetooth-widget-top", + ), + Separator(orientation="h", name="separator"), + self.paired_devices_label, + self.paired_devices_scrolled, + self.no_devices_label, + Separator(orientation="h", name="separator"), + self.other_devices_button, + self.other_devices_scrolled, + Separator(orientation="h", name="separator"), + self.more_settings_button, + ] + + self.connect("destroy", self.on_destroy) + self.connect("unmap", self.on_hide) + + self.client.notify("scanning") + self.client.notify("enabled") + self.update_devices() + + def handle_user_toggle(self, active: bool): + self._switch_lock = True + set_bluetooth_enabled_with_fallback(self.client, active) + GLib.timeout_add(1500, self._unlock_switch) + + def _unlock_switch(self): + self._switch_lock = False + if not self._destroyed and getattr(self, "toggle_button", None): + self.toggle_button.set_active(self.client.enabled) + return False + + def on_hide(self, *_): + """Called when the widget is hidden (popup closed)""" + self._cancel_pending_refresh() + if self.other_devices.get_visible(): + self.other_devices.set_visible(False) + self.other_devices_scrolled.snap_to_size(0) + if ( + self.client + and self.client.scanning + and hasattr(self.client, "stop_scan") + ): + self.client.stop_scan() + + self.update_scan_label() + + def toggle_other_devices(self, *_): + """Toggle the visibility of other devices section""" + current_state = self.other_devices.get_visible() + self._cancel_pending_refresh() + if current_state: + self.other_devices.set_visible(False) + self.other_devices_scrolled.snap_to_size(0) + self.update_scan_label() + if ( + self.client + and self.client.scanning + and hasattr(self.client, "stop_scan") + ): + self.client.stop_scan() + else: + self.other_devices.set_visible(True) + self.update_scan_label() + if ( + self.client + and not self.client.scanning + and hasattr(self.client, "scan") + ): + self.client.scan() + # Defer refresh until after the height animation completes + GLib.idle_add(self._refresh_after_animation) + + def _cancel_pending_refresh(self): + if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler: + try: + self.other_devices_scrolled.height_animator.disconnect( + self._anim_finished_handler + ) + except Exception as e: + logger.warning( + f"[bluetooth] self.other_devices_scrolled.height_animator.disconnect( s... failed: {e}" + ) + self._anim_finished_handler = None + + def _refresh_after_animation(self): + if self._destroyed: + return False + anim = self.other_devices_scrolled.height_animator + if anim.playing: + self._anim_finished_handler = anim.connect( + "finished", + lambda *_: self.force_device_refresh() if not self._destroyed else None, + ) + else: + self.force_device_refresh() + return False + + def open_bluetooth_settings(self, *_): + """Open Blueman bluetooth manager""" + try: + spawn_detached(["blueman-manager"]) + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + except FileNotFoundError as e: + logger.warning( + f"[bluetooth] spawn_detached(['blueman-manager']) failed: {e}" + ) + except Exception as e: + logger.error(f"[Bluetooth] Failed to open bluetooth settings: {e}") + + def update_scan_label(self): + """Update scanning state appearance""" + if self.client and self.client.scanning and self.other_devices.get_visible(): + self.refresh_indicator.set_label("Scanning for devices...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") + else: + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") + + def update_devices(self, *_): + """Update the list of available devices""" + if self._update_in_progress or self._destroyed or not self.client: + return + + self._update_in_progress = True + + try: + current_paired_addresses = { + child.device.address + for child in self.paired_devices.get_children() + if hasattr(child, "device") + } + current_other_addresses = { + child.device.address + for child in self.other_devices.get_children() + if hasattr(child, "device") + } + + devices = self.client.devices + paired_devices = [] + other_devices = [] + new_paired_addresses = set() + new_other_addresses = set() + + for device in devices: + try: + if device.name and device.name != "Unknown": + if device.paired: + paired_devices.append(device) + new_paired_addresses.add(device.address) + else: + other_devices.append(device) + new_other_addresses.add(device.address) + except Exception as e: + logger.warning( + f"[Bluetooth] Failed to process device {device}: {e}" + ) + continue + + paired_changed = current_paired_addresses != new_paired_addresses + other_changed = current_other_addresses != new_other_addresses + + if paired_changed or other_changed: + existing_paired = { + child.device.address: child + for child in self.paired_devices.get_children() + if hasattr(child, "device") + } + existing_other = { + child.device.address: child + for child in self.other_devices.get_children() + if hasattr(child, "device") + } + + for device in paired_devices: + if not self._destroyed: + if device.address in existing_paired: + slot = existing_paired.pop(device.address) + # update existing + slot.device = device + elif device.address in existing_other: + slot = existing_other.pop(device.address) + slot.device = device + self.other_devices.remove(slot) + self.paired_devices.add(slot) + else: + device_slot = BluetoothDeviceSlot(device) + self.paired_devices.add(device_slot) + + for device in other_devices: + if not self._destroyed: + if device.address in existing_other: + slot = existing_other.pop(device.address) + slot.device = device + elif device.address in existing_paired: + slot = existing_paired.pop(device.address) + slot.device = device + self.paired_devices.remove(slot) + self.other_devices.add(slot) + else: + device_slot = BluetoothDeviceSlot(device) + self.other_devices.add(device_slot) + + if not self._destroyed: + has_paired_devices = len(paired_devices) > 0 + has_other_devices = len(other_devices) > 0 + has_any_devices = has_paired_devices or has_other_devices + + self.paired_devices_scrolled.set_visible(True) + self.no_devices_label.set_visible(not has_any_devices) + self.other_devices_button.set_visible(True) # Always visible + + except Exception as e: + logger.error(f"[Bluetooth] Error during update_devices: {e}") + finally: + self._update_in_progress = False + + def force_device_refresh(self): + """Force an immediate refresh of the device list""" + if self._update_in_progress or self._destroyed: + return + + try: + self.update_devices() + except Exception as e: + logger.error(f"[Bluetooth] Error during forced device refresh: {e}") + + def on_client_changed(self, *_): + """Handle when the bluetooth client state changes""" + if getattr(self, "toggle_button", None) and not self._switch_lock: + self.toggle_button.set_active(self.client.enabled) + + self.update_scan_label() + self.update_devices() + + def on_destroy(self, widget): + """Cleanup when widget is destroyed""" + self._destroyed = True + self._cancel_pending_refresh() + for sig_id in self._client_signal_ids: + try: + self.client.disconnect(sig_id) + except Exception as e: + logger.error( + f"[Bluetooth] Failed to disconnect client signal {sig_id}: {e}" + ) + self._client_signal_ids.clear() + + def close_bluetooth(self): + """Called when Bluetooth panel is being closed""" + if self.other_devices.get_visible(): + self.other_devices.set_visible(False) + self.other_devices_scrolled.snap_to_size(0) + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + self.vadjustment = self.other_devices_scrolled.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Connect to scroll events + self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) + self.other_devices_scrolled.connect("button-press-event", self.on_button_press) + self.other_devices_scrolled.connect( + "button-release-event", self.on_button_release + ) + self.other_devices_scrolled.connect( + "motion-notify-event", self.on_motion_notify + ) + + # Enable events + self.other_devices_scrolled.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + if not self.client.scanning and hasattr(self.client, "scan"): + self.client.scan() + self.force_device_refresh() + return True + return False + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + if not self.client.scanning and hasattr(self.client, "scan"): + self.client.scan() + self.force_device_refresh() + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + if self.client.scanning: + self.refresh_indicator.set_label("↑ Release to stop scanning") + else: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + if self.client.scanning: + self.refresh_indicator.set_label("↓ Pull to stop scanning") + else: + self.refresh_indicator.set_label("↓ Pull to scan for devices") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def on_device_added(self, client: BluetoothClient, address: str): + """Handle when a new device is added""" + self.update_devices() diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py new file mode 100644 index 00000000..4b77671f --- /dev/null +++ b/src/window/controlcenter/expanded_player.py @@ -0,0 +1,806 @@ +import weakref + +from fabric.utils import bulk_connect, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.scale import Scale +from fabric.widgets.stack import Stack +from fabric.widgets.wayland import WaylandWindow as Window + +from services.mpris import PlayerManager, PlayerService +from utils.gtk_utils import svg_file +from window.controlcenter.player import ( + PLAYER_FALLBACK_ART, + apply_player_art, + get_shared_mpris_manager, +) + + +class EmbeddedExpandedPlayer(Box): + def __init__(self, control_center, **kwargs): + super().__init__( + orientation="vertical", + h_expand=True, + name="embedded-expanded-player", + **kwargs, + ) + self.control_center = control_center + self.mpris_manager = get_shared_mpris_manager() + self.player_content = PlayerBoxStack(self.mpris_manager) + self.children = [self.player_content] + + def refresh(self): + pass + + def suspend(self): + for child in self.player_content.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + + def resume(self): + for child in self.player_content.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + if hasattr(self, "player_content") and hasattr(self.player_content, "destroy"): + self.player_content.destroy() + super().destroy() + + +class PlayerBoxStack(Box): + def __init__(self, mpris_manager: PlayerManager, **kwargs): + self.player_stack = Stack(name="player-stack", transition_type="none") + self.current_stack_pos = 0 + self.player_buttons: list[Button] = [] + self._player_widgets = weakref.WeakValueDictionary() + self._signal_connections = [] + self.no_media_box = self._create_no_media_box() + + super().__init__(orientation="v", name="media", children=[self.player_stack]) + self.player_stack.children = [self.no_media_box] + self.set_visible(True) + self.mpris_manager = mpris_manager + self.connect("map", self._on_map) + + connections = bulk_connect( + self.mpris_manager, + {"new-player": self.on_new_player, "player-vanish": self.on_lost_player}, + ) + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + for name, player in self.mpris_manager.get_all_services().items(): + self.on_new_player(self.mpris_manager, name, player) + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + self._signal_connections.clear() + super().destroy() + + def _create_no_media_box(self): + fallback_cover_path = PLAYER_FALLBACK_ART + album_cover = Box(style_classes="album-image-c") + album_cover.set_style(f"background-image:url('{fallback_cover_path}')") + + track_title = Label( + label="No media playing", + name="player-title-no", + justification="left", + max_chars_width=30, + ellipsization="end", + h_align="start", + ) + track_info = Box( + name="track-info", + spacing=5, + orientation="v", + v_align="start", + h_align="start", + children=[track_title], + ) + + return Box( + h_align="center", + name="player-box", + h_expand=True, + children=[ + Overlay( + child=Box(name="outer-player-box", h_align="start"), + overlays=[ + Box(name="inner-player-box", v_align="center", h_align="start"), + Box( + name="player-info-box", + v_align="center", + h_align="start", + orientation="v", + h_expand=True, + children=[track_info], + ), + Box( + h_align="start", + v_align="center", + name="player-image-stack", + children=[album_cover], + ), + ], + ) + ], + ) + + def _find_playing_player_index(self): + players = self.player_stack.get_children() + for i, player_box in enumerate(players): + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + return i + return None + + def _check_and_update_playing_state(self): + players = [ + p for p in self.player_stack.get_children() if p != self.no_media_box + ] + if len(players) > 0: + if self.no_media_box in self.player_stack.get_children(): + self.player_stack.remove(self.no_media_box) + playing_index = self._find_playing_player_index() + if playing_index is not None: + self.on_player_clicked_by_index(playing_index) + return + + if ( + len(self.player_stack.get_children()) == 1 + and self.player_stack.get_children()[0] == self.no_media_box + ): + return + + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_stack.set_visible_child(self.no_media_box) + + def on_player_playback_changed(self, player_box, status): + status = str(status).lower() + if status == "playing": + players = self.player_stack.get_children() + for i, pb in enumerate(players): + if pb == player_box and i != self.current_stack_pos: + self.on_player_clicked_by_index(i) + break + elif status in ["paused", "stopped"]: + self._check_and_update_playing_state() + + def on_player_clicked_by_index(self, index): + if 0 <= index < len(self.player_buttons): + if self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + self.current_stack_pos = index + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos] + ) + self._update_all_player_buttons() + + def on_new_player(self, mpris_manager, name, player): + if ( + len(self.player_stack.get_children()) == 1 + and self.player_stack.get_children()[0] == self.no_media_box + ): + self.player_stack.children = [] + self.current_stack_pos = 0 + + self.set_visible(True) + new_player_box = PlayerBox(player=player, player_stack=self) + self.player_stack.children = [*self.player_stack.children, new_player_box] + self.make_new_player_button(new_player_box) + if self.player_buttons: + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + self._check_and_update_playing_state() + self._update_all_player_buttons() + + def on_lost_player(self, mpris_manager, player_name): + player_box_to_remove = None + for player_box in self.player_stack.get_children(): + if ( + hasattr(player_box, "player") + and player_box.player.player_name == player_name + ): + player_box_to_remove = player_box + break + + if player_box_to_remove: + player_box_to_remove.destroy() + + remaining_players = [ + p for p in self.player_stack.get_children() if p != player_box_to_remove + ] + if len(remaining_players) == 0: + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_buttons = [] + return + + if self.current_stack_pos >= len(self.player_stack.get_children()): + self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) + + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos] + ) + + self._update_all_player_buttons() + + def make_new_player_button(self, player_box): + new_button = Button(name="player-stack-button") + + def on_player_button_click(button: Button): + if self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + if button in self.player_buttons: + self.current_stack_pos = self.player_buttons.index(button) + button.add_style_class("active") + self.player_stack.set_visible_child(player_box) + + new_button.connect("clicked", on_player_button_click) + self.player_buttons.append(new_button) + player_box.connect( + "destroy", + lambda *_: ( + self.player_buttons.remove(new_button) + if new_button in self.player_buttons + else None + ), + ) + + def _update_all_player_buttons(self): + players = self.player_stack.get_children() + for player_box in players: + if hasattr(player_box, "update_buttons"): + player_box.update_buttons(self.player_buttons, len(players) > 1) + + +class PlayerBox(Box): + def __init__(self, player: PlayerService, player_stack=None, **kwargs): + super().__init__(h_align="center", name="player-box", h_expand=True, **kwargs) + self.player = player + self.player_stack = player_stack + self.fallback_cover_path = PLAYER_FALLBACK_ART + self.cover_path = self.fallback_cover_path + self.exit = False + self._user_seeking = False + self._signal_connections = [] + self._property_bindings = [] + self._seekbar_signal_ids = [] + self._cached_duration = 0 + + self.album_cover = Box(style_classes="album-image-c") + self.album_cover.set_style( + f"background-image:url('{self.fallback_cover_path}')" + ) + self.album_cover.set_size_request(70, 70) + + self.image = Overlay( + child=Box( + h_align="start", + v_align="center", + name="player-image-stack", + children=[self.album_cover], + ), + overlays=[ + Box( + children=Image( + icon_name=self.player.player_name, + name="player-app-icon", + icon_size=20, + ), + h_align="end", + v_align="end", + ) + ], + ) + + self.seek_bar = Scale( + value=0, + min_value=0, + max_value=100, + increments=(1, 1), + name="expanded-seek-bar", + size=1, + h_expand=True, + ) + self._seekbar_signal_ids.append( + self.seek_bar.connect("value-changed", self._on_scale_value_changed) + ) + self._seekbar_signal_ids.append( + self.seek_bar.connect("change-value", self._on_change_value) + ) + self._property_bindings.append( + self.player.bind_property("can_seek", self.seek_bar, "sensitive") + ) + + self.position_label = Label( + label="0:00", + name="macos-position-label", + justification="left", + h_align="start", + ) + self.length_label = Label( + label="0:00", + name="macos-length-label", + justification="right", + h_align="end", + ) + self.seek_box = Box( + name="macos-seek-box", + orientation="v", + spacing=2, + children=[ + self.seek_bar, + Box( + name="macos-labels-box", + orientation="h", + children=[ + self.position_label, + Box(h_expand=True), + self.length_label, + ], + ), + ], + ) + + self.track_title = Label( + label=self.player.title or "No Title", + name="macos-player-title", + justification="left", + max_chars_width=24, + ellipsization="end", + h_align="start", + h_expand=True, + ) + self.track_artist = Label( + label=", ".join(self.player.artist) if self.player.artist else "No Artist", + name="macos-player-artist", + justification="left", + max_chars_width=24, + ellipsization="end", + h_align="start", + h_expand=True, + visible=True, + ) + self.track_album = Label( + label=self.player.album or "No Album", + name="macos-player-album", + justification="left", + max_chars_width=24, + ellipsization="end", + h_align="start", + visible=True, + ) + + self.track_info = Box( + name="macos-track-info", + spacing=4, + orientation="v", + v_align="start", + h_align="fill", + h_expand=True, + v_expand=True, + children=[self.track_title, self.track_artist, self.track_album], + ) + + self.stack_buttons_box = Box( + h_expand=False, + v_expand=True, + name="macos-stack-buttons-box", + spacing=4, + orientation="h", + h_align="center", + v_align="end", + ) + self.stack_buttons_box.hide() + + initial_status = str(self.player.playback_status).lower() + icon_file = ( + "player/play.svg" if initial_status == "paused" else "player/pause.svg" + ) + self.play_pause_icon = svg_file(icon_file, size=22) + self.play_pause_button = Button( + style_classes=["control-buttons"], + name="macos-play-button", + child=self.play_pause_icon, + on_clicked=self.player.play_pause, + ) + self.next_button = Button( + style_classes=["control-buttons"], + name="macos-control-button", + child=svg_file("player/fwd.svg", size=22), + on_clicked=self._on_player_next, + ) + self.prev_button = Button( + name="macos-control-button", + child=svg_file("player/rewind.svg", size=22), + style_classes=["control-buttons"], + on_clicked=self._on_player_prev, + ) + + self.button_box = Box( + name="macos-button-box", + h_align="center", + h_expand=True, + spacing=10, + children=[self.prev_button, self.play_pause_button, self.next_button], + ) + self.controls_box = Box( + name="macos-player-controls", + orientation="v", + h_expand=True, + spacing=6, + h_align="center", + children=[self.button_box], + ) + + self.children = [ + Box( + name="macos-outer-player-box", + orientation="v", + spacing=10, + h_expand=True, + v_expand=True, + v_align="center", + h_align="fill", + children=[ + Box( + orientation="v", + h_expand=False, + h_align="center", + children=[ + Box( + orientation="horizontal", + children=[self.image, self.track_info], + ), + self.seek_box, + self.controls_box, + ], + ), + self.stack_buttons_box, + ], + ) + ] + self.get_children()[0].set_size_request(352, -1) + + connections = bulk_connect( + self.player, + { + "play": self._on_playback_change, + "pause": self._on_playback_change, + "meta-change": self._on_metadata, + "track-position": self._on_track_position, + "artwork-change": self._on_artwork_change, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.player, handler_id)) + + self.seek_bar.connect("realize", self._on_seek_bar_realized) + if self.seek_bar.get_realized(): + self._on_seek_bar_realized(self.seek_bar) + + result = apply_player_art(self.album_cover, self.player) + if result: + self.cover_path = result + + self._load_initial_metadata() + + def _load_initial_metadata(self): + try: + metadata = self.player._player.props.metadata + if metadata: + keys = metadata.keys() + self.track_title.set_label( + metadata["xesam:title"] if "xesam:title" in keys else "No Title" + ) + self.track_artist.set_label( + ", ".join(metadata["xesam:artist"]) + if "xesam:artist" in keys and metadata["xesam:artist"] + else "No Artist" + ) + self.track_album.set_label( + metadata["xesam:album"] if "xesam:album" in keys else "No Album" + ) + self._update_duration_from_metadata() + self.position_label.set_label( + self.length_str(self.player.position or 0) + ) + except Exception as e: + logger.warning( + f"[expanded_player] metadata = self.player._player.props.metadata failed: {e}" + ) + + def update_buttons(self, player_buttons, show_buttons): + if show_buttons and len(player_buttons) > 1: + if len(self.stack_buttons_box.get_children()) != len(player_buttons): + self.stack_buttons_box.children = [] + for i, button in enumerate(player_buttons): + dot_button = Button( + name="macos-player-switcher-dot", + style_classes=["macos-switcher-dot"], + ) + dot_button.connect( + "clicked", + lambda *_, idx=i: self.player_stack.on_player_clicked_by_index( + idx + ), + ) + self.stack_buttons_box.children = [ + *self.stack_buttons_box.children, + dot_button, + ] + + for i, dot_button in enumerate(self.stack_buttons_box.get_children()): + if player_buttons[i].get_style_context().has_class("active"): + dot_button.add_style_class("active") + else: + dot_button.remove_style_class("active") + + self.stack_buttons_box.show_all() + else: + self.stack_buttons_box.hide() + + def length_str(self, length): + if length is None or length <= 0: + return "0:00" + length_seconds = length / 1000000 + hours = int(length_seconds // 3600) + minutes = int((length_seconds % 3600) // 60) + seconds = int(length_seconds % 60) + return ( + f"{hours}:{minutes:02d}:{seconds:02d}" + if hours > 0 + else f"{minutes}:{seconds:02d}" + ) + + def _on_metadata(self, *args): + if self.exit or self.player is None: + return + metadata = args[1] if len(args) >= 2 else None + if metadata is not None: + keys = metadata.keys() + self.track_title.set_label( + metadata["xesam:title"] if "xesam:title" in keys else "No Title" + ) + self.track_artist.set_label( + ", ".join(metadata["xesam:artist"]) + if "xesam:artist" in keys and metadata["xesam:artist"] + else "No Artist" + ) + self.track_album.set_label( + metadata["xesam:album"] if "xesam:album" in keys else "No Album" + ) + if "mpris:length" in keys: + duration = int(metadata["mpris:length"]) + self.length_label.set_label(self.length_str(duration)) + self.seek_bar.set_range(0, duration / 1_000_000) + self.position_label.set_label(self.length_str(self.player.position or 0)) + else: + self.track_title.set_label(self.player.title or "No Title") + self.track_artist.set_label( + ", ".join(self.player.artist) if self.player.artist else "No Artist" + ) + self.track_album.set_label(self.player.album or "No Album") + self.set_image() + + def suspend(self): + pass + + def resume(self): + pass + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + self.exit = True + self.suspend() + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + super().destroy() + + def _on_player_next(self, *_): + if self.player: + self.player.next() + + def _on_player_prev(self, *_): + if self.player: + self.player.previous() + + def refresh_icon(self): + """Poll-based icon refresh - works even when browser doesn't fire signals.""" + if self.player is None: + return + try: + status = str(self.player.playback_status).lower() + self.play_pause_icon.dynamic_file( + "player/play.svg" if status == "paused" else "player/pause.svg" + ) + except Exception as e: + logger.error(f"An error occurred: {e}") + + def _on_playback_change(self, service, *args): + if not self.exit: + current_status = str(self.player.playback_status).lower() + if getattr(self, "_last_notified_status", None) == current_status: + return + self._last_notified_status = current_status + + icon_file = ( + "player/play.svg" if current_status == "paused" else "player/pause.svg" + ) + if hasattr(self, "play_pause_icon"): + self.play_pause_icon.dynamic_file(icon_file) + else: + # Fallback if play_pause_icon isn't available + import os + + from utils.gtk_utils import get_relative_path + + self.play_pause_button.get_child().set_from_file( + os.path.join(get_relative_path("assets/icons/"), icon_file) + ) + + self.player_stack.on_player_playback_changed(self, current_status) + + def _on_artwork_change(self, service, local_path, *_): + if self.exit: + return + result = apply_player_art(self.album_cover, self.player, service, local_path) + if result: + self.cover_path = result + + def set_image(self, service=None, path=None, *_): + if self.exit or self.player is None: + return + result = apply_player_art(self.album_cover, self.player, service, path) + if result: + self.cover_path = result + + def _on_track_position(self, service, pos: float, dur: float): + if self.exit: + return + try: + position_micros = int(pos * 1_000_000) + duration_micros = int(dur * 1_000_000) + if duration_micros <= 0: + duration_micros = self.player.length or 0 + + if duration_micros > 0: + self._cached_duration = duration_micros + self.length_label.set_label(self.length_str(duration_micros)) + + self.position_label.set_label(self.length_str(position_micros)) + + if self.seek_bar.get_realized() and self.seek_bar.get_adjustment(): + adj = self.seek_bar.get_adjustment() + if duration_micros > 0: + self.seek_bar.set_range(0, duration_micros / 1_000_000) + if not self._user_seeking and adj.get_upper() > 0: + self.seek_bar.set_value(position_micros / 1_000_000) + except Exception as e: + logger.error(f"[_on_track_position] Error: {e}") + + def _clear_seeking(self): + self._user_seeking = False + return False + + def _do_seek(self, value): + self._debounce_seek = None + if self.player and not self.exit: + try: + self.player.seek_position(value) + except Exception as e: + logger.warning( + f"[expanded_player] self.player.seek_position(value) failed: {e}" + ) + if hasattr(self, "_seek_timeout") and self._seek_timeout: + from fabric.utils import GLib + + GLib.source_remove(self._seek_timeout) + from fabric.utils import GLib + + self._seek_timeout = GLib.timeout_add(1000, self._clear_seeking) + return False + + def _on_change_value(self, scale, _scroll, value): + if not self.player or self.exit: + return False + self._user_seeking = True + value = max(0.0, value) + self.position_label.set_label(self.length_str(int(value * 1_000_000))) + + if hasattr(self, "_debounce_seek") and self._debounce_seek: + from fabric.utils import GLib + + GLib.source_remove(self._debounce_seek) + + from fabric.utils import GLib + + self._debounce_seek = GLib.timeout_add(100, self._do_seek, value) + return False + + def _update_duration_from_metadata(self): + try: + duration = self._cached_duration or self.player.length or 0 + if duration <= 0 and self.seek_bar.get_realized(): + adj = self.seek_bar.get_adjustment() + if adj.get_upper() > 1: + duration = int(adj.get_upper() * 1_000_000) + if duration > 0: + self.seek_bar.set_range(0, duration / 1_000_000) + self.length_label.set_label(self.length_str(duration)) + return True + except Exception as e: + logger.warning( + f"[expanded_player] duration = self._cached_duration or self.player.length or 0 failed: {e}" + ) + return False + + def _on_seek_bar_realized(self, widget): + try: + if not self.exit and self.player: + self._update_duration_from_metadata() + self.position_label.set_label( + self.length_str(self.player.position or 0) + ) + except Exception as e: + logger.error(f"An error occurred: {e}") + + def _on_scale_value_changed(self, scale: Scale): + if not self.player or self.exit: + return + try: + value = max(0.0, scale.get_value()) + self.position_label.set_label(self.length_str(int(value * 1_000_000))) + except Exception as e: + logger.error(f"An error occurred: {e}") + + +class ExpandedPlayer(Window): + def __init__(self, **kwargs): + super().__init__( + name="expanded-player", + title="modus", + anchor="top right", + layer="top", + exclusivity="auto", + child=PlayerBoxStack(get_shared_mpris_manager()), + visible=False, + ) + self.add_keybinding("Escape", lambda *_: self.set_visible(False)) + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + if hasattr(self, "child") and hasattr(self.child, "destroy"): + self.child.destroy() + super().destroy() + + def hide_controlcenter(self, *_): + self.set_visible(False) diff --git a/modules/controlcenter/main.py b/src/window/controlcenter/main.py similarity index 51% rename from modules/controlcenter/main.py rename to src/window/controlcenter/main.py index cfa00bcd..ef9ffa2f 100644 --- a/modules/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -1,45 +1,72 @@ +import os +import signal import subprocess -from fabric.utils import idle_add -from fabric.utils.helpers import ( - get_relative_path, -) +from fabric.utils import Gdk, GLib, idle_add, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox from fabric.widgets.label import Label -from fabric.widgets.scale import Scale -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib -from loguru import logger +from fabric.widgets.overlay import Overlay -from modules.controlcenter.bluetooth import ( - BluetoothConnections, - set_bluetooth_enabled_with_fallback, -) -from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer -from modules.controlcenter.nightlight import create_night_light_widget -from modules.controlcenter.per_app_volume import PerAppVolumeControl -from modules.controlcenter.player import PlayerBoxStack -from modules.controlcenter.wifi import WifiConnections from services.brightness import Brightness -from services.mpris import MprisPlayerManager from services.network import NetworkClient +from shared.widgets.flat_scale import FlatScale +from shared.window.applet_window import AppletWindow +from utils.gtk_utils import svg_file from utils.roam import audio_service, modus_service -from widgets.wayland import WaylandWindow as Window +from window.controlcenter.bluetooth import ( + BluetoothConnections, + set_bluetooth_enabled_with_fallback, +) +from window.controlcenter.expanded_player import EmbeddedExpandedPlayer +from window.controlcenter.nightlight import create_night_light_widget +from window.controlcenter.per_app_volume import PerAppVolumeControl +from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager +from window.controlcenter.wifi import WifiConnections -brightness_service = Brightness.get_initial() +_brightness_instance: "Brightness | None" = None -class ModusControlCenter(Window): - def __init__(self, **kwargs): +def get_brightness_service() -> "Brightness": + global _brightness_instance + if _brightness_instance is None: + _brightness_instance = Brightness() + return _brightness_instance + + +_CAFFEINE_PID_FILE = os.path.join( + os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "modus-caffeine.pid" +) +_CAFFEINE_INHIBIT_WHY = "Modus Caffeine" + + +def _caffeine_is_active() -> bool: + try: + with open(_CAFFEINE_PID_FILE) as f: + pid = int(f.read().strip()) + except (OSError, ValueError): + return False + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + data = f.read().decode(errors="ignore") + return "systemd-inhibit" in data and _CAFFEINE_INHIBIT_WHY in data + except OSError: + return False + + +class ModusControlCenter(AppletWindow): + def __init__(self, parent=None, pointing_to=None, **kwargs): super().__init__( + parent=parent, + pointing_to=pointing_to, layer="top", - title="modus", + title="modus-controlcenter", anchor="top right", margin="2px 10px 0px 0px", exclusivity="auto", - keyboard_mode="on-demand", + edge_margin=5, name="control-center-menu", visible=False, **kwargs, @@ -51,12 +78,10 @@ def __init__(self, **kwargs): # Flight mode and caffeine states self.flight_mode = False self.caffeine_mode = False - self._caffeine_process = None - - # Lazy loading flags - self._music_initialized = False + # Loading flags - music and expanded player are no longer lazy loaded self._per_app_volume_initialized = False - self._expanded_player_initialized = False + self._signals_connected = False # Track if signals are connected + self._resources_initialized = False # Track if resources are initialized # Store references for cleanup - initialize all as None self._signal_connections = [] @@ -65,33 +90,22 @@ def __init__(self, **kwargs): self._expanded_player_widget = None self._mpris_manager = None # Shared MPRIS manager instance - # Initialize network service for WiFi toggle - self.network_service = NetworkClient() + # Initialize network service for WiFi toggle - lazy load + self.network_service = None self.wifi_service = None - # Initialize flight mode and caffeine states - self._check_initial_states() - - # Wait for network service to be ready + # Initialize flight mode and caffeine states - lazy load + self.caffeine_mode = False + self.flight_mode = False + # Add keybinding immediately (this is fast) self.add_keybinding("Escape", self.hide_controlcenter) volume = 100 - wlan = modus_service.sc("wlan-changed", self.wlan_changed) - bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed) - music = modus_service.sc("music-changed", self.audio_changed) - - self.network_service.connect("wifi-device-added", self.on_network_ready) - # Store signal connections for cleanup - self._signal_connections.extend( - [ - audio_service.connect("changed", self.audio_changed), - audio_service.connect("changed", self.volume_changed), - modus_service.connect("dont-disturb-changed", self.dnd_changed), - ] - ) + # Get initial values and connect signals - store connection IDs for cleanup + wlan = modus_service.wlan if modus_service.wlan else "No Connection" + bluetooth = modus_service.bluetooth if modus_service.bluetooth else "Off" - print(wlan) self.wlan_label = Label( label=wlan, name="wifi-widget-label", @@ -115,74 +129,55 @@ def __init__(self, **kwargs): ellipsization="end", h_align="start", ) - self.volume_scale = Scale( + self.volume_scale = FlatScale( value=volume, min_value=0, max_value=100, - increments=(5, 5), + step=5, name="volume-widget-slider", size=30, h_expand=True, ) - self.volume_scale.connect("change-value", self.set_volume) - self.volume_scale.connect("scroll-event", self.on_volume_scroll) - current_brightness = brightness_service.screen_brightness + current_brightness = get_brightness_service().screen_brightness brightness_percentage = ( - int((current_brightness / brightness_service.max_screen) * 100) - if brightness_service.max_screen > 0 + int((current_brightness / get_brightness_service().max_screen) * 100) + if get_brightness_service().max_screen > 0 else 50 ) - self.brightness_scale = Scale( + self.brightness_scale = FlatScale( value=brightness_percentage, min_value=0, max_value=100, - increments=(5, 5), + step=5, name="brightness-widget-slider", size=30, h_expand=True, ) - # Only connect brightness controls if brightness service is available - if brightness_service.max_screen > 0: - self.brightness_scale.connect("change-value", self.set_brightness) - self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) - self._signal_connections.append( - brightness_service.connect("screen", self.brightness_changed) - ) - else: - # Disable brightness scale if no backlight device available + # Disable brightness scale if no backlight device is available + if get_brightness_service().max_screen <= 0: self.brightness_scale.set_sensitive(False) - # Create placeholder music widget - lazy load content when needed - self.music_widget = Box( - name="music-widget", - h_align="start", - children=[], # Empty initially - ) + self._mpris_manager = get_shared_mpris_manager() + self.music_widget = PlayerBoxStack(self._mpris_manager, control_center=self) self.has_bluetooth_open = False self.has_wifi_open = False self.has_per_app_volume_open = False self.has_expanded_player_open = False - self.bluetooth_svg = Svg( - name="bluetooth-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" + self.bluetooth_svg = svg_file( + ( + "applets/bluetooth.svg" if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" + else "applets/bluetooth-off.svg" ), size=42, ) - self.wifi_svg = Svg( - name="wifi-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ), + self.wifi_svg = svg_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg", size=42, ) @@ -244,13 +239,8 @@ def __init__(self, **kwargs): ], ) - self.focus_icon = Svg( - name="focus-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ), + self.focus_icon = svg_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg", size=42, ) @@ -287,13 +277,8 @@ def __init__(self, **kwargs): on_clicked=self.set_dont_disturb, ) - self.flight_icon = Svg( - name="flight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ), + self.flight_icon = svg_file( + "applets/flight-on.svg" if self.flight_mode else "applets/flight-off.svg", size=42, ) @@ -318,12 +303,11 @@ def __init__(self, **kwargs): on_clicked=self.toggle_flight_mode, ) - self.caffeine_icon = Svg( - name="caffeine-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" + self.caffeine_icon = svg_file( + ( + "applets/caffeine-on.svg" if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" + else "applets/caffeine-off.svg" ), size=42, ) @@ -350,16 +334,6 @@ def __init__(self, **kwargs): style_classes="title-widget", h_align="center", ), - # Box( - # orientation="vertical", - # v_expand=True, - # v_align="center", - # h_align="center", - # h_expand=True, - # children=[ - # # self.caffeine_status_label, - # ], - # ), ], ), on_clicked=self.toggle_caffeine, @@ -368,7 +342,7 @@ def __init__(self, **kwargs): # Create night light widget self.night_light_widget = create_night_light_widget(self) - # Create main widgets directly without XML + # Create main widgets directly without XML - defer heavy operations self.widgets = Box( orientation="vertical", h_expand=True, @@ -445,9 +419,18 @@ def __init__(self, **kwargs): h_expand=True, children=[ Label(label="Display", style_classes="title", h_align="start"), - self.brightness_scale, - Label( - label="󰖨 ", name="brightness-widget-icon", h_align="start" + Overlay( + h_expand=True, + child=self.brightness_scale, + overlays=[ + svg_file( + "brightness/brightness.svg", + name="brightness-widget-icon", + size=28, + h_align="start", + v_align="center", + ) + ], ), ], ), @@ -470,24 +453,33 @@ def __init__(self, **kwargs): v_align="center", v_expand=False, children=[ - self.volume_scale, + Overlay( + h_expand=True, + child=self.volume_scale, + overlays=[ + svg_file( + "volume/audio-volume.svg", + name="volume-widget-icon", + size=28, + h_align="start", + v_align="center", + ) + ], + ), ], ), Button( name="per-app-volume-button", size=(36, 36), - child=Svg( - svg_file=get_relative_path( - "../../config/assets/icons/player/audio-switcher.svg" - ), + child=svg_file( + "player/audio-switcher.svg", name="per-app-volume-icon", - sidze=32, + size=32, ), on_clicked=self.open_per_app_volume, ), ], ), - Label(label=" ", name="volume-widget-icon", h_align="start"), ], ), self.music_widget, @@ -501,18 +493,32 @@ def __init__(self, **kwargs): self.has_bluetooth_open = False self.has_wifi_open = False - # Lazy-loaded widgets - create placeholders + # Create expanded player widgets immediately (no lazy loading) + self._expanded_player_widget = EmbeddedExpandedPlayer(self) + self.expanded_player_widgets = Box( + orientation="vertical", + h_expand=True, + name="control-center-widgets", + children=[ + self._expanded_player_widget, + ], + ) + + # Lazy-loaded widgets - create placeholders for others self.bluetooth_widgets = None self.wifi_widgets = None self.per_app_volume_widgets = None - self.expanded_player_widgets = None # Create main content boxes self.center_box = CenterBox(start_children=[self.widgets]) self.bluetooth_center_box = None self.wifi_center_box = None self.per_app_volume_center_box = None - self.expanded_player_center_box = None + # Create expanded player center box immediately + self.expanded_player_center_box = CenterBox( + start_children=[self.expanded_player_widgets] + ) + self.expanded_player_center_box.set_size_request(300, -1) # Create revealers for crossfade transitions @@ -526,97 +532,169 @@ def __init__(self, **kwargs): # Connect to visibility changes for cleanup self.connect("notify::visible", self._on_visibility_changed) - def _on_visibility_changed(self, widget, param): - """Handle visibility changes for memory management""" + def _on_visibility_changed(self, widget, _param): + """Handle visibility changes for resource management""" if not self.get_visible(): - self._cleanup_when_hidden() + # Suspend player updates + if hasattr(self, "music_widget") and hasattr(self.music_widget, "suspend"): + self.music_widget.suspend() + if hasattr(self, "_expanded_player_widget") and hasattr( + self._expanded_player_widget, "suspend" + ): + self._expanded_player_widget.suspend() + + # Just disconnect signals and reset state flags - don't destroy widgets + self._disconnect_signals_when_hidden() + else: + self._initialize_resources() + + # Resume player updates + if hasattr(self, "music_widget") and hasattr(self.music_widget, "resume"): + self.music_widget.resume() + if hasattr(self, "_expanded_player_widget") and hasattr( + self._expanded_player_widget, "resume" + ): + self._expanded_player_widget.resume() + + def _initialize_resources(self): + """Initialize resources and connect signals when the control center becomes visible.""" + if self._resources_initialized: + return - def _cleanup_when_hidden(self): - """Aggressively clean up resources when widget is hidden to reduce memory usage""" try: - # Clean up music widget content if it exists - if self._music_widget_content: - # Remove from the parent container - current_children = list(self.music_widget.children) - if self._music_widget_content in current_children: - current_children.remove(self._music_widget_content) - self.music_widget.children = current_children - - # Trigger periodic cleanup before destroying - if hasattr(self._music_widget_content, "_periodic_cleanup"): - self._music_widget_content._periodic_cleanup() - - # Properly destroy the music widget content + logger.debug("Initializing control center resources...") + + # Initialize network service lazily + if self.network_service is None: + self.network_service = NetworkClient() + # Connect network signal + self.network_service.connect("device-ready", self.on_network_ready) + + # Check initial states lazily (only when needed) + self._check_initial_states() + + # Store signal connections as (obj, handler_id) tuples for proper cleanup + self._signal_connections.extend( + [ + ( + audio_service, + audio_service.connect("changed", self.audio_changed), + ), + ( + audio_service, + audio_service.connect("changed", self.volume_changed), + ), + ( + modus_service, + modus_service.connect("wlan-changed", self.wlan_changed), + ), + ( + modus_service, + modus_service.connect( + "bluetooth-changed", self.bluetooth_changed + ), + ), + ( + modus_service, + modus_service.connect("dont-disturb-changed", self.dnd_changed), + ), + ] + ) + + # Connect brightness controls if brightness service is available + if get_brightness_service().max_screen > 0: + self.brightness_scale.connect("value-changed", self.set_brightness) + self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) + self._signal_connections.append( + ( + get_brightness_service(), + get_brightness_service().connect( + "screen", self.brightness_changed + ), + ) + ) + + # Connect volume scale signals + self.volume_scale.connect("value-changed", self.set_volume) + self.volume_scale.connect("scroll-event", self.on_volume_scroll) + + # Mark signals as connected + self._signals_connected = True + self._resources_initialized = True + + logger.debug("Control center resources initialized successfully") + + except Exception as e: + logger.error(f"Failed to initialize control center resources: {e}") + # Reset flags on failure + self._signals_connected = False + self._resources_initialized = False + + def _disconnect_signals_when_hidden(self): + """Disconnect signals when hidden to reduce resource usage, but keep widgets intact""" + try: + # Actually disconnect the signals we tracked + for obj, handler_id in self._signal_connections: try: - self._music_widget_content.destroy() + obj.disconnect(handler_id) except Exception as e: - logger.warning(f"Failed to destroy music widget content: {e}") - self._music_widget_content = None + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() - # Clean up shared MPRIS manager when hidden to free memory - if self._mpris_manager: + # Disconnect direct scale signals (connected in _initialize_resources) + if hasattr(self, "volume_scale"): try: - self._mpris_manager.destroy() + self.volume_scale.disconnect_by_func(self.set_volume) + self.volume_scale.disconnect_by_func(self.on_volume_scroll) except Exception as e: - logger.warning( - f"Failed to destroy MPRIS manager during cleanup: {e}" - ) - self._mpris_manager = None + logger.error(f"An error occurred: {e}") + if hasattr(self, "brightness_scale"): + try: + self.brightness_scale.disconnect_by_func(self.set_brightness) + self.brightness_scale.disconnect_by_func(self.on_brightness_scroll) + except Exception as e: + logger.error(f"An error occurred: {e}") - # Reset initialization flags to force recreation next time - self._music_initialized = False + self._signals_connected = False + self._resources_initialized = False - # Force garbage collection - import gc + # Reset state flags + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False - gc.collect() + # Instantly reset the view + self._delayed_reset_view() - logger.debug("Control center aggressive cleanup completed") + logger.debug("Control center signals disconnected while hidden") except Exception as e: - logger.warning(f"Control center cleanup failed: {e}") - - def _ensure_music_widget(self): - """Lazy load music widget content - reuse MPRIS manager""" - if not self._music_initialized: - # Create shared MPRIS manager if it doesn't exist - if self._mpris_manager is None: - self._mpris_manager = MprisPlayerManager() + logger.warning(f"Control center signal disconnection failed: {e}") - self._music_widget_content = PlayerBoxStack( - self._mpris_manager, control_center=self - ) - # Add to the music widget's children list - current_children = list(self.music_widget.children) - current_children.append(self._music_widget_content) - self.music_widget.children = current_children - self._music_initialized = True + def _delayed_reset_view(self): + if not self.get_visible(): + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False + self.current_view = "main" + self.set_children(self.center_box) + return False def _ensure_bluetooth_widgets(self): """Lazy load bluetooth widgets""" if self.bluetooth_widgets is None: - self.bluetooth_widgets = Box( + inner_box = Box( orientation="vertical", h_expand=True, v_expand=True, - children=[ - self.bluetooth_man, - # Box( - # orientation="horizontal", - # name="top-widget", - # h_expand=True, - # children=[ - # Box( - # orientation="vertical", - # name="wb-widget", - # style_classes="menu", - # spacing=5, - # children=[ - # ], - # ), - # ], - # ), - ], + children=[self.bluetooth_man], + ) + self.bluetooth_widgets = EventBox( + events="button-press-mask", + on_button_press_event=lambda *_: True, + child=inner_box, ) self.bluetooth_center_box = Box( h_expand=True, v_expand=True, children=[self.bluetooth_widgets] @@ -626,7 +704,7 @@ def _ensure_bluetooth_widgets(self): def _ensure_wifi_widgets(self): """Lazy load wifi widgets""" if self.wifi_widgets is None: - self.wifi_widgets = Box( + inner_box = Box( orientation="vertical", h_expand=True, v_expand=True, @@ -634,6 +712,11 @@ def _ensure_wifi_widgets(self): self.wifi_man, ], ) + self.wifi_widgets = EventBox( + events="button-press-mask", + on_button_press_event=lambda *_: True, + child=inner_box, + ) self.wifi_center_box = Box( h_expand=True, v_expand=True, children=[self.wifi_widgets] ) @@ -658,53 +741,21 @@ def _ensure_per_app_volume_widgets(self): ) self.per_app_volume_center_box.set_size_request(300, -1) - def _ensure_expanded_player_widgets(self): - """Lazy load expanded player widgets""" - if self.expanded_player_widgets is None: - if self._expanded_player_widget is None: - self._expanded_player_widget = EmbeddedExpandedPlayer(self) - - self.expanded_player_widgets = Box( - orientation="vertical", - h_expand=True, - name="control-center-widgets", - children=[ - self._expanded_player_widget, - ], - ) - self.expanded_player_center_box = CenterBox( - start_children=[self.expanded_player_widgets] - ) - self.expanded_player_center_box.set_size_request(300, -1) - def _check_initial_states(self): - # Check if caffeine is already running - try: - result = subprocess.run( - ["pgrep", "-f", "modus-inhibit"], capture_output=True, text=True - ) - self.caffeine_mode = bool(result.stdout.strip()) - except Exception: - self.caffeine_mode = False - - # Flight mode starts as False (normal mode) + self.caffeine_mode = _caffeine_is_active() + self.caffeine_icon.dynamic_file( + "applets/caffeine-on.svg" + if self.caffeine_mode + else "applets/caffeine-off.svg" + ) + self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off") self.flight_mode = False - # Update initial labels (will be set after widgets are created) - GLib.timeout_add(100, self._update_initial_labels) - - def _update_initial_labels(self): - return False - def set_dont_disturb(self, *_): self.focus_mode = not self.focus_mode modus_service.dont_disturb = self.focus_mode - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" ) self.focus_status_label.set_label("On" if self.focus_mode else "Off") @@ -730,12 +781,10 @@ def toggle_flight_mode(self, *_): self.bluetooth_man.client.set_enabled(True) # Update icon - self.flight_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ) + self.flight_icon.dynamic_file( + "applets/flight-on.svg" + if self.flight_mode + else "applets/flight-off.svg" ) except Exception as e: @@ -743,55 +792,68 @@ def toggle_flight_mode(self, *_): def toggle_caffeine(self, *_): try: - if self.caffeine_mode: - # Turn off caffeine - inhibit_script = get_relative_path("../../utils/inhibit.py") - subprocess.run(["python3", inhibit_script, "off"], check=False) + if _caffeine_is_active(): + try: + with open(_CAFFEINE_PID_FILE) as f: + pid = int(f.read().strip()) + with open(f"/proc/{pid}/cmdline", "rb") as f: + data = f.read().decode(errors="ignore") + if "systemd-inhibit" in data and _CAFFEINE_INHIBIT_WHY in data: + os.kill(pid, signal.SIGTERM) + except (OSError, ValueError): + pass + try: + os.unlink(_CAFFEINE_PID_FILE) + except OSError: + pass self.caffeine_mode = False - if self._caffeine_process: - try: - self._caffeine_process.terminate() - except: - pass - self._caffeine_process = None else: - # Turn on caffeine - inhibit_script = get_relative_path("../../utils/inhibit.py") - self._caffeine_process = subprocess.Popen( - ["python3", inhibit_script, "on"], start_new_session=True - ) + cmd = [ + "systemd-inhibit", + "--what=idle:sleep", + f"--why={_CAFFEINE_INHIBIT_WHY}", + "--mode=block", + "sleep", + str(100_000_000), + ] + proc = subprocess.Popen(cmd) + try: + with open(_CAFFEINE_PID_FILE, "w") as f: + f.write(str(proc.pid)) + except OSError: + pass self.caffeine_mode = True - - self.caffeine_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" - if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" - ) + self.caffeine_icon.dynamic_file( + "applets/caffeine-on.svg" + if self.caffeine_mode + else "applets/caffeine-off.svg" ) self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off") - except Exception as e: logger.warning(f"Failed to toggle caffeine: {e}") - def set_volume(self, _, __, volume): + def set_volume(self, _, volume): + if not self._signals_connected: + return self._updating_volume = True audio_service.speaker.volume = round(volume) self._updating_volume = False - def set_brightness(self, _, __, brightness): + def set_brightness(self, _, brightness): + if not self._signals_connected: + return self._updating_brightness = True - brightness_value = int((brightness / 100) * brightness_service.max_screen) - brightness_service.screen_brightness = brightness_value + brightness_value = int((brightness / 100) * get_brightness_service().max_screen) + get_brightness_service().screen_brightness = brightness_value self._updating_brightness = False def brightness_changed(self, _, brightness_value): - if self._updating_brightness: + if not self._signals_connected or self._updating_brightness: return - if brightness_service.max_screen > 0: + if get_brightness_service().max_screen > 0: brightness_percentage = int( - (brightness_value / brightness_service.max_screen) * 100 + (brightness_value / get_brightness_service().max_screen) * 100 ) GLib.idle_add( @@ -799,6 +861,8 @@ def brightness_changed(self, _, brightness_value): ) def on_volume_scroll(self, widget, event): + if not self._signals_connected: + return False current_value = self.volume_scale.get_value() scroll_step = 5 if event.direction == Gdk.ScrollDirection.UP: @@ -812,6 +876,8 @@ def on_volume_scroll(self, widget, event): return True def on_brightness_scroll(self, widget, event): + if not self._signals_connected: + return False current_value = self.brightness_scale.get_value() scroll_step = 5 if event.direction == Gdk.ScrollDirection.UP: @@ -825,8 +891,9 @@ def on_brightness_scroll(self, widget, event): return True def toggle_bluetooth(self, *_): + if not self._resources_initialized: + return try: - # Access the bluetooth client from the bluetooth manager if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"): current_state = self.bluetooth_man.client.enabled set_bluetooth_enabled_with_fallback( @@ -838,32 +905,26 @@ def toggle_bluetooth(self, *_): logger.warning(f"Failed to toggle bluetooth: {e}") def on_network_ready(self, *_): - """Called when network service is ready""" self.wifi_service = self.network_service.wifi_device if self.wifi_service: - # Connect to WiFi state changes to update icon self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon) def update_wifi_icon(self, *_): - """Update WiFi icon based on current state""" try: if self.wifi_service and hasattr(self, "wifi_svg"): is_enabled = self.wifi_service.wireless_enabled - icon_file = ( - "../../config/assets/icons/applets/wifi.svg" - if is_enabled - else "../../config/assets/icons/applets/wifi-off.svg" + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if is_enabled else "applets/wifi-off.svg" ) - self.wifi_svg.set_from_file(get_relative_path(icon_file)) except Exception as e: logger.warning(f"Failed to update WiFi icon: {e}") def toggle_wifi(self, *_): - """Toggle wifi on/off""" + if not self._resources_initialized: + return try: if self.wifi_service: self.wifi_service.toggle_wifi() - # Update icon immediately after toggle GLib.timeout_add(100, self.update_wifi_icon) else: logger.warning("WiFi device not available for toggling") @@ -874,16 +935,24 @@ def set_children(self, children): self.children = children def open_bluetooth(self, *_): + if not self._resources_initialized: + return self._ensure_bluetooth_widgets() idle_add(lambda *_: self.set_children(self.bluetooth_center_box)) self.has_bluetooth_open = True def open_wifi(self, *_): + if not self._resources_initialized: + return self._ensure_wifi_widgets() idle_add(lambda *_: self.set_children(self.wifi_center_box)) self.has_wifi_open = True def close_bluetooth(self, *_): + try: + self.bluetooth_man.close_bluetooth() + except Exception as e: + logger.error(f"An error occurred: {e}") if self.current_view == "expanded_player": self._crossfade_to_view("main") else: @@ -891,6 +960,10 @@ def close_bluetooth(self, *_): self.has_bluetooth_open = False def close_wifi(self, *_): + try: + self.wifi_man.close_wifi() + except Exception as e: + logger.error(f"An error occurred: {e}") if self.current_view == "expanded_player": self._crossfade_to_view("main") else: @@ -898,6 +971,8 @@ def close_wifi(self, *_): self.has_wifi_open = False def open_per_app_volume(self, *_): + if not self._resources_initialized: + return self._ensure_per_app_volume_widgets() if self.current_view == "expanded_player": # If coming from expanded player, use crossfade @@ -923,46 +998,46 @@ def close_per_app_volume(self, *_): self.has_per_app_volume_open = False def open_expanded_player(self, *_): - self._ensure_expanded_player_widgets() + if not self._resources_initialized: + return self._crossfade_to_view("expanded_player") self.has_expanded_player_open = True - # Refresh the player when opening if self._expanded_player_widget: self._expanded_player_widget.refresh() def close_expanded_player(self, *_): + # Just hide the expanded player widget, don't destroy it self._crossfade_to_view("main") self.has_expanded_player_open = False def _crossfade_to_view(self, view_name): """Handle transitions between views""" if view_name == "expanded_player": - # Show expanded player - self._ensure_expanded_player_widgets() idle_add(lambda *_: self.set_children(self.expanded_player_center_box)) self.current_view = "expanded_player" elif view_name == "main": - # Show main view idle_add(lambda *_: self.set_children(self.center_box)) self.current_view = "main" def _set_mousecapture(self, visible: bool): if visible: - # Lazy load music widget when becoming visible - self._ensure_music_widget() + GLib.idle_add(self._initialize_resources) self.set_visible(visible) if not visible: - self.close_bluetooth() - self.close_wifi() - self.close_per_app_volume() - self.close_expanded_player() + # Simply reset views without multiple overlapping set_children calls + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False + self.current_view = "main" + self.set_children(self.center_box) def volume_changed( self, _, ): - if self._updating_volume: + if not self._signals_connected or self._updating_volume: return GLib.idle_add( @@ -970,12 +1045,10 @@ def volume_changed( ) def wlan_changed(self, _, wlan): - self.wifi_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ) + if not self._signals_connected: + return + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg" ) if wlan != "No Connection": if wlan.startswith("connected:"): @@ -995,12 +1068,12 @@ def wlan_changed(self, _, wlan): GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) def bluetooth_changed(self, _, bluetooth): - self.bluetooth_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" - if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" - ) + if not self._signals_connected: + return + self.bluetooth_svg.dynamic_file( + "applets/bluetooth.svg" + if bluetooth != "disabled" + else "applets/bluetooth-off.svg" ) if bluetooth != "disabled": if bluetooth.startswith("connected:"): @@ -1018,61 +1091,194 @@ def bluetooth_changed(self, _, bluetooth): GLib.idle_add(lambda: self.bluetooth_label.set_label("Off")) def audio_changed(self, *_): - pass + if not self._signals_connected: + return def dnd_changed(self, _, dnd_state): + if not self._signals_connected: + return self.focus_mode = dnd_state - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" ) self.focus_status_label.set_label("On" if self.focus_mode else "Off") - def _init_mousecapture(self, mousecapture): - self._mousecapture_parent = mousecapture + def _disconnect_all_signals(self): + """Disconnect all signal connections to prevent memory leaks""" + try: + # _signal_connections stores (obj, handler_id) tuples + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() - def hide_controlcenter(self, *_): - self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) + # Disconnect scale widget signals (connected directly, not tracked) + if hasattr(self, "volume_scale") and self.volume_scale: + try: + self.volume_scale.disconnect_by_func(self.set_volume) + except Exception as e: + logger.error(f"An error occurred: {e}") + try: + self.volume_scale.disconnect_by_func(self.on_volume_scroll) + except Exception as e: + logger.error(f"An error occurred: {e}") - def destroy(self): - """Clean up resources when widget is destroyed""" - # Disconnect all signal connections - for connection in self._signal_connections: - try: - connection.disconnect() - except: - pass + if hasattr(self, "brightness_scale") and self.brightness_scale: + try: + self.brightness_scale.disconnect_by_func(self.set_brightness) + except Exception as e: + logger.error(f"An error occurred: {e}") + try: + self.brightness_scale.disconnect_by_func(self.on_brightness_scroll) + except Exception as e: + logger.error(f"An error occurred: {e}") - # Clean up caffeine process - if self._caffeine_process: + # Disconnect the visibility change signal on self try: - self._caffeine_process.terminate() - except: - pass - self._caffeine_process = None - - # Clean up heavy components - if hasattr(self, "wifi_man") and self.wifi_man: - self.wifi_man.destroy() - if hasattr(self, "bluetooth_man") and self.bluetooth_man: - self.bluetooth_man.destroy() - if self._music_widget_content: - self._music_widget_content.destroy() - if self._per_app_volume_widget: - self._per_app_volume_widget.destroy() - if self._expanded_player_widget: - self._expanded_player_widget.destroy() + self.disconnect_by_func(self._on_visibility_changed) + except Exception as e: + logger.error(f"An error occurred: {e}") + + self._signals_connected = False + logger.debug("All signals disconnected successfully") - # Clean up shared MPRIS manager - if self._mpris_manager: + except Exception as e: + logger.warning(f"Signal disconnection failed: {e}") + + def _cleanup_managers(self): + """Clean up all manager instances""" + try: + if hasattr(self, "wifi_man") and self.wifi_man: + try: + self.wifi_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy WiFi manager: {e}") + self.wifi_man = None + + if hasattr(self, "bluetooth_man") and self.bluetooth_man: + try: + self.bluetooth_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy Bluetooth manager: {e}") + self.bluetooth_man = None + + if hasattr(self, "network_service") and self.network_service: + try: + self.network_service.destroy() + except Exception as e: + logger.warning(f"Failed to destroy network service: {e}") + self.network_service = None + + if hasattr(self, "wifi_service") and self.wifi_service: + try: + self.wifi_service.disconnect_by_func(self.update_wifi_icon) + except Exception as e: + logger.warning(f"Failed to disconnect WiFi service: {e}") + self.wifi_service = None + + self._resources_initialized = False + + logger.debug("All managers cleaned up successfully") + + except Exception as e: + logger.warning(f"Manager cleanup failed: {e}") + + def _cleanup_widgets(self): + try: + # Clean up main widgets + if hasattr(self, "widgets") and self.widgets: + try: + self.widgets.destroy() + except Exception as e: + logger.warning(f"Failed to destroy main widgets: {e}") + self.widgets = None + + # Clean up center box + if hasattr(self, "center_box") and self.center_box: + try: + self.center_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy center box: {e}") + self.center_box = None + + # Clean up individual widgets + widget_attrs = [ + "wlan_widget", + "bluetooth_widget", + "focus_widget", + "flight_widget", + "caffeine_widget", + "night_light_widget", + "volume_scale", + "brightness_scale", + "wlan_label", + "bluetooth_label", + "focus_status_label", + "caffeine_status_label", + "wlan_svg", + "bluetooth_svg", + "focus_icon", + "flight_icon", + "caffeine_icon", + ] + + for attr in widget_attrs: + if hasattr(self, attr) and getattr(self, attr): + try: + widget = getattr(self, attr) + if hasattr(widget, "destroy"): + widget.destroy() + except Exception as e: + logger.warning(f"Failed to destroy {attr}: {e}") + setattr(self, attr, None) + + logger.debug("All widgets cleaned up successfully") + + except Exception as e: + logger.warning(f"Widget cleanup failed: {e}") + + def _cleanup_processes(self): + logger.debug("All processes cleaned up successfully") + + def _complete_cleanup(self): + """Perform complete cleanup of all resources""" + try: + logger.debug("Starting complete cleanup...") + self._disconnect_all_signals() + self._cleanup_managers() + self._cleanup_widgets() + self._cleanup_processes() + self._disconnect_signals_when_hidden() + + logger.debug("Complete cleanup finished successfully") + + except Exception as e: + logger.error(f"Complete cleanup failed: {e}") + + def hide_controlcenter(self, *_): + try: + # Just disconnect signals when hiding, don't destroy widgets + self._disconnect_signals_when_hidden() + + # Hide the control center + self.set_visible(False) + self.set_visible(False) + + except Exception as e: + logger.error(f"Failed to hide control center: {e}") + # Still try to hide even if cleanup fails + self.set_visible(False) + + def destroy(self): + try: + self._complete_cleanup() + super().destroy() + logger.debug("Control center destroyed successfully") + except Exception as e: + logger.error(f"Failed to destroy control center: {e}") try: - self._mpris_manager.destroy() + super().destroy() except Exception as e: - logger.warning(f"Failed to destroy MPRIS manager: {e}") - self._mpris_manager = None - - super().destroy() + logger.error(f"An error occurred: {e}") diff --git a/src/window/controlcenter/nightlight.py b/src/window/controlcenter/nightlight.py new file mode 100644 index 00000000..cfa93afa --- /dev/null +++ b/src/window/controlcenter/nightlight.py @@ -0,0 +1,79 @@ +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label + +from services.modus import ( + is_night_light_active, + toggle_night_light, +) +from utils.gtk_utils import svg_file + + +def create_night_light_widget(control_center): + """Create night light widget for control center""" + + is_active = is_night_light_active() + + night_light_icon = svg_file( + ( + "applets/redshift-status-on.svg" + if is_active + else "applets/redshift-status-off.svg" + ), + size=42, + ) + + night_light_status_label = Label( + label="On" if is_active else "Off", + name="nightlight-widget-label", + style_classes="status-label", + max_chars_width=15, + ellipsization="end", + h_align="start", + ) + + def toggle_ui(*_): + if toggle_night_light(): + is_active = is_night_light_active() + night_light_icon.dynamic_file( + "applets/redshift-status-on.svg" + if is_active + else "applets/redshift-status-off.svg" + ) + night_light_status_label.set_label("On" if is_active else "Off") + + night_light_widget = Box( + name="nightlight-widget", + orientation="h", + h_expand=True, + children=[ + Button( + name="nightlight-icon-button", + child=night_light_icon, + on_clicked=toggle_ui, + ), + Button( + name="nightlight-info-button", + child=Box( + name="nightlight-widget-info", + h_expand=True, + v_expand=True, + v_align="center", + h_align="start", + orientation="vertical", + children=[ + Label( + name="nightlight-widget-name", + label="Night Light", + style_classes="ct", + h_align="start", + ), + night_light_status_label, + ], + ), + on_clicked=toggle_ui, + ), + ], + ) + + return night_light_widget diff --git a/modules/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py similarity index 71% rename from modules/controlcenter/per_app_volume.py rename to src/window/controlcenter/per_app_volume.py index 08ba18a5..60a2afbf 100644 --- a/modules/controlcenter/per_app_volume.py +++ b/src/window/controlcenter/per_app_volume.py @@ -1,18 +1,53 @@ -# Standard library imports -from gi.repository import GLib - -# Fabric imports +from fabric.utils import GLib, logger from fabric.widgets.box import Box from fabric.widgets.button import Button +from fabric.widgets.image import Image from fabric.widgets.label import Label -from fabric.widgets.scale import Scale from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image from fabric.widgets.separator import Separator +from shared.widgets.flat_scale import FlatScale + # Local imports from utils.roam import audio_service +# Mappings used to resolve an app's icon/display name from its description or +# name. Defined once at module level to avoid rebuilding on every refresh. +_APP_ICON_MAPPING = { + "spotify": "spotify", + "firefox": "firefox", + "chromium": "chromium-browser", + "chrome": "google-chrome", + "vlc": "vlc", + "discord": "discord", + "steam": "steam", + "zen": "zen-browser", + "code": "vscode", + "visual studio code": "vscode", + "telegram": "telegram-desktop", + "pulse": "audio-card", + "pipewire": "audio-card", + "alsa": "audio-card", + "sink": "audio-speakers", + "source": "audio-input-microphone", + "youtube": "youtube", + "music": "rhythmbox", + "media": "multimedia-player", +} + +_APP_SPECIAL_NAMES = { + "spotify": "Spotify", + "firefox": "Firefox", + "chromium": "Chromium", + "chrome": "Chrome", + "vlc": "VLC", + "discord": "Discord", + "steam": "Steam", + "zen": "Zen Browser", + "code": "VS Code", + "telegram": "Telegram", +} + class PerAppVolumeControl(Box): """Per-application volume control widget""" @@ -29,6 +64,8 @@ def __init__(self, control_center, **kwargs): self._updating_volumes = set() self._app_widgets = {} self._signal_connections = [] + self._destroyed = False + self._refresh_timer = None # Header with back button self.header = Box( @@ -61,8 +98,8 @@ def __init__(self, control_center, **kwargs): try: if hasattr(self.control_center, "add_keybinding"): self.control_center.add_keybinding("Escape", self._go_back) - except Exception: - pass # Ignore if keybinding fails + except Exception as e: + logger.error(f"An error occurred: {e}") # Ignore if keybinding fails self.children = [self.header, self.scrolled_window] @@ -75,16 +112,37 @@ def __init__(self, control_center, **kwargs): audio_service.connect("stream-removed", self._on_stream_changed) ) + # Track last known app names to avoid unnecessary rebuilds + self._last_app_names = frozenset() + # Initial population self._populate_apps() - # Set up auto-refresh timer for audio streams - self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh) + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) def _auto_refresh(self): """Auto-refresh the application list every 2 seconds""" - self._populate_apps() - return True # Continue the timer + if self._destroyed: + self._refresh_timer = None + return False + if not audio_service: + return True + apps = audio_service.applications or [] + current_names = frozenset(a.name or "" for a in apps) + if current_names != self._last_app_names: + self._last_app_names = current_names + self._populate_apps() + return True + + def _on_map(self, *_): + if self._refresh_timer is None: + self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh) + + def _on_unmap(self, *_): + if self._refresh_timer is not None: + GLib.source_remove(self._refresh_timer) + self._refresh_timer = None def _go_back(self, *_): """Return to main control center view""" @@ -104,28 +162,7 @@ def _get_app_icon(self, app): # Check description first as it's more reliable search_text = app_description if app_description else app_name - icon_mapping = { - "spotify": "spotify", - "firefox": "firefox", - "chromium": "chromium-browser", - "chrome": "google-chrome", - "vlc": "vlc", - "discord": "discord", - "steam": "steam", - "zen": "zen-browser", - "code": "vscode", - "visual studio code": "vscode", - "telegram": "telegram-desktop", - "pulse": "audio-card", - "zen": "zen-browser", - "pipewire": "audio-card", - "alsa": "audio-card", - "sink": "audio-speakers", - "source": "audio-input-microphone", - "youtube": "youtube", - "music": "rhythmbox", - "media": "multimedia-player", - } + icon_mapping = _APP_ICON_MAPPING # Check if any key in mapping is contained in search text for key, icon in icon_mapping.items(): @@ -147,29 +184,18 @@ def _format_app_name(self, name): return "Unknown" # Handle common app names specially - special_names = { - "spotify": "Spotify", - "firefox": "Firefox", - "chromium": "Chromium", - "chrome": "Chrome", - "vlc": "VLC", - "discord": "Discord", - "steam": "Steam", - "zen": "Zen Browser", - "code": "VS Code", - "telegram": "Telegram", - } - name_lower = name.lower() - if name_lower in special_names: - return special_names[name_lower] + if name_lower in _APP_SPECIAL_NAMES: + return _APP_SPECIAL_NAMES[name_lower] # Default: capitalize first letter return name.capitalize() def _populate_apps(self): """Populate the widget with current audio applications""" - # Clear existing widgets + from utils.functions import clear_children + + clear_children(self.apps_container) self.apps_container.children = [] self._app_widgets.clear() @@ -184,9 +210,10 @@ def _populate_apps(self): for i, app in enumerate(applications): if hasattr(app, "name") and hasattr(app, "volume"): app_widget = self._create_app_control(app) - self.apps_container.children = list( - self.apps_container.children - ) + [app_widget] + self.apps_container.children = [ + *list(self.apps_container.children), + app_widget, + ] # Add separator between apps (except for last one) if i < len(applications) - 1: @@ -194,9 +221,10 @@ def _populate_apps(self): orientation="horizontal", style_classes="app-volume-separator", ) - self.apps_container.children = list( - self.apps_container.children - ) + [separator] + self.apps_container.children = [ + *list(self.apps_container.children), + separator, + ] self._app_widgets[app.name] = (app_widget, app) else: @@ -243,11 +271,11 @@ def _create_app_control(self, app): ) # Volume scale (smaller and horizontal) - volume_scale = Scale( + volume_scale = FlatScale( value=volume_percent, min_value=0, max_value=max_vol, - increments=(1, 5), + step=5, name="compact-volume-slider", size=20, h_expand=True, @@ -255,8 +283,8 @@ def _create_app_control(self, app): # Connect volume change handler volume_scale.connect( - "change-value", - lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value), + "value-changed", + lambda scale, value, app=app: self._set_app_volume(app, value), ) # Create horizontal compact layout @@ -304,13 +332,14 @@ def _set_app_volume(self, app, volume_value): app.volume = volume_value except Exception as e: - print(f"Error setting volume for {app.name}: {e}") + logger.error(f"Error setting volume for {app.name}: {e}") finally: GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name)) def _on_stream_changed(self, *_): """Handle when audio streams are added or removed""" - GLib.idle_add(self._populate_apps) + if not self._destroyed: + GLib.idle_add(self._populate_apps) def refresh(self): """Manually refresh the application list""" @@ -318,19 +347,27 @@ def refresh(self): def destroy(self): """Clean up resources""" - if hasattr(self, "_refresh_timer"): - GLib.source_remove(self._refresh_timer) + self._destroyed = True + + # Stop the auto-refresh timer + if self._refresh_timer is not None: + try: + GLib.source_remove(self._refresh_timer) + except Exception as e: + logger.error(f"An error occurred: {e}") + self._refresh_timer = None - # Disconnect fabric audio service signals + # Disconnect audio service signals if audio_service: for connection in self._signal_connections: try: audio_service.disconnect(connection) - except: - pass + except Exception as e: + logger.error(f"An error occurred: {e}") self._signal_connections.clear() self._app_widgets.clear() self._updating_volumes.clear() + self.control_center = None super().destroy() diff --git a/src/window/controlcenter/player.py b/src/window/controlcenter/player.py new file mode 100644 index 00000000..39a1d1bf --- /dev/null +++ b/src/window/controlcenter/player.py @@ -0,0 +1,594 @@ +from fabric.utils import bulk_connect, get_relative_path, logger, os +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.stack import Stack + +import shared.data as data +from services.mpris import PlayerManager, PlayerService +from utils.gtk_utils import svg_file + +CACHE_DIR = f"{data.CACHE_DIR}/media" +PLAYER_FALLBACK_ART = get_relative_path("../../assets/icons/music.svg") +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + +_shared_mpris_manager = None + + +def get_shared_mpris_manager(): + global _shared_mpris_manager + if _shared_mpris_manager is None: + _shared_mpris_manager = PlayerManager() + return _shared_mpris_manager + + +def cleanup_shared_mpris_manager(): + global _shared_mpris_manager + if _shared_mpris_manager is not None: + try: + _shared_mpris_manager.destroy() + except Exception as e: + logger.warning(f"Failed to destroy shared MPRIS manager: {e}") + _shared_mpris_manager = None + + +def apply_player_art(album_cover_widget, player, service=None, path=None): + """ + Single source of truth for applying album art to any player widget. + + Call patterns: + - On init / metadata change: apply_player_art(widget, player) + - From artwork-change signal: apply_player_art(widget, player, service_obj, local_path) + + Returns the applied path, or None if no art is available yet (backend will + emit artwork-change when the async download completes). + """ + # artwork-change signal emits (service_obj, local_path_str) + if path and isinstance(path, str) and os.path.exists(path): + album_cover_widget.set_style(f"background-image:url('{path}')") + return path + # Check if backend already has it cached from a previous download + if player: + cached = player.get_artwork() + if cached and os.path.exists(cached): + album_cover_widget.set_style(f"background-image:url('{cached}')") + return cached + # Nothing yet — backend will emit artwork-change when download finishes + return None + + +class PlayerBoxStack(Box): + def __init__( + self, mpris_manager: PlayerManager = None, control_center=None, **kwargs + ): + self.player_stack = Stack(name="player-stack") + self.current_stack_pos = 0 + self.control_center = control_center + self.player_buttons: list[Button] = [] + self._signal_connections = [] + self.no_media_box = self._create_no_media_box() + + super().__init__(orientation="v", name="media", children=[self.player_stack]) + self.player_stack.children = [self.no_media_box] + self.set_visible(True) + self.mpris_manager = mpris_manager or get_shared_mpris_manager() + self.connect("map", self._on_map) + + if self.mpris_manager is not None: + self._init_mpris_manager() + + def _init_mpris_manager(self): + try: + connections = bulk_connect( + self.mpris_manager, + { + "new-player": self.on_new_player, + "player-vanish": self.on_lost_player, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + for name, player in self.mpris_manager.get_all_services().items(): + self.on_new_player(self.mpris_manager, name, player) + except Exception as e: + logger.error(f"Failed to initialize PlayerBoxStack signals: {e}") + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + self._signal_connections.clear() + super().destroy() + + def suspend(self): + for child in self.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + + def resume(self): + for child in self.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + + def _create_no_media_box(self): + fallback_cover_path = get_relative_path("../../assets/icons/music.svg") + album_cover = Box(style_classes="album-image-c") + album_cover.set_style(f"background-image:url('{fallback_cover_path}')") + + track_title = Label( + label="No media playing", + name="player-title-c", + justification="left", + max_chars_width=25, + ellipsization="end", + h_align="start", + ) + track_info = Box( + name="track-info", + h_expand=True, + orientation="v", + v_align="start", + h_align="start", + children=[track_title], + ) + + inner_box = CenterBox( + name="inner-player-box", + start_children=[Box(style_classes="album-image-c", children=album_cover)], + center_children=[ + Box( + name="player-info-box-c", + h_expand=True, + v_align="center", + h_align="center", + orientation="v", + children=[track_info], + ) + ], + ) + return Box( + h_align="center", + name="player-box", + h_expand=True, + children=[ + Box( + spacing=5, + name="outer-no-player-box-c", + h_expand=True, + h_align="fill", + v_expand=True, + children=inner_box, + ) + ], + ) + + def _find_playing_player_index(self): + players = self.player_stack.get_children() + for i, player_box in enumerate(players): + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + return i + return None + + def _check_and_update_playing_state(self): + players = [ + p for p in self.player_stack.get_children() if p != self.no_media_box + ] + if len(players) > 0: + if self.no_media_box in self.player_stack.get_children(): + self.player_stack.remove(self.no_media_box) + playing_index = self._find_playing_player_index() + if playing_index is not None: + self.on_player_clicked_by_index(playing_index) + return + + if ( + len(self.player_stack.get_children()) == 1 + and self.player_stack.get_children()[0] == self.no_media_box + ): + return + + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_stack.set_visible_child(self.no_media_box) + + def on_player_playback_changed(self, player_box, status): + status = str(status).lower() + if status == "playing": + players = self.player_stack.get_children() + for i, pb in enumerate(players): + if pb == player_box and i != self.current_stack_pos: + self.on_player_clicked_by_index(i) + break + elif status in ["paused", "stopped"]: + self._check_and_update_playing_state() + + def _update_all_player_buttons(self): + show_buttons = len(self.player_buttons) > 1 + for child in self.player_stack.get_children(): + if hasattr(child, "update_buttons"): + child.update_buttons(self.player_buttons, show_buttons) + + def on_player_clicked_by_index(self, index): + if 0 <= index < len(self.player_buttons): + if self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + self.current_stack_pos = index + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos] + ) + self._update_all_player_buttons() + + def on_new_player(self, mpris_manager, name, player): + if ( + len(self.player_stack.get_children()) == 1 + and self.player_stack.get_children()[0] == self.no_media_box + ): + self.player_stack.children = [] + self.current_stack_pos = 0 + + new_player_box = PlayerBox( + player=player, player_stack=self, control_center=self.control_center + ) + self.player_stack.children = [*self.player_stack.children, new_player_box] + self.make_new_player_button(new_player_box) + if self.player_buttons: + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + self._check_and_update_playing_state() + self._update_all_player_buttons() + + def on_lost_player(self, mpris_manager, bus_name): + player_box_to_remove = None + for player_box in self.player_stack.get_children(): + if ( + hasattr(player_box, "player") + and getattr(player_box.player, "player_name", None) == bus_name + ): + player_box_to_remove = player_box + break + + if player_box_to_remove: + player_box_to_remove.destroy() + + remaining_players = [ + p for p in self.player_stack.get_children() if p != player_box_to_remove + ] + if len(remaining_players) == 0: + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_buttons = [] + return + + if self.current_stack_pos >= len(self.player_stack.get_children()): + self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) + + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos] + ) + self._update_all_player_buttons() + + def make_new_player_button(self, player_box): + new_button = Button(name="player-stack-button") + + def on_player_button_click(button: Button): + if self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + if button in self.player_buttons: + self.current_stack_pos = self.player_buttons.index(button) + button.add_style_class("active") + self.player_stack.set_visible_child(player_box) + + new_button.connect("clicked", on_player_button_click) + self.player_buttons.append(new_button) + player_box.connect( + "destroy", + lambda *_: ( + self.player_buttons.remove(new_button) + if new_button in self.player_buttons + else None + ), + ) + + +class PlayerBox(Box): + def __init__( + self, player: PlayerService, player_stack=None, control_center=None, **kwargs + ): + super().__init__(h_align="center", name="player-box", h_expand=True, **kwargs) + self.player = player + self.player_stack = player_stack + self.control_center = control_center + self.cover_path = PLAYER_FALLBACK_ART + self.exit = False + self._signal_connections = [] + + self.album_cover = Box(style_classes="album-image-c") + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") + + self.image = Overlay( + child=Box( + h_align="start", + v_align="center", + name="player-image-stack", + children=[self.album_cover], + ), + overlays=[ + Box( + children=Image( + icon_name=self.player.player_name, + name="player-app-icon", + icon_size=20, + ), + h_align="end", + v_align="end", + ) + ], + ) + + self.track_title = Label( + label=self.player.title or "No Title", + name="player-title-c", + max_chars_width=25, + ellipsization="end", + h_align="start", + ) + self.track_artist = Label( + label=", ".join(self.player.artist) if self.player.artist else "No Artist", + name="player-artist-c", + max_chars_width=23, + ellipsization="end", + h_align="start", + ) + + initial_status = str(self.player.playback_status).lower() + icon_file = ( + "player/play.svg" if initial_status == "paused" else "player/pause.svg" + ) + self.play_pause_icon = svg_file(icon_file, size=22) + self.play_pause_button = Button( + name="player-button", + child=self.play_pause_icon, + on_clicked=self.player.play_pause, + ) + self.next_button = Button( + name="player-button", + child=svg_file("player/fwd.svg", size=22), + on_clicked=self.player.next, + ) + + self.stack_buttons_box = Box( + h_expand=False, + v_expand=False, + name="macos-stack-buttons-box", + spacing=4, + orientation="h", + h_align="center", + v_align="end", + ) + self.stack_buttons_box.hide() + + self.children = [ + Box( + orientation="v", + children=[ + CenterBox( + name="box-c", + orientation="h", + h_align="center", + start_children=[ + Button( + spacing=5, + name="outer-player-box-c", + h_expand=True, + on_clicked=self._on_outer_box_clicked, + h_align="start", + child=Box( + name="inner-player-box", + h_expand=True, + v_align="center", + h_align="start", + children=[ + self.image, + Box( + name="player-info-box-c", + v_align="center", + h_expand=True, + h_align="start", + orientation="v", + children=[ + Box( + name="track-info", + spacing=5, + orientation="v", + v_align="start", + h_align="start", + children=[ + self.track_title, + self.track_artist, + ], + ) + ], + ), + ], + ), + ) + ], + end_children=[ + Box( + name="button-box-c", + h_expand=False, + spacing=2, + children=[self.play_pause_button, self.next_button], + ) + ], + ), + self.stack_buttons_box, + ], + ) + ] + self.get_children()[0].set_size_request(352, -1) + + connections = bulk_connect( + self.player, + { + "play": self._on_playback_change, + "pause": self._on_playback_change, + "meta-change": self._on_metadata, + "artwork-change": self.set_image, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.player, handler_id)) + + result = apply_player_art(self.album_cover, self.player) + if result: + self.cover_path = result + + try: + metadata = self.player._player.props.metadata + if metadata: + keys = metadata.keys() + self.track_title.set_label( + metadata["xesam:title"] if "xesam:title" in keys else "No Title" + ) + self.track_artist.set_label( + ", ".join(metadata["xesam:artist"]) + if "xesam:artist" in keys and metadata["xesam:artist"] + else "No Artist" + ) + except Exception as e: + logger.warning( + f"[player] metadata = self.player._player.props.metadata failed: {e}" + ) + + def update_buttons(self, player_buttons, show_buttons): + if show_buttons and len(player_buttons) > 1: + if len(self.stack_buttons_box.get_children()) != len(player_buttons): + self.stack_buttons_box.children = [] + for i, button in enumerate(player_buttons): + dot_button = Button( + name="macos-player-switcher-dot", + style_classes=["macos-switcher-dot"], + ) + dot_button.connect( + "clicked", + lambda *_, idx=i: self.player_stack.on_player_clicked_by_index( + idx + ), + ) + self.stack_buttons_box.children = [ + *self.stack_buttons_box.children, + dot_button, + ] + + for i, dot_button in enumerate(self.stack_buttons_box.get_children()): + if player_buttons[i].get_style_context().has_class("active"): + dot_button.add_style_class("active") + else: + dot_button.remove_style_class("active") + + self.stack_buttons_box.show_all() + else: + self.stack_buttons_box.hide() + + def _on_outer_box_clicked(self, *_): + if self.control_center and hasattr(self.control_center, "open_expanded_player"): + self.control_center.open_expanded_player() + + def _on_metadata(self, *args): + if self.exit or self.player is None: + return + metadata = args[1] if len(args) >= 2 else None + if metadata is not None: + keys = metadata.keys() + self.track_title.set_label( + metadata["xesam:title"] if "xesam:title" in keys else "No Title" + ) + self.track_artist.set_label( + ", ".join(metadata["xesam:artist"]) + if "xesam:artist" in keys and metadata["xesam:artist"] + else "No Artist" + ) + else: + self.track_title.set_label(self.player.title or "No Title") + self.track_artist.set_label( + ", ".join(self.player.artist) if self.player.artist else "No Artist" + ) + self.set_image() + + def refresh_icon(self): + """Poll-based icon refresh - works even when browser doesn't fire signals.""" + if self.player is None: + return + try: + status = str(self.player.playback_status).lower() + self.play_pause_icon.dynamic_file( + "player/play.svg" if status == "paused" else "player/pause.svg" + ) + except Exception as e: + logger.error(f"An error occurred: {e}") + + def _on_playback_change(self, service, *args): + if self.exit or self.player is None: + return + + current_status = str(self.player.playback_status).lower() + if getattr(self, "_last_notified_status", None) == current_status: + return + self._last_notified_status = current_status + + self.play_pause_icon.dynamic_file( + "player/play.svg" if current_status == "paused" else "player/pause.svg" + ) + if self.player_stack: + self.player_stack.on_player_playback_changed(self, current_status) + + def set_image(self, service=None, path=None, *_): + if self.exit or self.player is None: + return + result = apply_player_art(self.album_cover, self.player, service, path) + if result: + self.cover_path = result + + def suspend(self): + self.exit = True + + def resume(self): + self.exit = False + if self.player is not None: + current_status = str(self.player.playback_status).lower() + self._last_notified_status = current_status + self.play_pause_icon.dynamic_file( + "player/play.svg" + if current_status == "paused" + else "player/pause.svg" + ) + self._on_metadata(self.player, self.player._player.props.metadata) + self.set_image() + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + self.exit = True + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + super().destroy() diff --git a/modules/controlcenter/wifi.py b/src/window/controlcenter/wifi.py similarity index 61% rename from modules/controlcenter/wifi.py rename to src/window/controlcenter/wifi.py index 6cb612d8..28e9bb79 100644 --- a/modules/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -1,61 +1,51 @@ -from widgets.wifi_password_dialog import WiFiPasswordDialog -from services.network import NetworkClient -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.revealer import Revealer -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.button import Button +from fabric.utils import Gdk, GLib, logger from fabric.widgets.box import Box -from fabric.widgets.svg import Svg -from fabric.utils import get_relative_path -from gi.repository import Gdk, GLib, Gtk +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label from fabric.widgets.separator import Separator -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -import gi -import subprocess -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") +from services.network import NetworkClient +from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog +from shared.widgets.smooth_switch import SmoothSwitch +from shared.window.animated_scrollwindow import AnimatedScrollable +from utils.functions import ( + get_wifi_connecting_icon, + get_wifi_icon_for_strength, + spawn_detached, +) +from utils.gtk_utils import svg_file class WifiNetworkSlot(Box): - def __init__(self, access_point, wifi_service, parent=None, **kwargs): + def __init__( + self, access_point, wifi_service, network_service=None, parent=None, **kwargs + ): super().__init__(name="wifi-network-slot", **kwargs) self.access_point = access_point self.wifi_service = wifi_service + self.network_service = network_service self.parent = parent # Reference to control center - # Get network info from AccessPoint object self.ssid = access_point.ssid self.bssid = access_point.bssid self.strength = access_point.strength self.icon_name = access_point.icon - # Check if this network is currently connected self.is_connected = access_point.is_active - # Initialize styles based on connection state self.styles = [ "connected" if self.is_connected else "", ] - # Create connection status indicator using dynamic WiFi icon based on signal strength wifi_icon_path = get_wifi_icon_for_strength(self.strength) - self.dimage = Svg( - svg_file=wifi_icon_path, - size=28, - name="device-icon", - ) + self.dimage = svg_file(wifi_icon_path, size=28) self.wifi_icon_box = Box( children=[self.dimage], style_classes=["wifi-icon-box"], ) - # if self.is_connected: - # self.wifi_icon_box.remove_style_class("wifi-icon-box") - # self.wifi_icon_box.add_style_class("wifi-icon-box-connected") - # - # Set initial style classes + if self.is_connected: self.dimage.add_style_class("connected") @@ -72,10 +62,8 @@ def __init__(self, access_point, wifi_service, parent=None, **kwargs): name="wifi-lock-icon", ) - # Initialize password dialog self.password_dialog = None - # Create the start section with WiFi icon and network name start_box = Box( orientation="h", spacing=8, @@ -83,7 +71,6 @@ def __init__(self, access_point, wifi_service, parent=None, **kwargs): start_box.children = [self.wifi_icon_box, self.network_label] if self.lock_icon: start_box.children.append(self.lock_icon) - # Create end section with lock icon if needed self.children = [ Button( @@ -94,31 +81,53 @@ def __init__(self, access_point, wifi_service, parent=None, **kwargs): ) ] - # Emit initial change to update display + self.on_changed() + + def update_ap(self, access_point): + """Update existing slot with new AP data without recreating it""" + self.access_point = access_point + self.bssid = access_point.bssid + self.strength = access_point.strength + self.is_connected = access_point.is_active + + if not self.dimage.has_style_class( + "connecting" + ) and not self.dimage.has_style_class("disconnecting"): + from shared.widgets.wifi_icon import get_wifi_icon_for_strength + + wifi_icon_path = get_wifi_icon_for_strength(self.strength) + self.dimage.set_from_file(wifi_icon_path) + self.on_changed() def toggle_connecting(self): - # Check if this network is currently connected is_currently_connected = self.access_point.is_active if is_currently_connected: - # Show disconnecting state using connecting icon connecting_icon = get_wifi_connecting_icon() self.dimage.set_from_file(connecting_icon) self.dimage.add_style_class("disconnecting") - # Disconnect from network - self.wifi_service.disconnect_wifi() + if self.wifi_service and self.wifi_service._device: + spawn_detached( + [ + "nmcli", + "device", + "disconnect", + self.wifi_service._device.get_iface(), + ] + ) self.is_connected = False - # Remove disconnecting state after a short delay to show feedback GLib.timeout_add(500, lambda: self._reset_disconnect_state()) else: - # Try to connect - check if password is required - if self.access_point.requires_password: - # Show password dialog immediately + is_saved = self.network_service and self.network_service.is_network_saved( + self.ssid + ) + + if self.access_point.requires_password and not is_saved: self._show_password_dialog() else: - # Try to connect without password (for open networks) + # Try to connect without password (for open or saved networks) connecting_icon = get_wifi_connecting_icon() self.dimage.set_from_file(connecting_icon) self.dimage.add_style_class("connecting") @@ -132,20 +141,25 @@ def on_open_connection_result(success, message): else: # Connection failed self._reset_connect_state() + # If password was wrong or auth failed, prompt the user + if message == "Wrong password": + self._show_password_dialog() # Update display after connection attempt self.on_changed() try: - self.wifi_service.connect_to_wifi( - self.access_point, callback=on_open_connection_result + if self.network_service: + self.network_service.connect_wifi_bssid( + self.access_point.bssid, callback=on_open_connection_result + ) + except Exception as e: + logger.error( + f"[WiFi] Failed to connect to {self.access_point.ssid}: {e}" ) - except Exception: - # Handle any connection errors gracefully self._reset_connect_state() self.on_changed() - # Update display after connection attempt self.on_changed() def _reset_disconnect_state(self): @@ -154,7 +168,7 @@ def _reset_disconnect_state(self): wifi_icon_path = get_wifi_icon_for_strength(self.strength) self.dimage.set_from_file(wifi_icon_path) self.on_changed() - return False # Remove timeout + return False def _reset_connect_state(self): """Reset visual state after connect operation""" @@ -162,15 +176,14 @@ def _reset_connect_state(self): wifi_icon_path = get_wifi_icon_for_strength(self.strength) self.dimage.set_from_file(wifi_icon_path) self.on_changed() - return False # Remove timeout + return False def on_changed(self, *_): - # Check if this network is currently connected using the access point's is_active property self.is_connected = self.access_point.is_active self.styles = [ "connected" if self.is_connected else "", ] - # Update style classes for SVG widget + if self.is_connected: self.wifi_icon_box.add_style_class("wifi-icon-box-connected") else: @@ -179,11 +192,9 @@ def on_changed(self, *_): def _show_password_dialog(self): """Show the WiFi password dialog""" - # Close the control center first if self.parent and hasattr(self.parent, "hide_controlcenter"): self.parent.hide_controlcenter() - # Create a new dialog each time to ensure clean state if self.password_dialog: self.password_dialog.destroy_dialog() @@ -198,22 +209,17 @@ def _show_password_dialog(self): def _on_password_connect(self, ssid, password): """Handle password dialog connect action""" if password.strip(): - # Show connecting state using connecting icon connecting_icon = get_wifi_connecting_icon() self.dimage.set_from_file(connecting_icon) self.dimage.add_style_class("connecting") - # Try to connect with password using callback def on_connection_result(success, message): """Handle the connection result""" if success: self.is_connected = True - # Remove connecting state after a short delay - from gi.repository import GLib GLib.timeout_add(500, lambda: self._reset_connect_state()) - # Clear any timeout in the password dialog if ( self.password_dialog and self.password_dialog.connection_timeout_id @@ -222,20 +228,21 @@ def on_connection_result(success, message): self.password_dialog.connection_timeout_id = None self.password_dialog.is_connecting = False else: - # Connection failed - show error in dialog self._reset_connect_state() if self.password_dialog: self._show_connection_error(message) - # Update display after connection attempt self.on_changed() try: - self.wifi_service.connect_to_wifi( - self.access_point, password, callback=on_connection_result + if self.network_service: + self.network_service.connect_wifi_with_password( + self.access_point.bssid, password, callback=on_connection_result + ) + except Exception as e: + logger.error( + f"[WiFi] Failed to initiate connection to {self.access_point.ssid}: {e}" ) - except Exception: - # Handle any connection errors gracefully self._reset_connect_state() if self.password_dialog: self._show_connection_error("Connection failed. Please try again.") @@ -249,7 +256,6 @@ def _show_connection_error(self, message="Incorrect password. Please try again." def _on_password_cancel(self): """Handle password dialog cancel action""" - # Reset any connecting state self._reset_connect_state() @@ -266,12 +272,16 @@ def __init__(self, parent, show_back_button=True, **kwargs): self.network_service = NetworkClient() self.wifi_service = None self.is_scanning = False # Track scanning state - self.refresh_timer = None # Timer for periodic network refresh self._update_in_progress = False # Prevent concurrent updates self._destroyed = False # Track if widget is destroyed + self._signal_ids = [] # Track all service signal IDs for cleanup - # Wait for network service to be ready - self.network_service.connect("wifi-device-added", self.on_network_ready) + self._signal_ids.append( + ( + self.network_service, + self.network_service.connect("device-ready", self.on_network_ready), + ) + ) # Create pull-to-refresh indicator self.refresh_indicator = Label( @@ -282,7 +292,6 @@ def __init__(self, parent, show_back_button=True, **kwargs): style="color: #fff; font-size: 12px; padding: 5px;", ) - # Create title with optional back button title_children = [] if show_back_button: title_children.append( @@ -298,7 +307,12 @@ def __init__(self, parent, show_back_button=True, **kwargs): children=title_children, ) - self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + self.toggle_button = SmoothSwitch( + name="toggle-button", + on_user_toggle=lambda active: self.on_toggle_changed( + self.toggle_button, active + ), + ) # Create Known Network section self.known_networks_label = Label( @@ -307,6 +321,13 @@ def __init__(self, parent, show_back_button=True, **kwargs): self.known_networks = Box( spacing=4, orientation="vertical", name="known-networks" ) + self.known_networks_scrolled = AnimatedScrollable( + min_content_size=(303, 0), + max_content_size=(303, 200), + child=self.known_networks, + overlay_scroll=True, + animate=False, + ) # Create "No networks available" message self.no_networks_label = Label( @@ -316,32 +337,29 @@ def __init__(self, parent, show_back_button=True, **kwargs): visible=False, ) - # Create Other Networks section with clickable title self.other_networks_button = Button( - child=Label("Other Networks", h_align="start"), + child=CenterBox( + start_children=Label("Other Networks", h_align="start"), + end_children=self.refresh_indicator, + ), name="wifi-other-button", on_clicked=self.toggle_other_networks, ) self.other_networks = Box(spacing=4, orientation="vertical") # Create scrolled window for other networks - self.other_networks_scrolled = ScrolledWindow( - min_content_size=(303, 150), + self.other_networks_scrolled = AnimatedScrollable( + min_content_size=(303, 0), + max_content_size=(303, 300), child=self.other_networks, overlay_scroll=True, + animate=False, ) + self.other_networks.set_visible(False) # Add pull-to-refresh functionality to scrolled window self.setup_pull_to_refresh() - # Create revealer for Other Networks section - self.other_networks_revealer = Revealer( - child=self.other_networks_scrolled, - transition_type="slide-down", - transition_duration=100, - child_revealed=False, - ) - # Create More Settings button (same style as Other Networks button) self.more_settings_button = Button( child=Label("More Settings", h_align="start"), @@ -355,52 +373,88 @@ def __init__(self, parent, show_back_button=True, **kwargs): end_children=self.toggle_button, name="wifi-widget-top", ), - self.refresh_indicator, Separator(orientation="h", name="separator"), self.known_networks_label, - self.known_networks, + self.known_networks_scrolled, self.no_networks_label, Separator(orientation="h", name="separator"), self.other_networks_button, - self.other_networks_revealer, + self.other_networks_scrolled, Separator(orientation="h", name="separator"), self.more_settings_button, ] - # Connect cleanup on destroy self.connect("destroy", self.on_destroy) + self.connect("unmap", self.on_hide) - # Start periodic network monitoring for real-time updates - self.start_network_monitoring() + def on_hide(self, *_): + """Called when the widget is hidden (popup closed)""" + self._cancel_pending_refresh() + if self.other_networks.get_visible(): + self.other_networks.set_visible(False) + self.other_networks_scrolled.snap_to_size(0) def toggle_other_networks(self, *_): """Toggle the visibility of other networks section""" - current_state = self.other_networks_revealer.child_revealed - self.other_networks_revealer.child_revealed = not current_state - - # Update button text based on state - if self.other_networks_revealer.child_revealed: - # Trigger a scan when revealing other networks and force refresh + current_state = self.other_networks.get_visible() + self._cancel_pending_refresh() + if current_state: + self.other_networks.set_visible(False) + self.other_networks_scrolled.snap_to_size(0) + else: + self.other_networks.set_visible(True) if self.wifi_service: self.wifi_service.scan() - # Also force an immediate network refresh to catch any missed connections - self.force_network_refresh() + # Defer refresh until after the height animation completes + GLib.idle_add(self._refresh_after_animation) + + def _cancel_pending_refresh(self): + if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler: + try: + self.other_networks_scrolled.height_animator.disconnect( + self._anim_finished_handler + ) + except Exception as e: + logger.warning( + f"[wifi] self.other_networks_scrolled.height_animator.disconnect( ... failed: {e}" + ) + self._anim_finished_handler = None + + def _refresh_after_animation(self): + if self._destroyed: + return False + anim = self.other_networks_scrolled.height_animator + if anim.playing: + self._anim_finished_handler = anim.connect( + "finished", + lambda *_: ( + self.force_network_refresh() if not self._destroyed else None + ), + ) + else: + self.force_network_refresh() + return False def on_network_ready(self, *_): """Called when network service is ready""" self.wifi_service = self.network_service.wifi_device if self.wifi_service: - # Set up WiFi toggle - self.toggle_button.set_active(self.wifi_service.wireless_enabled) - self.toggle_button.connect("notify::active", self.on_toggle_changed) - - # Connect to WiFi service signals - self.wifi_service.connect( - "notify::wireless-enabled", self.on_wifi_enabled_changed + self.toggle_button.set_active(self.wifi_service.enabled) + + self._signal_ids.append( + ( + self.wifi_service, + self.wifi_service.connect( + "notify::enabled", self.on_wifi_enabled_changed + ), + ) + ) + self._signal_ids.append( + ( + self.wifi_service, + self.wifi_service.connect("changed", self.update_networks), + ) ) - self.wifi_service.connect("changed", self.update_networks) - self.wifi_service.connect("ap-added", self.update_networks) - self.wifi_service.connect("ap-removed", self.update_networks) # Initial network update self.update_networks() @@ -409,23 +463,25 @@ def on_toggle_changed(self, toggle_button, *_): """Handle WiFi toggle button changes""" if self.wifi_service: new_state = toggle_button.get_active() - self.wifi_service.wireless_enabled = new_state + self.wifi_service.enabled = new_state def on_wifi_enabled_changed(self, *_): """Handle WiFi enabled state changes""" if self.wifi_service: - self.toggle_button.set_active(self.wifi_service.wireless_enabled) + self.toggle_button.set_active(self.wifi_service.enabled) def open_network_settings(self, *_): """Open NetworkManager connection editor""" try: - subprocess.Popen(["nm-connection-editor"], start_new_session=True) + spawn_detached(["nm-connection-editor"]) if self.parent and hasattr(self.parent, "hide_controlcenter"): self.parent.hide_controlcenter() - except FileNotFoundError: - pass - except Exception: - pass + except FileNotFoundError as e: + logger.warning( + f"[wifi] spawn_detached(['nm-connection-editor']) failed: {e}" + ) + except Exception as e: + logger.error(f"[WiFi] Failed to open network settings: {e}") def update_networks(self, *_): """Update the list of available networks""" @@ -436,7 +492,6 @@ def update_networks(self, *_): self._update_in_progress = True try: - # Store current network SSIDs to detect changes current_known_ssids = { child.ssid for child in self.known_networks.get_children() @@ -448,7 +503,6 @@ def update_networks(self, *_): if hasattr(child, "ssid") } - # Get current networks access_points = self.wifi_service.access_points known_networks = [] other_networks = [] @@ -458,8 +512,6 @@ def update_networks(self, *_): for access_point in access_points: try: if access_point.ssid and access_point.ssid != "Unknown": - # Categorize networks: connected or saved networks go to "Known Network" - # All others go to "Other Networks" if access_point.is_active or self._is_saved_network( access_point ): @@ -468,159 +520,128 @@ def update_networks(self, *_): else: other_networks.append(access_point) new_other_ssids.add(access_point.ssid) - except Exception: + except Exception as e: + logger.warning( + f"[WiFi] Failed to process access point {access_point}: {e}" + ) continue - # Check if we need to update (networks added/removed) known_changed = current_known_ssids != new_known_ssids other_changed = current_other_ssids != new_other_ssids - # Only rebuild if something actually changed if known_changed or other_changed: - # Clear existing networks safely - for child in list(self.known_networks.get_children()): - if not self._destroyed: - child.destroy() - for child in list(self.other_networks.get_children()): - if not self._destroyed: - child.destroy() + # Get existing networks + existing_known = { + child.ssid: child for child in self.known_networks.get_children() + } + existing_other = { + child.ssid: child for child in self.other_networks.get_children() + } - # Add known networks for access_point in known_networks: if not self._destroyed: - network_slot = WifiNetworkSlot( - access_point, self.wifi_service, parent=self.parent - ) - self.known_networks.add(network_slot) + if access_point.ssid in existing_known: + slot = existing_known.pop(access_point.ssid) + slot.update_ap(access_point) + elif access_point.ssid in existing_other: + slot = existing_other.pop(access_point.ssid) + slot.update_ap(access_point) + self.other_networks.remove(slot) + self.known_networks.add(slot) + else: + network_slot = WifiNetworkSlot( + access_point, + self.wifi_service, + network_service=self.network_service, + parent=self.parent, + ) + self.known_networks.add(network_slot) - # Add other networks for access_point in other_networks: if not self._destroyed: - network_slot = WifiNetworkSlot( - access_point, self.wifi_service, parent=self.parent - ) - self.other_networks.add(network_slot) + if access_point.ssid in existing_other: + slot = existing_other.pop(access_point.ssid) + slot.update_ap(access_point) + elif access_point.ssid in existing_known: + slot = existing_known.pop(access_point.ssid) + slot.update_ap(access_point) + self.known_networks.remove(slot) + self.other_networks.add(slot) + else: + network_slot = WifiNetworkSlot( + access_point, + self.wifi_service, + network_service=self.network_service, + parent=self.parent, + ) + self.other_networks.add(network_slot) + + for slot in existing_known.values(): + if hasattr(slot, "access_point"): + slot.strength = 0 + from shared.widgets.wifi_icon import get_wifi_icon_for_strength + + if not slot.dimage.has_style_class( + "connecting" + ) and not slot.dimage.has_style_class("disconnecting"): + slot.dimage.set_from_file(get_wifi_icon_for_strength(0)) + + for slot in existing_other.values(): + if hasattr(slot, "access_point"): + slot.strength = 0 + from shared.widgets.wifi_icon import get_wifi_icon_for_strength + + if not slot.dimage.has_style_class( + "connecting" + ) and not slot.dimage.has_style_class("disconnecting"): + slot.dimage.set_from_file(get_wifi_icon_for_strength(0)) - # Show/hide sections based on available networks if not self._destroyed: has_known_networks = len(known_networks) > 0 has_other_networks = len(other_networks) > 0 has_any_networks = has_known_networks or has_other_networks - # Show known networks section only if there are known networks - self.known_networks_label.set_visible(has_known_networks) - self.known_networks.set_visible(has_known_networks) - - # Show "No networks available" message if no networks at all + self.known_networks_scrolled.set_visible(True) self.no_networks_label.set_visible(not has_any_networks) - - # Always show the other networks button, regardless of available networks self.other_networks_button.set_visible(True) # Always visible - - # Update all network connection states self.refresh_network_states() - except Exception: - pass + except Exception as e: + logger.error(f"[WiFi] Error during update_networks: {e}") finally: self._update_in_progress = False def _is_saved_network(self, access_point): """Check if a network is saved/known using NetworkManager connections""" - if not self.network_service or not self.network_service._client: + if not self.network_service: return False - - try: - ssid = access_point.ssid - if not ssid or ssid == "Unknown": - return False - - # Get all saved connections from NetworkManager - connections = self.network_service._client.get_connections() - - for connection in connections: - # Check if this is a WiFi connection - if connection.get_connection_type() != "802-11-wireless": - continue - - # Get the wireless setting - wifi_setting = connection.get_setting_wireless() - if not wifi_setting: - continue - - # Compare SSIDs - connection_ssid_bytes = wifi_setting.get_ssid() - if connection_ssid_bytes: - from gi.repository import NM - - connection_ssid = NM.utils_ssid_to_utf8( - connection_ssid_bytes.get_data() - ) - if connection_ssid == ssid: - return True - - except Exception: - pass - - return False + ssid = access_point.ssid + if not ssid or ssid == "Unknown": + return False + return self.network_service.is_network_saved(ssid) def refresh_network_states(self, *_): """Refresh connection states for all network slots""" - # Refresh known networks for child in self.known_networks.get_children(): if hasattr(child, "on_changed"): child.on_changed() - # Refresh other networks for child in self.other_networks.get_children(): if hasattr(child, "on_changed"): child.on_changed() - def start_network_monitoring(self): - """Start periodic monitoring for network changes""" - # Monitor for network changes every 5 seconds - # This helps catch networks that connect from external sources - self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh) - - def stop_network_monitoring(self): - """Stop periodic monitoring""" - if self.refresh_timer: - GLib.source_remove(self.refresh_timer) - self.refresh_timer = None - - def periodic_network_refresh(self): - """Periodically refresh network list to catch external connections""" - # Skip if update in progress, destroyed, or wifi service not available - if ( - self._update_in_progress - or self._destroyed - or not self.wifi_service - or not self.wifi_service.wireless_enabled - ): - return True # Continue monitoring - - try: - # Simple check - just trigger update_networks which has its own safety checks - self.update_networks() - except Exception: - pass - - return True # Continue monitoring - def force_network_refresh(self): """Force an immediate refresh of the network list""" if self._update_in_progress or self._destroyed: return try: - # Simply trigger update_networks which has its own safety checks self.update_networks() - except Exception: - pass + except Exception as e: + logger.error(f"[WiFi] Error during forced network refresh: {e}") def setup_pull_to_refresh(self): """Setup pull-to-refresh gesture for the scrolled window""" - # Get the scrolled window's vertical adjustment self.vadjustment = self.other_networks_scrolled.get_vadjustment() # Track gesture state @@ -648,15 +669,13 @@ def setup_pull_to_refresh(self): def on_scroll_event(self, widget, event): """Handle scroll events for pull-to-refresh""" - # Only handle pull-to-refresh when at the top if self.vadjustment.get_value() <= 0: if event.direction == Gdk.ScrollDirection.UP: - # Scrolling up at the top - trigger scan and force refresh if self.wifi_service: self.wifi_service.scan() self.force_network_refresh() - return True # Consume the event - return False # Let normal scrolling continue + return True + return False def on_button_press(self, widget, event): """Handle button press for touch/drag gestures""" @@ -670,11 +689,9 @@ def on_button_release(self, widget, event): if self.is_pulling: pull_distance = event.y - self.pull_start_y if pull_distance > self.pull_threshold: - # Trigger scan and force refresh if self.wifi_service: self.wifi_service.scan() self.force_network_refresh() - # Hide refresh indicator self.refresh_indicator.set_visible(False) self.refresh_indicator.remove_style_class("ready-to-refresh") self.is_pulling = False @@ -685,7 +702,6 @@ def on_motion_notify(self, widget, event): if self.is_pulling and self.vadjustment.get_value() <= 0: pull_distance = event.y - self.pull_start_y if pull_distance > 0: - # Show refresh indicator when pulling down self.refresh_indicator.set_visible(True) if pull_distance >= self.pull_threshold: self.refresh_indicator.set_label("↑ Release to scan") @@ -699,17 +715,19 @@ def on_motion_notify(self, widget, event): def on_destroy(self, widget): """Cleanup when widget is destroyed""" - # Mark as destroyed to prevent further updates self._destroyed = True - # Stop monitoring - self.stop_network_monitoring() - # Make sure other networks revealer is collapsed when closing - try: - self.other_networks_revealer.child_revealed = False - except: - pass # Widget might already be destroyed + self._cancel_pending_refresh() + for obj, sig_id in self._signal_ids: + try: + obj.disconnect(sig_id) + except Exception as e: + logger.error(f"[WiFi] Failed to disconnect signal {sig_id}: {e}") + self._signal_ids.clear() + self.wifi_service = None + self.network_service = None def close_wifi(self): """Called when WiFi panel is being closed""" - # Collapse the other networks section when closing - self.other_networks_revealer.child_revealed = False + if self.other_networks.get_visible(): + self.other_networks.set_visible(False) + self.other_networks_scrolled.snap_to_size(0) diff --git a/src/window/desktop/__init__.py b/src/window/desktop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/desktop/constants.py b/src/window/desktop/constants.py new file mode 100644 index 00000000..f79a8783 --- /dev/null +++ b/src/window/desktop/constants.py @@ -0,0 +1,158 @@ +import datetime + +WEATHER_UPDATE_INTERVAL = 600 # 10 minutes +WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes +SYSTEM_UPDATE_INTERVAL = 60000 # 1 minute instead of 1 second +CALENDAR_UPDATE_INTERVAL = int( + ( + ( + datetime.datetime.combine( + datetime.date.today() + datetime.timedelta(days=1), datetime.time.min + ) + - datetime.datetime.now() + ).total_seconds() + ) + * 1000 +) # Calculate time till midnight + + +WEATHER_GRADIENT_MAP = { + # Clear/Sunny conditions - bright blue to lighter blue + 0: "weather-clear", # Clear sky + 1: "weather-mostly-clear", # Mainly clear + # Cloudy conditions - grey gradients + 2: "weather-partly-cloudy", # Partly cloudy + 3: "weather-overcast", # Overcast + # Fog conditions - muted grey/blue + 45: "weather-fog", # Fog + 48: "weather-fog", # Depositing rime fog + # Light rain/drizzle - blue-grey gradients + 51: "weather-light-rain", # Light drizzle + 53: "weather-rain", # Moderate drizzle + 55: "weather-rain", # Dense drizzle + 61: "weather-light-rain", # Slight rain + 80: "weather-light-rain", # Slight rain showers + # Heavy rain - darker blue-grey + 63: "weather-heavy-rain", # Moderate rain + 65: "weather-heavy-rain", # Heavy rain + 81: "weather-heavy-rain", # Moderate rain showers + 82: "weather-storm", # Violent rain showers + # Snow conditions - blue-white gradients + 56: "weather-snow", # Light freezing drizzle + 57: "weather-snow", # Dense freezing drizzle + 66: "weather-snow", # Light freezing rain + 67: "weather-snow", # Heavy freezing rain + 71: "weather-snow", # Slight snow fall + 73: "weather-heavy-snow", # Moderate snow fall + 75: "weather-heavy-snow", # Heavy snow fall + 77: "weather-snow", # Snow grains + 85: "weather-snow", # Slight snow showers + 86: "weather-heavy-snow", # Heavy snow showers + # Storm conditions - dark dramatic gradients + 95: "weather-storm", # Thunderstorm + 96: "weather-storm", # Thunderstorm with slight hail + 99: "weather-storm", # Thunderstorm with heavy hail +} + +# Weather condition to emoji mapping +WEATHER_EMOJI_MAP = { + 0: "☀️", # Clear sky + 1: "🌤️", # Mainly clear + 2: "⛅", # Partly cloudy + 3: "☁️", # Overcast + 45: "🌫️", # Fog + 48: "🌫️", # Depositing rime fog + 51: "🌦️", # Light drizzle + 53: "🌧️", # Moderate drizzle + 55: "🌧️", # Dense drizzle + 56: "🌨️", # Light freezing drizzle + 57: "🌨️", # Dense freezing drizzle + 61: "🌦️", # Slight rain + 63: "🌧️", # Moderate rain + 65: "🌧️", # Heavy rain + 66: "🌨️", # Light freezing rain + 67: "🌨️", # Heavy freezing rain + 71: "🌨️", # Slight snow fall + 73: "❄️", # Moderate snow fall + 75: "❄️", # Heavy snow fall + 77: "🌨️", # Snow grains + 80: "🌦️", # Slight rain showers + 81: "🌧️", # Moderate rain showers + 82: "⛈️", # Violent rain showers + 85: "🌨️", # Slight snow showers + 86: "❄️", # Heavy snow showers + 95: "⛈️", # Thunderstorm + 96: "⛈️", # Thunderstorm with slight hail + 99: "⛈️", # Thunderstorm with heavy hail +} + +# Weather condition to SVG icon mapping +WEATHER_ICON_MAP = { + 0: "weather-clear", + 1: "weather-few-clouds", + 2: "weather-clouds", + 3: "weather-overcast", + 45: "weather-fog", + 48: "weather-fog", + 51: "weather-showers-scattered", + 53: "weather-showers", + 55: "weather-showers", + 61: "weather-showers-scattered", + 80: "weather-showers-scattered", + 63: "weather-showers", + 65: "weather-showers", + 81: "weather-showers", + 82: "weather-storm", + 56: "weather-freezing-rain", + 57: "weather-freezing-rain", + 66: "weather-freezing-rain", + 67: "weather-freezing-rain", + 71: "weather-snow", + 73: "weather-snow", + 75: "weather-snow", + 77: "weather-hail", + 85: "weather-snow-scattered", + 86: "weather-snow-scattered", + 95: "weather-storm", + 96: "weather-storm", + 99: "weather-storm", +} + +# Weather condition descriptions +WEATHER_DESC_MAP = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snow", + 73: "Moderate snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Light rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with hail", + 99: "Thunderstorm with heavy hail", +} + +# Location APIs in order of preference (fastest first) +LOCATION_APIS = [ + "https://ipapi.co/json/", # Fastest, 200ms average + "http://ip-api.com/json/", # Fast fallback, 150ms average + "https://ipinfo.io/json", # Original fallback +] diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py new file mode 100644 index 00000000..93732dbc --- /dev/null +++ b/src/window/desktop/widget.py @@ -0,0 +1,713 @@ +import calendar +import datetime +import urllib.parse +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Tuple + +import httpx +import psutil +from fabric.core.service import Service, Signal +from fabric.utils import GLib, invoke_repeater, logger, time +from fabric.widgets.box import Box +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.datetime import DateTime +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.wayland import WaylandWindow as Window + +from shared.data import load_config +from utils.gtk_utils import svg_file +from window.desktop.constants import ( + CALENDAR_UPDATE_INTERVAL, + LOCATION_APIS, + SYSTEM_UPDATE_INTERVAL, + WEATHER_CACHE_TIMEOUT, + WEATHER_DESC_MAP, + WEATHER_GRADIENT_MAP, + WEATHER_ICON_MAP, + WEATHER_UPDATE_INTERVAL, +) + +# Icons that have a dedicated -night SVG variant. +_NIGHT_VARIANT_ICONS = frozenset( + ( + "weather-clear", + "weather-clouds", + "weather-few-clouds", + "weather-overcast", + "weather-showers", + "weather-showers-scattered", + "weather-snow", + "weather-snow-scattered", + "weather-storm", + ) +) + +_HTTP_HEADERS = {"User-Agent": "Modus-Desktop/1.0"} + + +class WeatherService(Service): + """Owns HTTP, caching, coordinate lookup and update scheduling for the + weather widget. Networking runs on a single background worker; results are + delivered back to the GLib main loop through the ``updated`` signal. + + This is an app-lifetime singleton: its executor, client and timer are never + torn down. Widgets observe it by connecting to ``updated`` and disconnect on + destroy, so the service survives widget rebuilds (config reloads, etc.).""" + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @staticmethod + def get_initial(): + if WeatherService._instance is None: + WeatherService._instance = WeatherService() + return WeatherService._instance + + @Signal + def updated(self, weather_info: object) -> None: + """Emitted on the main loop with fresh weather data (or None on failure).""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + super().__init__(**kwargs) + self._initialized = True + self._http = httpx.Client( + timeout=3.0, follow_redirects=True, headers=_HTTP_HEADERS + ) + self._executor = ThreadPoolExecutor(max_workers=1) + + # This shell shows one location at a time, so caches hold single values. + self._weather: Optional[List[str]] = None + self._weather_ts: float = 0.0 + self._location: Optional[str] = None + self._coords: Optional[Tuple[float, float]] = None + self._coords_location: Optional[str] = None + + # Periodic refresh only; widgets request the first fetch after + # connecting (see Weather.__init__) to avoid a race with subscription. + GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, self._refresh) + + # -- scheduling ------------------------------------------------------- + + def refresh(self) -> None: + """Trigger an immediate fetch (e.g. when a widget subscribes).""" + self._executor.submit(self._fetch) + + def _refresh(self) -> bool: + self._executor.submit(self._fetch) + return True + + # -- networking ------------------------------------------------------- + + def _get_json(self, url: str, timeout: float) -> Optional[Dict[str, Any]]: + try: + resp = self._http.get(url, timeout=timeout) + if resp.status_code == 200: + return resp.json() + except Exception as e: + logger.error(f"HTTP request to {url} failed: {e}") + return None + + def _get_location(self) -> str: + try: + manual_location = load_config().get("weather_location") + if manual_location: + return manual_location + except Exception as e: + logger.error(f"An error occurred: {e}") + + for api_url in LOCATION_APIS: + data = self._get_json(api_url, 2.0) + if data: + city = data.get("city", "") + if city: + return city + + logger.warning("All location APIs failed") + return "" + + def _get_coordinates(self, city: str) -> Optional[Tuple[float, float]]: + # Only query Nominatim when the location is new or unknown. + if self._coords is not None and self._coords_location == city: + return self._coords + + encoded_city = urllib.parse.quote(city) + url = ( + "https://nominatim.openstreetmap.org/search?" + f"q={encoded_city}&format=json&limit=1" + ) + data = self._get_json(url, 3.0) + if isinstance(data, list) and data: + try: + coords = (float(data[0]["lat"]), float(data[0]["lon"])) + except (ValueError, KeyError) as e: + logger.warning( + f"[widget] coords = (float(data[0]['lat']), float(data[0]['lon'])) failed: {e}" + ) + return None + self._coords = coords + self._coords_location = city + return coords + return None + + def _get_weather_data(self, lat: float, lon: float) -> Optional[Dict[str, Any]]: + url = ( + "https://api.open-meteo.com/v1/forecast?" + f"latitude={lat}&longitude={lon}" + "¤t_weather=true" + "&daily=temperature_2m_max,temperature_2m_min" + "&timezone=auto" + "&forecast_days=1" + ) + return self._get_json(url, 3.0) + + # -- background fetch (runs on the worker thread) --------------------- + + def _fetch(self) -> None: + location = self._get_location() + if not location: + return GLib.idle_add(self._deliver, None) + + cache_key = location.lower() + now = time.time() + if ( + self._weather is not None + and self._location == cache_key + and now - self._weather_ts < WEATHER_CACHE_TIMEOUT + ): + return GLib.idle_add(self._deliver, self._weather) + + coords = self._get_coordinates(location) + if not coords: + return GLib.idle_add(self._deliver, None) + + weather_data = self._get_weather_data(*coords) + if not weather_data: + return GLib.idle_add(self._deliver, None) + + formatted = _format_weather_data(weather_data, location) + if formatted: + self._weather = formatted + self._location = cache_key + self._weather_ts = now + GLib.idle_add(self._deliver, formatted) + else: + GLib.idle_add(self._deliver, None) + return None + + def _deliver(self, weather_info: Optional[List[str]]) -> None: + self.emit("updated", weather_info) + + +def _format_weather_data( + weather_data: Dict[str, Any], city: str +) -> Optional[List[str]]: + """Format weather data into the list consumed by the widget.""" + try: + current = weather_data["current_weather"] + daily = weather_data["daily"] + + weather_code = current["weathercode"] + is_day = current.get("is_day", 1) # Default to day if missing + + base_icon = WEATHER_ICON_MAP.get(weather_code, "weather-none-available") + icon_name = base_icon + if not is_day and base_icon in _NIGHT_VARIANT_ICONS: + icon_name = f"{base_icon}-night" + + condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") + gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear") + + temp = f"{round(current['temperature'])}°" + max_temp = f"{round(daily['temperature_2m_max'][0])}°" + min_temp = f"{round(daily['temperature_2m_min'][0])}°" + + return [icon_name, temp, condition, city, max_temp, min_temp, gradient_class] + except (KeyError, IndexError, TypeError) as e: + logger.error(f"Error formatting weather data: {e}") + return None + + +class Weather(Box): + def __init__(self, parent, **kwargs): + super().__init__( + name="weather-widget", + h_expand=True, + v_expand=True, + justification="right", + orientation="v", + all_visible=False, + **kwargs, + ) + self.parent = parent + self.weatherinfo = None + self._create_labels() + self._layout_labels() + self._service = WeatherService.get_initial() + self._service.connect("updated", self._on_service_update) + self._service.refresh() + + def _create_labels(self): + self.header = Box(orientation="h", h_expand=True) + self.body = Box(orientation="v", v_expand=True, valign="end") + + self.city = Label( + name="city", + label="Loading...", + justification="left", + h_align="start", + max_chars_width=15, + ellipsization="end", + ) + self.temperature = Label(name="temperature", label="--°", h_align="start") + self.condition_em = svg_file( + "weather/weather-none-available.svg", size=(35, 35), name="condition-emoji" + ) + self.condition = Label( + name="condition", + label="Loading...", + max_chars_width=18, + ellipsization="end", + h_align="start", + ) + self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start") + + def _layout_labels(self): + # Header: City (left) and Icon (right) + self.header.add(self.city) + self.header.pack_end(self.condition_em, False, False, 0) + + # Body: Temp, Condition, High/Low + self.body.add(self.temperature) + self.body.add(self.condition) + self.body.add(self.feels_like) + + self.add(self.header) + self.add(self.body) + + def update_labels(self, weather_info: List[str]): + if not weather_info or len(weather_info) != 7: + return + icon_name, temp, condition, location, maxtemp, mintemp, gradient_class = ( + weather_info + ) + maxmin = f"L:{mintemp} H:{maxtemp}" + + self.city.set_label(location) + self.temperature.set_label(temp) + + # update SVG File + self.condition_em.dynamic_file(f"weather/{icon_name}.svg") + + self.condition.set_label(condition) + self.feels_like.set_label(maxmin) + + # Apply gradient class to container + if hasattr(self, "parent") and self.parent: + # Remove old weather classes + for cls in self.parent.get_style_context().list_classes(): + if cls.startswith("weather-"): + self.parent.remove_style_class(cls) + # Add new one + self.parent.add_style_class(gradient_class) + + self.parent.set_visible(True) + + def _on_service_update(self, _service, weather_info): + if weather_info: + self.weatherinfo = weather_info + self.update_labels(weather_info) + + def destroy(self): + """Disconnect from the weather service (the service is app-lifetime).""" + try: + self._service.disconnect_by_func(self._on_service_update) + except ValueError as e: + logger.warning( + f"[widget] self._service.disconnect_by_func(self._on_service_update) failed: {e}" + ) + super().destroy() + + +class WeatherContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="weather-container", + v_expand=True, + v_align="center", + size=(170, 170), + visible=True, + h_align="center", + children=[Weather(self)], + **kwargs, + ) + + +class Date(Box): + def __init__(self, **kwargs): + super().__init__( + name="date-widget", + h_expand=True, + v_expand=True, + justification="center", + h_align="center", + v_align="start", + orientation="v", + **kwargs, + ) + self.top = Box(orientation="h", name="date-top", h_expand=True) + date_interval = 10000 + self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") + self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") + self.datethree = DateTime( + formatters=["%-d"], interval=date_interval, name="date" + ) + self.top.add(self.dateone) + self.top.add(self.datetwo) + self.add(self.top) + self.add(self.datethree) + + +class DateContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="date-container", + v_expand=True, + size=(170, 170), + v_align="center", + h_align="center", + children=[Date()], + **kwargs, + ) + + +class Calendar(Box): + def __init__(self, **kwargs): + calendar.setfirstweekday(6) + super().__init__( + name="calendar-widget", + h_expand=True, + v_expand=True, + orientation="v", + **kwargs, + ) + self._update_current_date() + self._create_header() + self._create_days_header() + self._create_calendar_grid() + self.add(self.month_label) + self.add(self.days_header) + self.add(self.calendar_grid) + self._calendar_timer_id = invoke_repeater( + CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed + ) + + def destroy(self): + """Cleanup calendar update timer""" + if hasattr(self, "_calendar_timer_id") and self._calendar_timer_id: + GLib.source_remove(self._calendar_timer_id) + self._calendar_timer_id = None + super().destroy() + + def _update_current_date(self): + now = datetime.datetime.now() + self.current_month, self.current_year, self.current_day = ( + now.month, + now.year, + now.day, + ) + + def _create_header(self): + self.month_label = Label( + name="calendar-month", + label=calendar.month_name[self.current_month], + h_align="start", + justification="left", + ) + + def _create_days_header(self): + self.days_header = Box( + name="calendar-days-header", orientation="h", h_expand=True, spacing=2 + ) + for i, day_name in enumerate(["S", "M", "T", "W", "T", "F", "S"]): + self.days_header.add( + Label( + name="calendar-day-header-weekend" + if i in (0, 6) + else "calendar-day-header", + label=day_name, + h_align="center", + h_expand=True, + ) + ) + + def _create_calendar_grid(self): + self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) + self.update_calendar() + + def update_calendar_if_needed(self) -> bool: + now = datetime.datetime.now() + if (now.month, now.year, now.day) != ( + self.current_month, + self.current_year, + self.current_day, + ): + self._update_current_date() + self.update_calendar() + return True + + def update_calendar(self): + from utils.functions import clear_children + + clear_children(self.calendar_grid) + self.month_label.set_label(calendar.month_name[self.current_month]) + cal = calendar.monthcalendar(self.current_year, self.current_month) + for week in cal: + week_box = Box(orientation="h", spacing=2, h_expand=True) + for i, day in enumerate(week): + if day == 0: + label = Label( + name="calendar-day-empty", + label="", + h_align="center", + h_expand=True, + ) + else: + is_today = ( + day == self.current_day + and self.current_month == datetime.datetime.now().month + ) + name = ( + "calendar-day-today" + if is_today + else ("calendar-day-weekend" if i in (0, 6) else "calendar-day") + ) + label = Label( + name=name, label=str(day), h_align="center", h_expand=True + ) + week_box.add(label) + self.calendar_grid.add(week_box) + + +class CalendarContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="calendar-box-widget", + v_expand=True, + size=(170, 170), + v_align="center", + h_align="center", + children=[Calendar()], + **kwargs, + ) + + +class SystemInfoBase(Box): + @staticmethod + def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): + return CircularProgressBar( + name=name, + start_angle=270, + end_angle=630, + min_value=0, + max_value=100, + size=size, + **kwargs, + ) + + def __init__(self, name: str, **kwargs): + super().__init__( + layer="bottom", + title="sysinfo", + name=name, + visible=True, + size=(170, 170), + h_expand=True, + v_expand=True, + all_visible=True, + **kwargs, + ) + self.progress = self.create_progress_bar(name="progress") + self.main_label = Label( + label="0%\nLoading", justification="center", name="progress-label" + ) + self.info_container = Box( + name="info-container", orientation="v", spacing=2, h_align="center" + ) + self.add( + Box( + name="progress-bar-container", + h_expand=True, + v_expand=True, + orientation="v", + spacing=12, + h_align="center", + v_align="center", + children=[ + Box( + children=[ + Overlay( + child=self.progress, + tooltip_text="", + overlays=self.main_label, + ) + ] + ), + Box( + h_align="center", + justification="centre", + orientation="v", + children=[self.info_container], + ), + ], + ) + ) + + def start_updates(self): + self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) + + def destroy(self): + """Cleanup system info update timer""" + if hasattr(self, "_system_timer_id") and self._system_timer_id: + GLib.source_remove(self._system_timer_id) + self._system_timer_id = None + super().destroy() + + def create_info_line( + self, indicator_name: str, info_text: str, value_text: str + ) -> Box: + line = Box( + orientation="h", + spacing=4, + h_align="start", + children=[ + Label(label="■", name=indicator_name), + Label(label=info_text, name="info-text"), + Label(label=value_text, name="info-value"), + ], + ) + line.value_label = line.get_children()[2] + return line + + def update(self) -> bool: + raise NotImplementedError + + +class RamInfo(SystemInfoBase): + def __init__(self, **kwargs): + super().__init__("info-box-widget", **kwargs) + self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB") + self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB") + self.info_container.add(self.used_line) + self.info_container.add(self.free_line) + self.start_updates() + + def update(self) -> bool: + try: + mem = psutil.virtual_memory() + self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") + self.used_line.value_label.set_label(f"{round(mem.used / (1024**3), 1)}GB") + self.free_line.value_label.set_label( + f"{round(mem.available / (1024**3), 1)}GB" + ) + GLib.idle_add(self.progress.set_value, mem.percent) + except Exception as e: + logger.error(f"Error: {e}") + return True + + +class CpuInfo(SystemInfoBase): + def __init__(self, **kwargs): + super().__init__("info-box-widget", **kwargs) + self.temp_value = Label(label="0°C", name="info-value") + self.info_container.add( + Box( + orientation="h", + spacing=4, + h_align="start", + children=[Label(label="Temp", name="info-text"), self.temp_value], + ) + ) + self.start_updates() + + def get_cpu_temp(self) -> Optional[float]: + try: + temps = psutil.sensors_temperatures() + for name, entries in temps.items(): + if any(s in name.lower() for s in ["coretemp", "k10temp", "cpu"]): + for entry in entries: + if any( + p in (entry.label or "").lower() + for p in ["package id 0", "core 0", ""] + ): + return round(entry.current, 1) + except Exception as e: + logger.error(f"An error occurred: {e}") + return None + + def update(self) -> bool: + try: + cpu = psutil.cpu_percent() + self.main_label.set_label(f" {round(cpu):<2} %\nCPU") + temp = self.get_cpu_temp() + self.temp_value.set_label(f"{temp}°C" if temp else "N/A") + GLib.idle_add(self.progress.set_value, cpu) + except Exception as e: + logger.error(f"Error: {e}") + return True + + +class Deskwidgets: + """Desktop widgets manager - handles all desktop widgets.""" + + config = load_config() + + def __init__(self): + # This class now just manages other windows instead of being one itself + + # Create separate independent windows as attributes + self.top_left = Window( + anchor="top left", + title="modus-widgets", + exclusivity="none", + orientation="h", + layer="bottom", + visible=False, # Start hidden until content ready + child=Box( + name="desktop-widgets-container", + children=[ + DateContainer(), + WeatherContainer(), + CalendarContainer(), + ], + ), + ) + + self.bottom_left = Window( + anchor="bottom right", + title="modus-widgets", + orientation="h", + layer="bottom", + exclusivity="none", + visible=False, # Start hidden until content ready + child=Box( + name="desktop-widgets-container", + children=[ + CpuInfo(), + RamInfo(), + ], + ), + ) + + # Show widgets after initialization is complete + self.top_left.set_visible(True) + self.bottom_left.set_visible(True) diff --git a/src/window/dock/__init__.py b/src/window/dock/__init__.py new file mode 100644 index 00000000..11359d35 --- /dev/null +++ b/src/window/dock/__init__.py @@ -0,0 +1,3 @@ +from .main import Dock + +__all__ = ["Dock"] diff --git a/src/window/dock/animator.py b/src/window/dock/animator.py new file mode 100644 index 00000000..90bb2516 --- /dev/null +++ b/src/window/dock/animator.py @@ -0,0 +1,67 @@ +from typing import Optional + +from fabric.utils import GLib + +from .constants import ANIM_INTERVAL_MS, IDLE_THRESHOLD, LERP_FACTOR +from .layout import DockLayout + + +class DockAnimator: + def __init__(self, canvas): + self._canvas = canvas + self._timer_id: Optional[int] = None + self._running = False + + def start(self) -> None: + if self._running: + return + self._running = True + self._timer_id = GLib.timeout_add(ANIM_INTERVAL_MS, self._tick) + + def stop(self) -> None: + self._running = False + if self._timer_id is not None: + GLib.source_remove(self._timer_id) + self._timer_id = None + + def _tick(self) -> bool: + if not self._running: + return False + + canvas = self._canvas + items = canvas.model.items + dirty = False + + for item in items: + delta = item.target_scale - item.current_scale + if abs(delta) > IDLE_THRESHOLD: + item.current_scale += delta * LERP_FACTOR + dirty = True + else: + item.current_scale = item.target_scale + + model_w = canvas._canvas_max_width() + model_h = canvas._canvas_height() + DockLayout.compute( + items, + canvas._mouse_x, + canvas._mouse_y, + canvas._base_icon_size(), + model_w, + model_h, + canvas._mouse_inside, + ) + + if ( + model_w != canvas._last_requested_width + or model_h != canvas._last_requested_height + ): + canvas._last_requested_width = model_w + canvas._last_requested_height = model_h + canvas._update_size_request() + + if dirty or canvas._needs_redraw: + canvas._needs_redraw = False + canvas.queue_draw() + + return self._running diff --git a/src/window/dock/canvas.py b/src/window/dock/canvas.py new file mode 100644 index 00000000..24358d0e --- /dev/null +++ b/src/window/dock/canvas.py @@ -0,0 +1,1051 @@ +import math +from typing import Dict, List, Optional, Tuple + +import cairo +import tomlkit +from fabric.utils import ( + Gdk, + GdkPixbuf, + GLib, + Gtk, + get_desktop_applications, + logger, + os, +) +from gi.repository import Rsvg + +from services.config import config +from services.modus import ( + close_window, + get_active_window, + get_clients, + get_modus_service, + launch_app, + open_trash, +) +from utils.functions import clear_children, is_special_workspace_id +from utils.gtk_utils import svg_file +from utils.icon_resolver import IconResolver + +from .animator import DockAnimator +from .constants import ( + BADGE_BG, + BADGE_FG, + BADGE_FONT_SIZE, + BADGE_PADDING, + BADGE_RADIUS, + BG_PADDING_H, + BG_PADDING_V, + CANVAS_TOP_PAD, + ICON_GAP, + INDICATOR_COLOR, + INDICATOR_H, + INDICATOR_RADIUS, + MAX_SCALE, + MIN_SCALE, + PINNED_APPS_FILE, + SEPARATOR_COLOR, + SEPARATOR_WIDTH, +) +from .items import DockHitTest, DockItem, DockModel +from .layout import DockLayout + +_PIXBUF_CACHE_MAX = 64 + +# Terminal/interpreter executables whose name alone should not force a match +# against unrelated desktop apps during dock item resolution. +_COMMON_EXECUTABLES = ("kitty", "bash", "sh", "zsh", "python", "python3") + + +class DockCanvas(Gtk.DrawingArea): + def __init__(self, parent_window): + super().__init__() + + self._parent = parent_window + self.icon_resolver = IconResolver() + self._hyprland_connection = get_modus_service()._hyprland_connection + + self.model = DockModel() + self._pixbuf_cache: Dict[Tuple[str, int], Optional[GdkPixbuf.Pixbuf]] = {} + + self._mouse_x: float = -9999.0 + self._mouse_y: float = -9999.0 + self._mouse_inside: bool = False + self._needs_redraw: bool = True + + self._focused_address: str = "" + self._dock_update_timer: Optional[int] = None + self._hyprland_event_handlers: List = [] + self._last_requested_width: int = 0 + self._last_requested_height: int = 0 + self._modus_ws_handler: int | None = None + + self._desktop_apps: list = [] + self._trash_pixbuf: Optional[GdkPixbuf.Pixbuf] = None + self._cached_trash_full: Optional[bool] = None + self._cached_trash_size: int = 0 + + self.pinned_apps: List = self._read_pinned_apps() + self.menu = Gtk.Menu() + self._configure_widget() + self.animator = DockAnimator(self) + + self._rebuild_model() + self.setup_app_monitoring() + self.animator.start() + + self.show() + + def _configure_widget(self) -> None: + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + | Gdk.EventMask.ENTER_NOTIFY_MASK + | Gdk.EventMask.SCROLL_MASK + ) + + self.connect("draw", self._on_draw) + self.connect("button-press-event", self._on_button_press) + self.connect("motion-notify-event", self._on_motion) + self.connect("enter-notify-event", self._on_enter) + self.connect("leave-notify-event", self._on_leave) + self.connect("size-allocate", self._on_size_allocate) + + self.set_hexpand(False) + self.set_vexpand(False) + self.set_app_paintable(True) + + screen = self.get_screen() + if screen: + visual = screen.get_rgba_visual() + if visual: + self.set_visual(visual) + + def _base_icon_size(self) -> int: + return int(config().get("dock_icon_size", 52)) + + def _icon_cache_size(self) -> int: + return int(self._base_icon_size() * MAX_SCALE) + + def _get_desktop_apps(self) -> list: + if not self._desktop_apps: + try: + self._desktop_apps = get_desktop_applications(include_hidden=False) + except Exception: + self._desktop_apps = [] + return self._desktop_apps + + def _canvas_height(self) -> int: + size = self._base_icon_size() + return int( + size * MAX_SCALE + CANVAS_TOP_PAD + 2 * BG_PADDING_V + INDICATOR_H + 4 + ) + + def _canvas_max_width(self) -> int: + n = len(self.model.items) + if n == 0: + return 200 + size = self._base_icon_size() + return int( + n * size * MAX_SCALE + max(n - 1, 0) * ICON_GAP + 2 * BG_PADDING_H + 40 + ) + + def _canvas_min_width(self) -> int: + return self._canvas_max_width() + + def _update_size_request(self) -> None: + w = self._canvas_max_width() + h = self._canvas_height() + self.set_size_request(w, h) + self.queue_resize() + + def _on_size_allocate(self, widget, allocation) -> None: + self._needs_redraw = True + + def do_get_preferred_height(self): + h = self._canvas_height() + return h, h + + def do_get_preferred_width(self): + w = self._canvas_max_width() + return w, w + + def _get_pixbuf(self, icon_name: str, size: int) -> Optional[GdkPixbuf.Pixbuf]: + key = (icon_name, size) + if key not in self._pixbuf_cache: + while len(self._pixbuf_cache) >= _PIXBUF_CACHE_MAX: + self._pixbuf_cache.pop(next(iter(self._pixbuf_cache)), None) + self._pixbuf_cache[key] = self.icon_resolver.get_icon_pixbuf( + icon_name, size + ) + return self._pixbuf_cache[key] + + def _clear_pixbuf_cache(self) -> None: + self._pixbuf_cache.clear() + + def _get_app_icon_pixbuf( + self, app_data, app=None, size: Optional[int] = None + ) -> Optional[GdkPixbuf.Pixbuf]: + if size is None: + size = self._base_icon_size() + + icon_name = None + if app and hasattr(app, "icon_name") and app.icon_name: + icon_name = app.icon_name + elif isinstance(app_data, dict): + icon_name = app_data.get("window_class") or app_data.get("name") or "" + elif isinstance(app_data, str): + icon_name = app_data + + return self._get_pixbuf(icon_name or "application-x-executable", size) + + def _rebuild_model(self) -> None: + old_scales: Dict[str, float] = {} + for item in self.model.items: + key = item.instance_address or item.app_id + old_scales[key] = item.current_scale + + try: + clients = self._get_clients() + focused = self._get_focused_window() + self._focused_address = (focused or {}).get("address", "") + except Exception as e: + logger.error(f"[DockCanvas] Error fetching clients: {e}") + clients = [] + + desktop_apps = self._get_desktop_apps() + size = self._icon_cache_size() + + active_clients = { + c.get("address"): c + for c in clients + if not c.get("hidden", False) and self._should_show_app_instance(c) + } + + running_classes_lower = { + (c.get("class", "") or c.get("title", "")).lower() + for c in active_clients.values() + } + + pinned_ids_lower = { + self._get_app_identifier(a).lower() for a in self.pinned_apps + } + + new_items: List[DockItem] = [] + + for app_data in self.pinned_apps: + app_id = self._get_app_identifier(app_data) + if not app_id: + continue + + existing = self.model.get_by_id(app_id) + if existing and existing.is_pinned: + item = existing + currently_running = app_id.lower() in running_classes_lower + item.is_running = currently_running + if not currently_running: + item.instance_address = None + item.client_data = None + else: + app = self._find_desktop_app(app_data, desktop_apps) + pixbuf = self._get_app_icon_pixbuf(app_data, app, size) + display_name = ( + app.display_name + if app + else ( + app_data.get("display_name", app_id) + if isinstance(app_data, dict) + else app_id + ) + ) + item = DockItem( + app_id=app_id, + app_data=app_data, + is_pinned=True, + pixbuf=pixbuf, + tooltip=display_name, + ) + + key = app_id + item.current_scale = old_scales.get(key, MIN_SCALE) + new_items.append(item) + + self._update_trash_item(new_items, old_scales, size) + + for client in active_clients.values(): + instance_address = client.get("address", "") + app_class = client.get("class", "") or client.get("title", "") + if not instance_address or not app_class: + continue + + if app_class.lower() in pinned_ids_lower: + pinned = next( + (i for i in new_items if i.app_id.lower() == app_class.lower()), + None, + ) + if pinned: + pinned.is_running = True + pinned.instance_address = instance_address + pinned.app_class = app_class + pinned.client_data = client + pinned.workspace_id = self._get_workspace_id(client) + continue + + existing = self.model.get_by_address(instance_address) + if existing and not existing.is_pinned and not existing.is_trash: + item = existing + tooltip = client.get("title", app_class) + item.tooltip = ( + f"{app_class}: {tooltip}" if tooltip != app_class else app_class + ) + item.client_data = client + item.workspace_id = self._get_workspace_id(client) + else: + app = self._find_desktop_app(app_class, desktop_apps) + pixbuf = self._get_app_icon_pixbuf(app_class, app, size) + tooltip = client.get("title", app_class) + if tooltip != app_class: + tooltip = f"{app_class}: {tooltip}" + item = DockItem( + app_id=app_class, + app_data=app_class, + is_pinned=False, + is_running=True, + instance_address=instance_address, + app_class=app_class, + client_data=client, + workspace_id=self._get_workspace_id(client), + pixbuf=pixbuf, + tooltip=tooltip, + ) + + key = instance_address + item.current_scale = old_scales.get(key, MIN_SCALE) + new_items.append(item) + + self.model.items = new_items + self._needs_redraw = True + + def _update_trash_item( + self, + new_items: List[DockItem], + old_scales: Dict[str, float], + size: int, + ) -> None: + trash_full = self._trash_has_files() + if ( + self._trash_pixbuf is None + or trash_full != self._cached_trash_full + or size != self._cached_trash_size + ): + self._cached_trash_full = trash_full + self._cached_trash_size = size + trash_svg = "trash-full.svg" if trash_full else "trash-empty.svg" + try: + svg_w = svg_file(f"misc/{trash_svg}") + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + cr = cairo.Context(surface) + rect = Rsvg.Rectangle() + rect.x = rect.y = 0 + rect.width = size + rect.height = size + svg_w._handle.set_dpi(160) + svg_w._handle.render_document(cr, rect) + self._trash_pixbuf = Gdk.pixbuf_get_from_surface( + surface, 0, 0, size, size + ) + del svg_w + except Exception: + self._trash_pixbuf = self._get_pixbuf("user-trash", size) + + existing = self.model.get_by_id("trash") + if existing and existing.is_trash: + item = existing + item.pixbuf = self._trash_pixbuf + else: + item = DockItem( + app_id="trash", + app_data="trash", + is_pinned=True, + is_trash=True, + pixbuf=self._trash_pixbuf, + tooltip="Trash", + ) + item.current_scale = old_scales.get("trash", MIN_SCALE) + new_items.append(item) + + def setup_app_monitoring(self) -> None: + events = [ + "event::openwindow", + "event::closewindow", + "event::movewindow", + ] + for event in events: + handler_id = self._hyprland_connection.connect( + event, lambda *_: self.debounced_update_dock_apps() + ) + self._hyprland_event_handlers.append(handler_id) + + self._hyprland_event_handlers.append( + self._hyprland_connection.connect( + "event::activewindow", self._on_active_window_changed + ) + ) + + self._hyprland_event_handlers.append( + self._hyprland_connection.connect( + "event::windowtitle", self._on_window_title_changed + ) + ) + + self._modus_ws_handler = get_modus_service().connect( + "current-workspace-changed", self._on_modus_workspace_changed + ) + + GLib.idle_add(self._rebuild_model) + + def _on_modus_workspace_changed(self, service, value) -> None: + try: + clients = self._get_clients() + except Exception as e: + logger.warning(f"[canvas] clients = self._get_clients() failed: {e}") + return + client_map = {c.get("address"): c for c in clients if c.get("address")} + for item in self.model.items: + if item.instance_address and item.instance_address in client_map: + item.workspace_id = self._get_workspace_id( + client_map[item.instance_address] + ) + self._needs_redraw = True + self.queue_draw() + + def _on_active_window_changed(self, *args) -> None: + try: + focused = get_active_window() + self._focused_address = (focused or {}).get("address", "") + except Exception: + self._focused_address = "" + self._needs_redraw = True + self.queue_draw() + + def _on_window_title_changed(self, _hyprland, signal) -> None: + try: + address = signal.data[0] + if not address: + return + for item in self.model.items: + if item.instance_address == address: + clients = self._get_clients() + client = next( + (c for c in clients if c.get("address") == address), None + ) + if client: + title = client.get("title", "") + app_class = item.app_class or "" + item.tooltip = ( + f"{app_class}: {title}" if title != app_class else app_class + ) + break + except Exception as e: + logger.warning(f"[canvas] address = signal.data[0] failed: {e}") + self._needs_redraw = True + self.queue_draw() + + def debounced_update_dock_apps(self) -> None: + if self._dock_update_timer: + GLib.source_remove(self._dock_update_timer) + self._dock_update_timer = GLib.timeout_add(50, self._do_update) + + def _do_update(self) -> bool: + self._dock_update_timer = None + try: + self._rebuild_model() + except Exception as e: + logger.error(f"[DockCanvas] Error in update: {e}") + return False + + def update_icon_size(self) -> None: + self._clear_pixbuf_cache() + self._rebuild_model() + + def _on_draw(self, widget, cr: cairo.Context) -> bool: + w = self.get_allocated_width() + h = self.get_allocated_height() + items = self.model.items + base_size = self._base_icon_size() + + cr.set_operator(cairo.OPERATOR_CLEAR) + cr.paint() + cr.set_operator(cairo.OPERATOR_OVER) + + if not items: + return True + + bg_x, bg_y, bg_w, bg_h = DockLayout.background_rect(items, base_size, w, h) + + self._draw_background(cr, bg_x, bg_y, bg_w, bg_h) + + if self.model.has_pinned() and self.model.has_running_only(): + self._draw_separator(cr, items, bg_y, bg_h, base_size) + + for item in items: + self._draw_item(cr, item, base_size, h) + + return True + + def _rounded_rect( + self, + cr: cairo.Context, + x: float, + y: float, + w: float, + h: float, + r: float, + ) -> None: + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + def _draw_background( + self, + cr: cairo.Context, + x: float, + y: float, + w: float, + h: float, + ) -> None: + context = self.get_style_context() + context.save() + context.add_class("dock-background") + + # Render the CSS background (includes background-color, background-image/gradients, box-shadow) + Gtk.render_background(context, cr, x, y, w, h) + + # Render the CSS border (includes border-color, border-width, border-style) + Gtk.render_frame(context, cr, x, y, w, h) + + context.restore() + + def _draw_separator( + self, + cr: cairo.Context, + items: List[DockItem], + bg_y: float, + bg_h: float, + base_size: int, + ) -> None: + last_pinned_x: Optional[float] = None + first_running_x: Optional[float] = None + + for item in items: + if item.is_pinned: + last_pinned_x = item.render_x + item.render_w + elif first_running_x is None: + first_running_x = item.render_x + + if last_pinned_x is None or first_running_x is None: + return + + sep_x = (last_pinned_x + first_running_x) / 2.0 + sep_margin = BG_PADDING_V + 4 + cr.save() + cr.set_source_rgba(*SEPARATOR_COLOR) + cr.set_line_width(SEPARATOR_WIDTH) + cr.move_to(sep_x, bg_y + sep_margin) + cr.line_to(sep_x, bg_y + bg_h - sep_margin) + cr.stroke() + cr.restore() + + def _draw_item( + self, + cr: cairo.Context, + item: DockItem, + base_size: int, + canvas_h: int, + ) -> None: + ix = item.render_x + iy = item.render_y + iw = item.render_w + ih = item.render_h + + if iw <= 0 or ih <= 0: + return + + if item.pixbuf: + cr.save() + pb_w = item.pixbuf.get_width() + pb_h = item.pixbuf.get_height() + if pb_w > 0 and pb_h > 0: + sx = iw / pb_w + sy = ih / pb_h + cr.translate(ix, iy) + cr.scale(sx, sy) + Gdk.cairo_set_source_pixbuf(cr, item.pixbuf, 0, 0) + cr.paint() + cr.restore() + else: + cr.save() + cr.set_source_rgba(0.4, 0.4, 0.5, 0.8) + cr.rectangle(ix, iy, iw, ih) + cr.fill() + cr.restore() + + if item.instance_address and item.instance_address == self._focused_address: + dot_cx = ix + iw / 2.0 + dot_cy = iy + ih + 4 + cr.save() + cr.set_source_rgba(*INDICATOR_COLOR) + cr.arc(dot_cx, dot_cy, INDICATOR_RADIUS, 0, 2 * math.pi) + cr.fill() + cr.restore() + + if item.workspace_id is not None and not item.is_pinned: + self._draw_workspace_badge(cr, ix, iy, iw, ih, str(item.workspace_id)) + + def _draw_workspace_badge( + self, + cr: cairo.Context, + ix: float, + iy: float, + iw: float, + ih: float, + text: str, + ) -> None: + cr.save() + cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cr.set_font_size(BADGE_FONT_SIZE) + + x_bearing, y_bearing, tw, th, _, _ = cr.text_extents(text) + bw = tw + 2 * BADGE_PADDING + bh = th + 2 * BADGE_PADDING + + bx = ix + iw - bw - 2 + by = iy + ih - bh - 2 + + cr.set_source_rgba(*BADGE_BG) + self._rounded_rect(cr, bx, by, bw, bh, BADGE_RADIUS) + cr.fill() + + cr.set_source_rgba(*BADGE_FG) + cr.move_to(bx + BADGE_PADDING - x_bearing, by + BADGE_PADDING - y_bearing) + cr.show_text(text) + + cr.restore() + + def _on_motion(self, widget, event: Gdk.EventMotion) -> bool: + self._mouse_x = event.x + self._mouse_y = event.y + + items = self.model.items + if items: + size = self._base_icon_size() + n = len(items) + total_base = n * size + max(n - 1, 0) * ICON_GAP + w = self.get_allocated_width() + base_start_x = (w - total_base) / 2.0 + h = self.get_allocated_height() + bg_h = size + 2 * BG_PADDING_V + bg_y = h - INDICATOR_H - bg_h + baseline_y = bg_y + BG_PADDING_V + size + icon_top_y = baseline_y - size * MAX_SCALE + self._mouse_inside = ( + base_start_x <= event.x <= base_start_x + total_base + and icon_top_y <= event.y <= baseline_y + ) + else: + self._mouse_inside = False + + item = DockHitTest.find_item(self.model.items, event.x, event.y) + self.set_tooltip_text(item.tooltip if item else "") + + if not self.animator._running: + self.animator.start() + + return False + + def _on_enter(self, widget, event: Gdk.EventCrossing) -> bool: + self._mouse_inside = False + self._parent.on_hover_enter() + if not self.animator._running: + self.animator.start() + return False + + def _on_leave(self, widget, event: Gdk.EventCrossing) -> bool: + self._mouse_inside = False + self._mouse_x = -9999.0 + self._mouse_y = -9999.0 + self.set_tooltip_text("") + self._parent.on_hover_leave() + return False + + def _on_button_press(self, widget, event: Gdk.EventButton) -> bool: + item = DockHitTest.find_item(self.model.items, event.x, event.y) + if item is None: + self._mouse_inside = False + self._mouse_x = -9999.0 + self._mouse_y = -9999.0 + return False + + self._mouse_inside = False + self._mouse_x = -9999.0 + self._mouse_y = -9999.0 + + if item.is_trash: + if event.button == 1: + self._handle_trash_click() + return True + + if item.is_pinned and not item.is_running: + self._handle_pinned_click(event, item) + elif (item.is_running and not item.is_pinned) or ( + item.is_pinned and item.is_running + ): + self._handle_instance_click(event, item) + + return True + + def _handle_pinned_click(self, event: Gdk.EventButton, item: DockItem) -> None: + if event.button == 1: + self._launch_app(item.app_data) + elif event.button == 2: + self._unpin_app(item.app_id) + elif event.button == 3: + self.show_menu(item.app_id) + self.menu.popup_at_pointer(event) + + def _handle_instance_click(self, event: Gdk.EventButton, item: DockItem) -> None: + if event.button == 1: + if item.instance_address: + try: + GLib.spawn_command_line_async( + f"hyprctl dispatch 'hl.dsp.focus({{ window = " + f'"address:{item.instance_address}" }})\'' + ) + except Exception as e: + logger.error(f"[DockCanvas] Error focusing window: {e}") + else: + self._launch_app(item.app_data) + elif event.button == 2: + if item.app_class and not self._is_app_pinned(item.app_class): + self._pin_app(item.app_class) + elif event.button == 3: + app_id = item.app_class or item.app_id + self.show_menu(app_id, instance_address=item.instance_address) + self.menu.popup_at_pointer(event) + + def show_menu( + self, + app_id: str, + client=None, + instance_address: Optional[str] = None, + ) -> None: + clear_children(self.menu) + + if instance_address: + close_item = Gtk.MenuItem(label="Close") + close_item.connect( + "activate", + lambda *_: self._close_running_app(instance_address), + ) + self.menu.add(close_item) + + if app_id: + self.menu.add(Gtk.SeparatorMenuItem()) + + if app_id: + is_pinned = self._is_app_pinned(app_id) + pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin") + if is_pinned: + pin_item.connect("activate", lambda *_: self._unpin_app(app_id)) + else: + pin_item.connect("activate", lambda *_: self._pin_app(app_id)) + self.menu.add(pin_item) + + self.menu.show_all() + + def _close_running_app(self, instance_address: str) -> None: + close_window(instance_address) + + def _trash_has_files(self) -> bool: + try: + trash_path = os.path.expanduser("~/.local/share/Trash/files") + return os.path.exists(trash_path) and len(os.listdir(trash_path)) > 0 + except Exception as e: + logger.warning( + f"[canvas] trash_path = os.path.expanduser('~/.local/share/Trash/fil... failed: {e}" + ) + return False + + def _handle_trash_click(self) -> None: + open_trash() + + def _read_pinned_apps(self) -> list: + try: + if os.path.exists(PINNED_APPS_FILE): + with open(PINNED_APPS_FILE) as f: + data = tomlkit.load(f) + return list(data.get("pinned", [])) + except Exception as e: + logger.error(f"[DockCanvas] Failed to read pinned apps: {e}") + return [] + + def _write_pinned_apps(self) -> None: + try: + doc = tomlkit.document() + arr = tomlkit.array() + for app in self.pinned_apps: + if isinstance(app, dict): + tbl = tomlkit.inline_table() + for k, v in app.items(): + tbl[k] = v + arr.append(tbl) + else: + arr.append(app) + doc["pinned"] = arr + with open(PINNED_APPS_FILE, "w") as f: + tomlkit.dump(doc, f) + except Exception as e: + logger.error(f"[DockCanvas] Failed to write pinned apps: {e}") + + def _pin_app(self, app_class: str) -> bool: + if self._is_app_pinned(app_class): + return False + try: + app = self._find_desktop_app(app_class, self._get_desktop_apps()) + app_data = { + "name": app.name if app else app_class, + "display_name": (app.display_name or app.name) if app else app_class, + "window_class": (getattr(app, "window_class", None) or app_class) + if app + else app_class, + "executable": app.executable if app else app_class, + "command_line": app.command_line if app else app_class, + } + self.pinned_apps.append(app_data) + except Exception as e: + logger.warning( + f"[canvas] app = self._find_desktop_app(app_class, self._get_desktop... failed: {e}" + ) + self.pinned_apps.append(app_class) + self._write_pinned_apps() + self._rebuild_model() + return True + + def _unpin_app(self, app_identifier: str) -> bool: + to_remove = [ + i + for i, a in enumerate(self.pinned_apps) + if self._matches_app_identifier(a, app_identifier) + ] + for i in reversed(to_remove): + self.pinned_apps.pop(i) + if to_remove: + self._write_pinned_apps() + self._rebuild_model() + return True + return False + + def _is_app_pinned(self, app_class: str) -> bool: + return any(self._matches_app_identifier(a, app_class) for a in self.pinned_apps) + + def _matches_app_identifier(self, pinned_app, app_identifier: str) -> bool: + if not app_identifier: + return False + if isinstance(pinned_app, dict): + wc = pinned_app.get("window_class") or "" + nm = pinned_app.get("name") or "" + return ( + wc.lower() == app_identifier.lower() + or nm.lower() == app_identifier.lower() + ) + return ( + isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower() + ) + + def _get_app_identifier(self, app_data) -> str: + if isinstance(app_data, dict): + return app_data.get("name", "") or app_data.get("window_class", "") + return str(app_data) if app_data else "" + + def _launch_app(self, app_info) -> None: + try: + command_line = "" + if hasattr(app_info, "command_line"): + command_line = app_info.command_line + elif isinstance(app_info, dict): + command_line = app_info.get("command_line") or app_info.get( + "executable", "" + ) + elif isinstance(app_info, str): + command_line = app_info + + if not command_line: + app = self._find_desktop_app(app_info, self._get_desktop_apps()) + if app: + command_line = app.command_line + + if command_line: + launch_app(command_line) + elif hasattr(app_info, "launch"): + from window.globalmenu.launch import launch_desktop_app + + launch_desktop_app(app_info) + else: + logger.error( + f"[DockCanvas] Cannot determine launch command: {app_info}" + ) + except Exception as e: + logger.error(f"[DockCanvas] Failed to launch app: {e}") + + def _find_desktop_app(self, app_info, desktop_apps): + if not app_info: + return None + + search_terms = [] + if isinstance(app_info, dict): + search_terms = [ + app_info.get("window_class"), + app_info.get("name"), + app_info.get("executable"), + ] + else: + search_terms = [app_info] + + search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)] + if not search_terms: + return None + + expanded = list(search_terms) + for term in search_terms: + if "." in term: + parts = term.split(".") + if parts[-1] and parts[-1] not in expanded: + expanded.append(parts[-1]) + for sep in ["_", "-"]: + if sep in term: + for part in term.split(sep): + if len(part) > 2 and part not in expanded: + expanded.append(part) + + # Check exact desktop file name first + for app in desktop_apps: + app_id = getattr(app, "id", None) + if not app_id and hasattr(app, "get_id"): + app_id = app.get_id() + if app_id: + app_id_clean = app_id.lower().replace(".desktop", "") + if app_id_clean in search_terms: + return app + + for app in desktop_apps: + ac = getattr(app, "window_class", None) + if ac and ac.lower() in search_terms: + return app + + for app in desktop_apps: + names = [ + getattr(app, "name", None), + getattr(app, "display_name", None), + ] + if any(n and n.lower() in expanded for n in names): + return app + + for app in desktop_apps: + if app.executable: + exe = os.path.basename(app.executable).lower() + if exe in expanded: + if exe in _COMMON_EXECUTABLES: + app_names = [ + getattr(app, "name", ""), + getattr(app, "display_name", ""), + ] + if not any(exe in (n or "").lower() for n in app_names): + continue + return app + + best_match = None + best_score = -1 + + for app in desktop_apps: + at = [ + getattr(app, "name", ""), + getattr(app, "display_name", ""), + getattr(app, "window_class", ""), + ] + at = [t.lower() for t in at if t] + for term in expanded: + if len(term) < 3: + continue + for t in at: + if term == t: + return app + if term in t: + score = 0 + if t.startswith(term): + score += 2 + if f" {term} " in f" {t} ": + score += 2 + score += len(term) / max(len(t), 1) + if score > best_score: + best_score = score + best_match = app + + return best_match + + def _get_clients(self) -> list: + return get_clients() + + def _get_focused_window(self) -> Optional[dict]: + return get_active_window() + + def _get_workspace_id(self, client: dict) -> Optional[int]: + ws = client.get("workspace", {}) + if isinstance(ws, dict): + return ws.get("id") + elif isinstance(ws, (int, str)): + try: + return int(ws) + except (ValueError, TypeError) as e: + logger.warning(f"[canvas] return int(ws) failed: {e}") + return None + return None + + def _should_show_app_instance(self, client: dict) -> bool: + if not config().get("dock_hide_special_workspace_apps", True): + return True + ws_id = self._get_workspace_id(client) + return False if ws_id is None else not is_special_workspace_id(ws_id) + + def destroy(self) -> None: + self.animator.stop() + + if self._dock_update_timer: + GLib.source_remove(self._dock_update_timer) + self._dock_update_timer = None + + for handler_id in self._hyprland_event_handlers: + try: + self._hyprland_connection.disconnect(handler_id) + except Exception as e: + logger.warning( + f"[canvas] self._hyprland_connection.disconnect(handler_id) failed: {e}" + ) + self._hyprland_event_handlers.clear() + + if self._modus_ws_handler is not None: + try: + get_modus_service().disconnect(self._modus_ws_handler) + except Exception as e: + logger.warning( + f"[canvas] get_modus_service().disconnect(self._modus_ws_handler) failed: {e}" + ) + self._modus_ws_handler = None + + if self.menu: + self.menu.destroy() + + super().destroy() diff --git a/src/window/dock/constants.py b/src/window/dock/constants.py new file mode 100644 index 00000000..39343d7d --- /dev/null +++ b/src/window/dock/constants.py @@ -0,0 +1,38 @@ +from utils.gtk_utils import toml_file + +PINNED_APPS_FILE = toml_file("dock.toml") + +LERP_FACTOR = 0.20 +IDLE_THRESHOLD = 0.0005 +ANIM_FPS = 60 +ANIM_INTERVAL_MS = 1000 // ANIM_FPS + +MAX_SCALE = 2.0 +MIN_SCALE = 1.0 +SIGMA_FACTOR = 1.3 + +ICON_GAP = 8 +BG_PADDING_H = 14 +BG_PADDING_V = 12 +CANVAS_TOP_PAD = 28 +INDICATOR_H = 10 +SEPARATOR_WIDTH = 1 +SEPARATOR_GAP = 4 + +BG_RADIUS = 20.0 +BG_ALPHA = 0.82 +BORDER_ALPHA = 0.50 +BORDER_WIDTH = 1.0 + +ICON_CORNER_RADIUS_FACTOR = 0.22 + +INDICATOR_RADIUS = 2.5 +INDICATOR_COLOR = (1.0, 1.0, 1.0, 0.95) + +BADGE_FONT_SIZE = 9.0 +BADGE_PADDING = 2 +BADGE_RADIUS = 4.0 +BADGE_BG = (0.0, 0.0, 0.0, 0.70) +BADGE_FG = (1.0, 1.0, 1.0, 1.0) + +SEPARATOR_COLOR = (1.0, 1.0, 1.0, 0.25) diff --git a/src/window/dock/items.py b/src/window/dock/items.py new file mode 100644 index 00000000..dd9ed323 --- /dev/null +++ b/src/window/dock/items.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from typing import Optional + +from fabric.utils import GdkPixbuf + +from .constants import MIN_SCALE + + +@dataclass +class DockItem: + app_id: str + app_data: object + is_pinned: bool = False + is_trash: bool = False + + is_running: bool = False + instance_address: Optional[str] = None + app_class: Optional[str] = None + client_data: Optional[dict] = None + workspace_id: Optional[int] = None + + pixbuf: Optional[GdkPixbuf.Pixbuf] = None + tooltip: str = "" + + current_scale: float = MIN_SCALE + target_scale: float = MIN_SCALE + + render_x: float = 0.0 + render_y: float = 0.0 + render_w: float = 0.0 + render_h: float = 0.0 + + drag_index: int = 0 + is_dragging: bool = False + + def hit(self, mx: float, my: float) -> bool: + return ( + self.render_x <= mx <= self.render_x + self.render_w + and self.render_y <= my <= self.render_y + self.render_h + ) + + +class DockModel: + def __init__(self): + self.items: list[DockItem] = [] + + def get_by_address(self, address: str) -> Optional[DockItem]: + for item in self.items: + if item.instance_address == address: + return item + return None + + def get_by_id(self, app_id: str) -> Optional[DockItem]: + for item in self.items: + if item.app_id.lower() == app_id.lower(): + return item + return None + + def pinned_items(self) -> list[DockItem]: + return [i for i in self.items if i.is_pinned] + + def running_only_items(self) -> list[DockItem]: + return [i for i in self.items if not i.is_pinned and not i.is_trash] + + def has_running_only(self) -> bool: + return any(not i.is_pinned and not i.is_trash for i in self.items) + + def has_pinned(self) -> bool: + return any(i.is_pinned for i in self.items) + + +class DockHitTest: + @staticmethod + def find_item(items: list[DockItem], mx: float, my: float) -> Optional[DockItem]: + for item in reversed(items): + if item.hit(mx, my): + return item + return None diff --git a/src/window/dock/layout.py b/src/window/dock/layout.py new file mode 100644 index 00000000..3e9caa93 --- /dev/null +++ b/src/window/dock/layout.py @@ -0,0 +1,89 @@ +import math + +from .constants import ( + BG_PADDING_H, + BG_PADDING_V, + ICON_GAP, + INDICATOR_H, + MAX_SCALE, + MIN_SCALE, + SIGMA_FACTOR, +) +from .items import DockItem + + +class DockLayout: + @staticmethod + def compute( + items: list[DockItem], + mouse_x: float, + _mouse_y: float, + base_icon_size: int, + canvas_w: int, + canvas_h: int, + mouse_inside: bool, + ) -> None: + if not items: + return + + sigma = base_icon_size * SIGMA_FACTOR + amplitude = MAX_SCALE - MIN_SCALE + + n = len(items) + base_w = base_icon_size + ICON_GAP + total_base = n * base_icon_size + max(n - 1, 0) * ICON_GAP + start_x = (canvas_w - total_base) / 2.0 + + for i, item in enumerate(items): + est_cx = start_x + i * base_w + base_icon_size / 2.0 + + if mouse_inside: + dist = abs(est_cx - mouse_x) + target = MIN_SCALE + amplitude * math.exp( + -(dist * dist) / (2.0 * sigma * sigma) + ) + else: + target = MIN_SCALE + + item.target_scale = target + + bg_h = base_icon_size + 2 * BG_PADDING_V + bg_y = canvas_h - INDICATOR_H - bg_h + baseline_y = bg_y + BG_PADDING_V + base_icon_size + + rendered_widths = [base_icon_size * item.current_scale for item in items] + total_w = sum(rendered_widths) + max(n - 1, 0) * ICON_GAP + cursor_x = (canvas_w - total_w) / 2.0 + + for i, item in enumerate(items): + iw = rendered_widths[i] + ih = base_icon_size * item.current_scale + ix = cursor_x + iy = baseline_y - ih + item.render_x = ix + item.render_y = iy + item.render_w = iw + item.render_h = ih + item.drag_index = i + cursor_x += iw + ICON_GAP + + @staticmethod + def background_rect( + items: list[DockItem], + base_icon_size: int, + canvas_w: int, + canvas_h: int, + ) -> tuple[float, float, float, float]: + bg_h = base_icon_size + 2 * BG_PADDING_V + bg_y = canvas_h - INDICATOR_H - bg_h + + if items: + min_x = min(item.render_x for item in items) + max_x = max(item.render_x + item.render_w for item in items) + bg_x = min_x - BG_PADDING_H + bg_w = (max_x - min_x) + 2 * BG_PADDING_H + else: + bg_x = BG_PADDING_H + bg_w = canvas_w - 2 * BG_PADDING_H + + return bg_x, bg_y, bg_w, bg_h diff --git a/src/window/dock/main.py b/src/window/dock/main.py new file mode 100644 index 00000000..88a013d1 --- /dev/null +++ b/src/window/dock/main.py @@ -0,0 +1,161 @@ +from fabric.utils import GLib, logger, random +from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +from services.config import config, on_config_change +from services.modus import check_occlusion + +from .canvas import DockCanvas + + +class Dock(Window): + def __init__(self): + super().__init__( + layer="top", + anchor="bottom center", + exclusivity="none", + title="modus-dock", + name="dock", + ) + + screen = self.get_screen() + if screen: + visual = screen.get_rgba_visual() + if visual: + self.set_visual(visual) + self.set_app_paintable(True) + + self.canvas = DockCanvas(self) + + canvas_box = Box( + name="dock-canvas-box", + children=[self.canvas], + ) + + self.revealer = Revealer( + child=canvas_box, + transition_duration=200, + transition_type="slide-up", + ) + + hover_strip = Box( + name="dock-hover-strip", + ) + + outer_box = Box( + name="dock-outer-box", + orientation="v", + children=[self.revealer, hover_strip], + ) + + self.children = EventBox( + events=["enter-notify", "leave-notify"], + child=outer_box, + on_enter_notify_event=lambda *_: self.on_hover_enter(), + on_leave_notify_event=lambda *_: self.on_hover_leave(), + ) + + self.dock_height = 100 + self.is_hovered = False + self.hide_ticket = 0 + self._startup_grace = True + + self.revealer.set_reveal_child(True) + self.show_all() + + on_config_change(self._on_config_change) + + if config().get("dock_auto_hide", True): + GLib.timeout_add(2000, self._start_occlusion_monitoring) + else: + self._occlusion_timer_id = None + + def _start_occlusion_monitoring(self) -> bool: + self._startup_grace = False + self.setup_occlusion_monitoring() + return False + + def _on_config_change(self, new_config, old_config) -> None: + if config().has_changed("dock_enabled", old_config): + self._update_visibility() + + if config().has_changed("dock_icon_size", old_config): + self.canvas.update_icon_size() + + if config().has_changed("dock_auto_hide", old_config): + if new_config.get("dock_auto_hide", True): + self.setup_occlusion_monitoring() + else: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + self.revealer.set_reveal_child(True) + + if config().has_changed("dock_hide_special_workspace_apps", old_config): + self.canvas._rebuild_model() + + def _update_visibility(self) -> None: + if config().get("dock_enabled", True): + self.show() + self.revealer.set_reveal_child(True) + else: + self.hide() + + def on_hover_enter(self) -> None: + self.is_hovered = True + self.hide_ticket = random.getrandbits(32) + self.revealer.set_reveal_child(True) + + def on_hover_leave(self) -> None: + self.is_hovered = False + ticket = random.getrandbits(32) + self.hide_ticket = ticket + + def delayed_hide(t): + if t == self.hide_ticket and not self.is_hovered: + if config().get("dock_auto_hide", True): + is_occ = config().get( + "dock_always_occluded", False + ) or check_occlusion(("bottom", self.dock_height)) + if is_occ: + self.revealer.set_reveal_child(False) + return False + + GLib.timeout_add(500, delayed_hide, ticket) + + def setup_occlusion_monitoring(self) -> None: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + + def check_dock_occlusion() -> bool: + try: + if config().get("dock_always_occluded", False): + is_occ = True + else: + is_occ = check_occlusion(("bottom", self.dock_height)) + + if is_occ and not self.is_hovered and self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(False) + elif not is_occ and not self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(True) + elif is_occ and self.is_hovered: + if not self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(True) + except Exception as e: + logger.error(f"[Dock] Occlusion check error: {e}") + return True + + self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion) + + def destroy(self) -> None: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + + if hasattr(self, "canvas") and self.canvas: + self.canvas.destroy() + + super().destroy() diff --git a/src/window/globalmenu/__init__.py b/src/window/globalmenu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/globalmenu/dbusmenu.py b/src/window/globalmenu/dbusmenu.py new file mode 100644 index 00000000..35ff2558 --- /dev/null +++ b/src/window/globalmenu/dbusmenu.py @@ -0,0 +1,407 @@ +""" +Universal DBus + GTK Global Menu Client +Supports: +- com.canonical.dbusmenu (KDE / Unity / XFCE) +- GTK appmenu-module DBusMenu +- GTK GMenuModel fallback (best-effort) +- Incremental updates via DBusMenu signals +""" + +import threading +from typing import Callable, Dict, List, Optional, Tuple + +from fabric.utils import Any, Gio, GLib, logger + +# SHARED BUS — one connection for the entire process +_SESSION_BUS: Optional[Gio.DBusConnection] = None +_BUS_LOCK = threading.Lock() + + +def _get_bus() -> Gio.DBusConnection: + global _SESSION_BUS + if _SESSION_BUS is None: + with _BUS_LOCK: + if _SESSION_BUS is None: + _SESSION_BUS = Gio.bus_get_sync(Gio.BusType.SESSION, None) + return _SESSION_BUS + + +class DBusMenuItem: + __slots__ = ( + "action_name", + "action_target", + "children", + "enabled", + "has_submenu", + "icon_name", + "id", + "label", + "parent_id", + "shortcut", + "type", + "visible", + ) + + def __init__( + self, + id: int, + label: str = "", + enabled: bool = True, + visible: bool = True, + type: str = "standard", + shortcut: str = "", + icon_name: str = "", + has_submenu: bool = False, + children: Optional[List["DBusMenuItem"]] = None, + action_name: str = "", + action_target: Optional[Any] = None, + parent_id: int = -1, + ): + self.id = id + self.label = label + self.enabled = enabled + self.visible = visible + self.type = type + self.shortcut = shortcut + self.icon_name = icon_name + self.has_submenu = has_submenu + self.children: List[DBusMenuItem] = children if children is not None else [] + self.action_name = action_name + self.action_target = action_target + self.parent_id = parent_id + + +class DBusMenuClient: + __slots__ = ( + "_cache", + "_cache_valid", + "_fetching", + "_hash_cache", + "_lock", + "_on_item_activated_cb", + "_on_items_updated_cb", + "_on_layout_updated_cb", + "_revision", + "_signal_ids", + "object_path", + "service_name", + ) + + def __init__(self, service_name: str, object_path: str): + self.service_name = service_name + self.object_path = object_path + + self._cache: Optional[List[DBusMenuItem]] = None + self._cache_valid = False + self._hash_cache: Optional[str] = None + self._revision: int = 0 + + self._lock = threading.Lock() + self._fetching = False + self._signal_ids: List[int] = [] + self._on_layout_updated_cb: Optional[Callable] = None + self._on_items_updated_cb: Optional[Callable] = None + self._on_item_activated_cb: Optional[Callable] = None + + def _call(self, method, params, reply_type=None): + try: + return _get_bus().call_sync( + self.service_name, + self.object_path, + "com.canonical.dbusmenu", + method, + params, + reply_type, + Gio.DBusCallFlags.NONE, + 3000, + None, + ) + except Exception as e: + logger.warning( + f"[dbusmenu] return _get_bus().call_sync( self.service_name, self.obje... failed: {e}" + ) + return None + + def get_layout(self, force_refresh=False): + if self._cache and self._cache_valid and not force_refresh: + return self._cache + + with self._lock: + if self._fetching: + return self._cache or [] + self._fetching = True + + try: + return self._fetch() + finally: + with self._lock: + self._fetching = False + + def _fetch(self): + params = GLib.Variant("(iias)", (0, -1, [])) + res = self._call("GetLayout", params) + + if not res: + return [] + + try: + revision = res.get_child_value(0).get_uint32() + self._revision = revision + + layout = res.get_child_value(1) + while layout.is_of_type(GLib.VariantType("v")): + layout = layout.get_variant() + + parsed = self._parse_dbusmenu(layout).children + + h = self._hash(parsed) + if h == self._hash_cache: + return self._cache or parsed + + self._hash_cache = h + self._cache = parsed + self._cache_valid = True + return parsed + + except Exception as e: + logger.error(f"[Menu] parse error: {e}") + return [] + + def _hash(self, items): + flat: List[str] = [] + + def walk(nodes): + for n in nodes: + flat.append(f"{n.id}:{n.label}:{n.enabled}") + walk(n.children) + + walk(items) + return str(hash("".join(flat))) + + def _parse_dbusmenu(self, node, _depth: int = 0, _parent_id: int = -1): + if _depth > 20: + return DBusMenuItem(id=0) + item = DBusMenuItem(id=0) + + try: + if node.n_children() != 3: + return item + + item.id = node.get_child_value(0).get_int32() + item.parent_id = _parent_id + + try: + props = node.get_child_value(1).unpack() + except Exception: + props = {} + + item.label = props.get("label", "").replace("_", "") + item.enabled = props.get("enabled", True) + item.visible = props.get("visible", True) + item.type = props.get("type", "standard") + item.icon_name = props.get("icon-name", "") + + if props.get("children-display") == "submenu": + item.has_submenu = True + + sc = props.get("shortcut") + if sc: + try: + parts = [] + if isinstance(sc, list): + flat = sc[0] if isinstance(sc[0], list) else sc + mapping = { + "Control": "Ctrl", + "Shift": "Shift", + "Alt": "Alt", + "Meta": "Super", + } + for s in flat: + parts.append(mapping.get(s, str(s).upper())) + item.shortcut = "+".join(parts) + except Exception: + item.shortcut = "" + + children = node.get_child_value(2) + for i in range(children.n_children()): + if _depth >= 20: + break + c = children.get_child_value(i) + if c.is_of_type(GLib.VariantType("v")): + c = c.get_variant() + item.children.append(self._parse_dbusmenu(c, _depth + 1, item.id)) + + if item.has_submenu and not item.children: + try: + self.about_to_show(item.id) + except Exception as e: + logger.warning( + f"[dbusmenu] self.about_to_show(item.id) failed: {e}" + ) + + except Exception as e: + logger.warning( + f"[dbusmenu] if node.n_children() != 3: return item failed: {e}" + ) + + return item + + def about_to_show(self, item_id): + try: + res = self._call( + "AboutToShow", + GLib.Variant("(i)", (item_id,)), + GLib.VariantType("(b)"), + ) + return res.get_child_value(0).get_boolean() if res else False + except Exception as e: + logger.warning( + f"[dbusmenu] res = self._call( 'AboutToShow', GLib.Variant('(i)', (ite... failed: {e}" + ) + return False + + def click_item(self, item_id, pid=0): + try: + self._call( + "Event", + GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("s", ""), 0)), + ) + self.invalidate() + except Exception as e: + logger.warning( + f"[dbusmenu] self._call( 'Event', GLib.Variant('(isvu)', (item_id, 'cl... failed: {e}" + ) + + def invalidate(self): + self._cache_valid = False + + def prefetch_async(self): + if self._fetching: + return + threading.Thread( + target=self.get_layout, kwargs={"force_refresh": True}, daemon=True + ).start() + + def connect_signals( + self, + on_layout_updated: Optional[Callable] = None, + on_items_updated: Optional[Callable] = None, + on_item_activated: Optional[Callable] = None, + ): + self._on_layout_updated_cb = on_layout_updated + self._on_items_updated_cb = on_items_updated + self._on_item_activated_cb = on_item_activated + + bus = _get_bus() + + def handle_layout_updated( + _connection, _sender, _path, _iface, _signal, params, _user_data=None + ): + try: + parent_id, revision, properties = params.unpack() + self._revision = revision + if self._on_layout_updated_cb: + self._on_layout_updated_cb(parent_id, revision, properties) + except Exception as e: + logger.debug(f"[DBusMenuClient] LayoutUpdated handler error: {e}") + + def handle_items_properties_updated( + _connection, _sender, _path, _iface, _signal, params, _user_data=None + ): + try: + updated_props, removed_ids = params.unpack() + if self._on_items_updated_cb: + self._on_items_updated_cb(updated_props, removed_ids) + self._cache_valid = False + except Exception as e: + logger.debug(f"[DBusMenuClient] ItemsPropertiesUpdated error: {e}") + + def handle_item_activation_requested( + _connection, _sender, _path, _iface, _signal, params, _user_data=None + ): + try: + item_id, timestamp = params.unpack() + if self._on_item_activated_cb: + self._on_item_activated_cb(item_id, timestamp) + except Exception as e: + logger.debug(f"[DBusMenuClient] ItemActivationRequested error: {e}") + + for sig_name, handler in [ + ("LayoutUpdated", handle_layout_updated), + ("ItemsPropertiesUpdated", handle_items_properties_updated), + ("ItemActivationRequested", handle_item_activation_requested), + ]: + sid = bus.signal_subscribe( + self.service_name, + "com.canonical.dbusmenu", + sig_name, + self.object_path, + None, + Gio.DBusCallFlags.NONE, + handler, + None, + ) + if sid: + self._signal_ids.append(sid) + + def disconnect_signals(self): + if not self._signal_ids: + return + bus = _get_bus() + for sid in self._signal_ids: + try: + bus.signal_unsubscribe(sid) + except Exception as e: + logger.warning(f"[dbusmenu] bus.signal_unsubscribe(sid) failed: {e}") + self._signal_ids.clear() + + def update_layout( + self, parent_id: int, revision: int + ) -> Optional[List[DBusMenuItem]]: + self._revision = revision + params = GLib.Variant("(iias)", (parent_id, revision, [])) + res = self._call("GetLayout", params) + if not res: + return None + try: + layout = res.get_child_value(1) + while layout.is_of_type(GLib.VariantType("v")): + layout = layout.get_variant() + parsed = self._parse_dbusmenu(layout).children + if parent_id == 0: + self._hash_cache = self._hash(parsed) + self._cache = parsed + self._cache_valid = True + return parsed + except Exception as e: + logger.error(f"[Menu] update_layout error: {e}") + return None + + def apply_items_update( + self, updated_props: List[Tuple[int, Dict]], removed_ids: List[int] + ): + if not self._cache: + return + removed_set = set(removed_ids) + + def walk(items): + result = [] + for item in items: + if item.id in removed_set: + continue + for uid, props in updated_props: + if item.id == uid: + if "label" in props: + item.label = str(props["label"]).replace("_", "") + if "enabled" in props: + item.enabled = bool(props["enabled"]) + if "visible" in props: + item.visible = bool(props["visible"]) + if "type" in props: + item.type = str(props["type"]) + item.children = walk(item.children) + result.append(item) + return result + + self._cache = walk(self._cache) + self._hash_cache = None diff --git a/src/window/globalmenu/detection.py b/src/window/globalmenu/detection.py new file mode 100644 index 00000000..7399f5f5 --- /dev/null +++ b/src/window/globalmenu/detection.py @@ -0,0 +1,240 @@ +"""Detect whether an executable is built against GTK3 or GTK4. + +The global menu compat shim (libmenu_button_shim.so) links against GTK3 and +must only be loaded into GTK3 processes. Loading it into a GTK4 process pulls +GTK 2/3 symbols into the same process and GTK4 aborts with: + + GTK-ERROR: "GTK 2/3 symbols detected. Using GTK 2/3 and GTK 4 in the same + process is not supported" + +We therefore inspect an executable's ELF ``DT_NEEDED`` entries to learn which +GTK it depends on, and only inject the shim for GTK3 binaries. Results are +cached on disk (keyed by resolved path + mtime) so the lookup is cheap and is +never run on every launch for unchanged binaries. +""" + +import json +import os +import shutil +from collections import deque +from pathlib import Path + +from fabric.utils import logger + +# SONAMEs we care about. +GTK3_SONAME = "libgtk-3.so.0" +GTK4_SONAME = "libgtk-4.so.1" + +_CACHE_DIR = Path.home() / ".cache" / "modus" +_CACHE_FILE = _CACHE_DIR / "gtk_class_cache.json" + +# Directories searched when resolving a NEEDED SONAME to a file. The binary's +# own directory is always tried first, then these (LD_LIBRARY_PATH wins). +_LIB_DIRS = [ + *os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep), + "/usr/lib", + "/usr/lib64", + "/lib", + "/lib64", + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/i386-linux-gnu", +] + + +def _parse_dynamic(path: Path) -> tuple[set[str], list[Path]]: + """Parse an ELF's dynamic section. + + Returns ``(needed_sonames, runpath_dirs)``. ``runpath_dirs`` are the + directories named by DT_RUNPATH/DT_RPATH with ``$ORIGIN`` expanded to the + directory containing ``path``. Private libraries (e.g. gedit's + libgedit-50.so) live there and must be searched to follow the dependency + tree correctly. + """ + try: + with open(path, "rb") as f: + ident = f.read(20) + if len(ident) < 20 or ident[:4] != b"\x7fELF": + return set(), [] + is_64 = ident[4] == 2 + data = path.read_bytes() + except OSError as e: + logger.warning( + f"[detection] with open(path, 'rb') as f: ident = f.read(20) failed: {e}" + ) + return set(), [] + + try: + if is_64: + e_phoff = int.from_bytes(data[0x20:0x28], "little") + e_phentsize = int.from_bytes(data[0x36:0x38], "little") + e_phnum = int.from_bytes(data[0x38:0x3A], "little") + else: + e_phoff = int.from_bytes(data[0x1C:0x20], "little") + e_phentsize = int.from_bytes(data[0x2A:0x2C], "little") + e_phnum = int.from_bytes(data[0x2C:0x2E], "little") + except (IndexError, ValueError) as e: + logger.warning( + f"[detection] if is_64: e_phoff = int.from_bytes(data[0x20:0x28], 'litt... failed: {e}" + ) + return set(), [] + + PT_DYNAMIC = 2 + word_size = 8 if is_64 else 4 + + def unpack(b: bytes) -> int: + return int.from_bytes(b[:word_size], "little") + + dynamic_off = None + for i in range(e_phnum): + base = e_phoff + i * e_phentsize + if unpack(data[base : base + 4]) == PT_DYNAMIC: + dynamic_off = unpack(data[base + word_size : base + word_size * 2]) + dyn_strtab = unpack(data[base + word_size * 2 : base + word_size * 3]) + break + + if dynamic_off is None: + return set(), [] + + DT_NEEDED = 1 + DT_STRTAB = 5 + DT_RUNPATH = 0x1D + DT_RPATH = 0x0F + + needed_offsets: list[int] = [] + strtab_off = dyn_strtab + runpath_str = "" + step = word_size * 2 + + pos = dynamic_off + while pos + step <= len(data): + d_tag = unpack(data[pos : pos + word_size]) + d_val = unpack(data[pos + word_size : pos + step]) + if d_tag == 0: # DT_NULL + break + if d_tag == DT_NEEDED: + needed_offsets.append(d_val) + elif d_tag == DT_STRTAB: + strtab_off = d_val + elif d_tag in (DT_RUNPATH, DT_RPATH): + end = data.find(b"\x00", strtab_off + d_val) + if end != -1: + runpath_str = data[strtab_off + d_val : end].decode("utf-8", "replace") + pos += step + + needed: set[str] = set() + for off in needed_offsets: + end = data.find(b"\x00", strtab_off + off) + if end == -1: + continue + needed.add(data[strtab_off + off : end].decode("utf-8", "replace")) + + origin = path.parent + runpath_dirs: list[Path] = [] + for entry in runpath_str.split(":"): + entry = entry.strip() + if not entry: + continue + entry = entry.replace("$ORIGIN", str(origin)).replace("${ORIGIN}", str(origin)) + runpath_dirs.append(Path(entry)) + + return needed, runpath_dirs + + +def _find_lib(soname: str, search_dirs: list[Path]) -> Path | None: + """Resolve a NEEDED SONAME to a file within ``search_dirs``.""" + for directory in search_dirs: + candidate = directory / soname + if candidate.is_file(): + return candidate + return None + + +def _transitive_needed(root: Path) -> set[str]: + """Collect every DT_NEEDED SONAME in the dependency tree of ``root``. + + Follows NEEDED entries transitively (including private libs reached via + RUNPATH) so that GTK pulled in indirectly is still detected. + """ + all_needed: set[str] = set() + seen_paths: set[Path] = set() + queue: deque[Path] = deque([root]) + + while queue: + lib = queue.popleft() + if lib in seen_paths: + continue + seen_paths.add(lib) + + needed, runpath_dirs = _parse_dynamic(lib) + search_dirs = [lib.parent, *runpath_dirs, *[Path(d) for d in _LIB_DIRS if d]] + for soname in needed: + all_needed.add(soname) + if soname in (GTK3_SONAME, GTK4_SONAME): + continue + dep = _find_lib(soname, search_dirs) + if dep and dep not in seen_paths: + queue.append(dep) + + return all_needed + + +def _load_cache() -> dict: + from utils.functions import read_json_file + + return read_json_file(str(_CACHE_FILE)) or {} + + +def _save_cache(cache: dict) -> None: + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + _CACHE_FILE.write_text(json.dumps(cache)) + except OSError as e: + logger.debug(f"[GlobalMenu] Could not write gtk class cache: {e}") + + +def executable_gtk_class(executable: str | None) -> str | None: + """Classify an executable as ``"gtk3"``, ``"gtk4"`` or ``None``. + + ``None`` means "unable to determine" (e.g. a shell script, a wrapper, or a + non-GTK binary). In that case the caller should NOT inject the GTK3 shim, + which keeps GTK4 applications safe. + """ + if not executable: + return None + + token = executable.strip().split()[0] if executable.strip() else "" + if not token: + return None + + resolved = shutil.which(token) or token + path = Path(resolved).expanduser() + if not path.is_absolute() or not path.exists(): + # Try as a direct path too. + path = Path(token) + if not path.exists(): + return None + + try: + mtime = path.stat().st_mtime_ns + except OSError as e: + logger.warning(f"[detection] mtime = path.stat().st_mtime_ns failed: {e}") + return None + + cache = _load_cache() + key = f"{path}" + + cached = cache.get(key) + if cached and cached.get("mtime") == mtime: + return cached.get("class") + + needed = _transitive_needed(path) + if GTK4_SONAME in needed: + klass = "gtk4" + elif GTK3_SONAME in needed: + klass = "gtk3" + else: + klass = None + + cache[key] = {"mtime": mtime, "class": klass} + _save_cache(cache) + return klass diff --git a/src/window/globalmenu/environment.py b/src/window/globalmenu/environment.py new file mode 100644 index 00000000..09d666ab --- /dev/null +++ b/src/window/globalmenu/environment.py @@ -0,0 +1,193 @@ +"""Global menu environment setup — GTK_MODULES, UBUNTU_MENUPROXY, settings.ini, Flatpak.""" + +import os +import subprocess +from pathlib import Path + +from fabric.utils import exec_shell_command, logger + +SHIM_SRC = Path(__file__).resolve().parent / "libmenu_button_shim.c" +SHIM_SO = Path(__file__).resolve().parent / "libmenu_button_shim.so" + +ENV_VARS = { + "GTK_MODULES": "appmenu-gtk-module", + "UBUNTU_MENUPROXY": "1", +} + +# Marker lines previously written around the global LD_PRELOAD export in shell +# rc files. Kept so we can strip the stale block from already-configured systems. +_SHIM_BEGIN = "# >>> modus global-menu shim >>>" +_SHIM_END = "# <<< modus global-menu shim <<<" + + +def _remove_global_ld_preload(): + """Strip the legacy global LD_PRELOAD export from previously configured systems. + + The shim is now injected per-launch into GTK3 apps only (see + globalmenu.launch). Any globally exported LD_PRELOAD must be removed so it + no longer breaks GTK4 processes. + """ + home = Path.home() + shell = os.environ.get("SHELL", "") + rc_files = [ + home / ".zshrc", + home / ".bashrc", + home / ".profile", + home / ".config" / "fish" / "config.fish", + ] + rc_path = { + "zsh": home / ".zshrc", + "bash": home / ".bashrc", + "fish": home / ".config" / "fish" / "config.fish", + }.get(Path(shell).name) + if rc_path: + rc_files.append(rc_path) + rc_files = list(dict.fromkeys(rc_files)) # de-dupe, keep order + + for rc in rc_files: + try: + if not rc.is_file(): + continue + lines = rc.read_text().splitlines(keepends=True) + if not any(_SHIM_BEGIN in ln for ln in lines): + continue + out = [] + drop = False + for ln in lines: + if _SHIM_BEGIN in ln: + drop = True + continue + if _SHIM_END in ln: + drop = False + continue + if drop: + continue + out.append(ln) + rc.write_text("".join(out)) + logger.info(f"[GlobalMenu] Removed stale LD_PRELOAD block from {rc}") + except OSError as e: + logger.warning(f"[environment] if not rc.is_file(): continue failed: {e}") + + # Strip the LD_PRELOAD line from environment.d/appmenu.conf. + env_path = home / ".config" / "environment.d" / "appmenu.conf" + try: + if env_path.is_file(): + kept = [ + ln + for ln in env_path.read_text().splitlines(keepends=True) + if not ln.strip().startswith("LD_PRELOAD=") + ] + env_path.write_text("".join(kept)) + except OSError as e: + logger.warning( + f"[environment] if env_path.is_file(): kept = [ ln for ln in env_path.rea... failed: {e}" + ) + + # Clear it from the activation environment for future launches. + exec_shell_command("dbus-update-activation-environment --systemd LD_PRELOAD=") + + +def get_compiled_shim() -> Path | None: + """Compile the GTK3 menu-button shim if needed. Returns path to .so or None.""" + try: + if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime: + return SHIM_SO + + if not SHIM_SRC.exists(): + logger.debug("[GlobalMenu] Shim source not found, skipping") + return None + + gtk_cflags = subprocess.check_output( + ["pkg-config", "--cflags", "--libs", "gtk+-3.0"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + cmd = f"gcc -shared -fPIC -O2 -o {SHIM_SO} {SHIM_SRC} {gtk_cflags} -ldl" + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, timeout=15 + ) + if result.returncode != 0: + logger.warning(f"[GlobalMenu] Shim compilation failed:\n{result.stderr}") + return None + + logger.info(f"[GlobalMenu] Compiled menu shim -> {SHIM_SO}") + return SHIM_SO + except Exception as e: + logger.warning(f"[GlobalMenu] Shim compilation error: {e}") + return None + + +def setup_global_menu_environment(): + """Write env config files so GTK apps register with AppMenu at next login. + + Does NOT set env via hyprctl keyword env — that would cause Fabric's own + GTK windows to load appmenu-gtk-module and trigger Gdk-CRITICAL assertions. + Uses session-level config files that take effect at login. + """ + try: + shim_path = get_compiled_shim() + + # Remove any legacy global LD_PRELOAD export from older installs. + _remove_global_ld_preload() + + # NOTE: The menu-button shim is compiled against GTK3 and must NOT be + # exported globally. Injecting it into a GTK4 process aborts the app + # ("GTK 2/3 symbols detected"). It is injected per-launch into GTK3 + # apps only — see globalmenu.launch. + if shim_path: + logger.debug(f"[GlobalMenu] GTK3 shim ready at {shim_path}") + + exec_shell_command( + "dbus-update-activation-environment --systemd " + "GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1" + ) + + exec_shell_command( + "flatpak override --user --talk-name=com.canonical.AppMenu.Registrar" + ) + + env_dir = Path.home() / ".config" / "environment.d" + try: + env_dir.mkdir(parents=True, exist_ok=True) + env_path = env_dir / "appmenu.conf" + with open(env_path, "w") as f: + for k, v in ENV_VARS.items(): + f.write(f"{k}={v}\n") + except OSError as e: + logger.warning( + f"[environment] env_dir.mkdir(parents=True, exist_ok=True) failed: {e}" + ) + + settings_path = Path.home() / ".config" / "gtk-3.0" / "settings.ini" + try: + settings_path.parent.mkdir(parents=True, exist_ok=True) + content = "" + if settings_path.exists(): + content = settings_path.read_text() + if "gtk-shell-shows-menubar" not in content: + with open(settings_path, "a") as f: + if content and not content.endswith("\n"): + f.write("\n") + f.write("gtk-shell-shows-menubar=1\n") + except OSError as e: + logger.warning( + f"[environment] settings_path.parent.mkdir(parents=True, exist_ok=True) failed: {e}" + ) + + pam_path = Path.home() / ".pam_environment" + try: + existing = "" + if pam_path.exists(): + existing = pam_path.read_text() + lines = existing.splitlines(True) + for k, v in ENV_VARS.items(): + line = f"{k} DEFAULT={v}\n" + if not any(ln.startswith(f"{k} ") for ln in lines): + with open(pam_path, "a") as f: + f.write(line) + except OSError as e: + logger.warning(f"[environment] existing = '' failed: {e}") + + logger.info("[GlobalMenu] GTK environment injected") + except Exception as e: + logger.warning(f"[GlobalMenu] Environment setup failed: {e}") diff --git a/src/window/globalmenu/gtkmenu.py b/src/window/globalmenu/gtkmenu.py new file mode 100644 index 00000000..d2e34e3b --- /dev/null +++ b/src/window/globalmenu/gtkmenu.py @@ -0,0 +1,760 @@ +from typing import Any, Dict, List, Optional + +from fabric.utils import Gio, GLib, logger + +from window.globalmenu.dbusmenu import DBusMenuItem, _get_bus + + +def atspi_activate_menu_item(pid: int, label: str) -> bool: + """Activate a menu item by label via AT-SPI accessibility interface. + + Used as a fallback when D-Bus org.gtk.Actions.Activate doesn't reach the + app's real menu item handler (common with appmenu-gtk-module on Wayland + where gdk_threads_add_idle_full dispatch may not fire). + """ + try: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + except (ImportError, ValueError) as e: + logger.warning(f"[gtkmenu] import gi failed: {e}") + return False + + try: + desktop = Atspi.get_desktop(0) + for i in range(desktop.get_child_count()): + app = desktop.get_child_at_index(i) + if app is None: + continue + try: + app_pid = app.get_process_id() + except Exception as e: + logger.warning(f"[gtkmenu] app_pid = app.get_process_id() failed: {e}") + continue + if app_pid != pid: + continue + + def _find_menubar(obj, depth=0): + if depth > 8: + return None + try: + if obj.get_role_name() == "menu bar": + return obj + for j in range(obj.get_child_count()): + result = _find_menubar(obj.get_child_at_index(j), depth + 1) + if result: + return result + except Exception as e: + logger.warning( + f"[gtkmenu] if obj.get_role_name() == 'menu bar': return obj failed: {e}" + ) + return None + + def _find_frame(obj, depth=0): + if depth > 4: + return None + try: + if obj.get_role_name() == "frame": + return obj + for j in range(obj.get_child_count()): + result = _find_frame(obj.get_child_at_index(j), depth + 1) + if result: + return result + except Exception as e: + logger.warning( + f"[gtkmenu] if obj.get_role_name() == 'frame': return obj failed: {e}" + ) + return None + + frame = _find_frame(app) + if not frame: + continue + menubar = _find_menubar(frame) + if not menubar: + continue + + label_lower = label.lower().strip() + for j in range(menubar.get_child_count()): + item = menubar.get_child_at_index(j) + if item is None: + continue + try: + item_name = (item.get_name() or "").lower().strip() + except Exception as e: + logger.warning( + f"[gtkmenu] item_name = (item.get_name() or '').lower().strip() failed: {e}" + ) + continue + if item_name == label_lower or label_lower in item_name: + try: + n_actions = item.get_n_actions() + if n_actions > 0: + result = item.do_action(0) + if result: + logger.debug( + f"[ATSPI] Activated menu item '{label}' " + f"via do_action on pid {pid}" + ) + return True + except Exception as e: + logger.debug(f"[ATSPI] do_action failed: {e}") + return False + break + except Exception as e: + logger.debug(f"[ATSPI] activate_menu_item error: {e}") + return False + + +class _ActionInfo: + __slots__ = ("enabled", "parameter_type", "state", "state_type") + + def __init__( + self, + enabled: bool = True, + parameter_type: str = "", + state: Optional[Any] = None, + state_type: str = "", + ): + self.enabled = enabled + self.parameter_type = parameter_type + self.state = state + self.state_type = state_type + + +class GtkMenuClient: + __slots__ = ( + "_action_cache", + "_action_path", + "_cached_items", + "object_path", + "service_name", + ) + + def __init__(self, service_name: str, object_path: str): + self.service_name = service_name + self.object_path = object_path + self._action_path = ( + object_path.split("/menus/")[0] if "/menus/" in object_path else object_path + ) + self._cached_items: List[DBusMenuItem] = [] + self._action_cache: Dict[str, _ActionInfo] = {} + + def _call(self, interface, method, params, reply_type=None, path=None): + try: + return _get_bus().call_sync( + self.service_name, + path or self.object_path, + interface, + method, + params, + reply_type, + Gio.DBusCallFlags.NONE, + 3000, + None, + ) + except Exception as e: + logger.debug(f"[GtkMenuClient] {method} failed: {e}") + return None + + def get_layout( + self, parent_id: int = 0, force_refresh: bool = False + ) -> List[DBusMenuItem]: + self._refresh_action_cache() + if force_refresh or not self._cached_items: + items = self._fetch_menu(parent_id) + self._cached_items = items + return self._cached_items + + def _refresh_action_cache(self): + for path in dict.fromkeys([self._action_path, self.object_path]): + try: + res = self._call( + "org.gtk.Actions", + "DescribeAll", + None, + GLib.VariantType("(a{s(bgav)})"), + path=path, + ) + if not res: + continue + data = res.get_child_value(0).unpack() + self._action_cache.clear() + for name, (enabled, param_type_v, state_v) in data.items(): + param_type = str(param_type_v) if param_type_v else "" + state = ( + state_v.unpack() + if state_v and state_v.n_children() > 0 + else None + ) + state_type = "" + if state_v and state_v.n_children() > 0: + st = state_v.get_child_value(0) + state_type = st.get_type_string() if st else "" + self._action_cache[name] = _ActionInfo( + enabled=enabled, + parameter_type=param_type, + state=state, + state_type=state_type, + ) + return + except Exception as e: + logger.debug(f"[GtkMenuClient] DescribeAll cache error: {e}") + + def _describe_action(self, action_name: str) -> Optional[_ActionInfo]: + cached = self._action_cache.get(action_name) + if cached is not None: + return cached + for path in dict.fromkeys([self._action_path, self.object_path]): + try: + res = self._call( + "org.gtk.Actions", + "Describe", + GLib.Variant("(s)", (action_name,)), + GLib.VariantType("(bgav)"), + path=path, + ) + if res: + enabled, param_type_v, state_v = res.unpack() + param_type = str(param_type_v) if param_type_v else "" + state = ( + state_v.unpack() + if state_v and state_v.n_children() > 0 + else None + ) + state_type = "" + if state_v and state_v.n_children() > 0: + st = state_v.get_child_value(0) + state_type = st.get_type_string() if st else "" + info = _ActionInfo(enabled, param_type, state, state_type) + self._action_cache[action_name] = info + return info + except Exception as e: + logger.debug(f"[GtkMenuClient] Describe error for {action_name}: {e}") + return None + + def _find_item( + self, item_id: int, items: Optional[List[DBusMenuItem]] = None + ) -> Optional[DBusMenuItem]: + if items is None: + items = self._cached_items + for item in items: + if item.id == item_id: + return item + if item.children: + found = self._find_item(item_id, item.children) + if found: + return found + return None + + def _fetch_menu( + self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None + ) -> List[DBusMenuItem]: + if _depth > 10: + return [] + if _visited is None: + _visited = set() + if menu_id in _visited: + return [] + _visited.add(menu_id) + res = self._call( + "org.gtk.Menus", + "Start", + GLib.Variant("(au)", ([menu_id],)), + GLib.VariantType("(a(uuaa{sv}))"), + ) + if not res: + return [] + + try: + data = res.get_child_value(0).unpack() + if not data: + return [] + + entry_map = {} + for entry in data: + gid, mid, sections = entry + entry_map[(gid, mid)] = sections + + items: List[DBusMenuItem] = [] + + def resolve(sections, visited=None): + if visited is None: + visited = set() + for props in sections: + if not isinstance(props, dict): + continue + if ":section" in props: + ref = props[":section"] + if isinstance(ref, (list, tuple)) and len(ref) == 2: + key = (int(ref[0]), int(ref[1])) + if key not in visited: + visited.add(key) + child = entry_map.get(key) + if child is not None: + resolve(child, visited) + continue + if not props.get("label") and not any( + k in props for k in ("action", ":submenu", "submenu") + ): + continue + item = DBusMenuItem(id=hash((menu_id, len(items)))) + item.label = props.get("label", "").replace("_", "") + item.enabled = props.get("enabled", True) + item.visible = props.get("visible", True) + sub_ref = props.get(":submenu") or props.get("submenu") + if ( + sub_ref + and isinstance(sub_ref, (list, tuple)) + and len(sub_ref) == 2 + ): + item.has_submenu = True + item.children = self._fetch_menu( + int(sub_ref[0]), _depth + 1, _visited + ) + if "action" in props: + item.action_name = props["action"] + if "target" in props: + item.action_target = props["target"] + items.append(item) + + if data: + resolve(data[0][2]) + + return items + except Exception as e: + logger.error(f"[GtkMenuClient] Parse error: {e}") + return [] + + def about_to_show(self, item_id: int) -> bool: + return False + + def click_item( + self, + item_id: int, + action_name: str = "", + action_target=None, + pid: int = 0, + ) -> None: + item_label = "" + if not action_name: + item = self._find_item(item_id) + if item and item.action_name: + action_name = item.action_name + action_target = item.action_target + item_label = item.label or "" + if not action_name: + return + if action_name.startswith("unity."): + action_name = action_name[6:] + + action_info = self._describe_action(action_name) + use_change_state = False + if action_info and action_info.state is not None: + if action_info.state_type == "b" or action_info.parameter_type: + use_change_state = True + + try: + for act_path in (self.object_path, self._action_path): + try: + if ( + use_change_state + and action_info + and action_info.state is not None + ): + new_state = ( + not action_info.state + if action_info.state_type == "b" + else action_info.state + ) + self._call( + "org.gtk.Actions", + "ChangeState", + GLib.Variant( + "(sv)", + ( + action_name, + GLib.Variant(action_info.state_type, new_state), + ), + ), + path=act_path, + ) + return + else: + params_arr = self._build_activate_params( + action_info, action_target + ) + self._call( + "org.gtk.Actions", + "Activate", + GLib.Variant("(sava{sv})", (action_name, params_arr, {})), + path=act_path, + ) + if pid and item_label: + atspi_activate_menu_item(pid, item_label) + return + except Exception as e: + logger.warning( + f"[gtkmenu] if ( use_change_state and action_info and action_info.sta... failed: {e}" + ) + continue + except Exception as e: + logger.error(f"[GtkMenuClient] click failed: {e}") + + if not use_change_state and pid and item_label: + atspi_activate_menu_item(pid, item_label) + + @staticmethod + def _build_activate_params(action_info, action_target): + """Build the parameters array for Activate call.""" + if action_target is not None: + try: + return GLib.Variant("av", [action_target]) + except Exception as e: + logger.warning( + f"[gtkmenu] return GLib.Variant('av', [action_target]) failed: {e}" + ) + if action_info and action_info.parameter_type: + param_type_str = action_info.parameter_type + if param_type_str and param_type_str != "()": + try: + param_type = GLib.VariantType(param_type_str) + default_val = GLib.Variant.new_tuple(GLib.Variant(param_type, None)) + return GLib.Variant("av", [default_val]) + except Exception as e: + logger.warning( + f"[gtkmenu] param_type = GLib.VariantType(param_type_str) failed: {e}" + ) + return GLib.Variant("av", []) + + +class ActionMenuClient: + __slots__ = ( + "_action_cache", + "_action_map", + "_action_path", + "_cached_items", + "object_path", + "service_name", + ) + + def __init__(self, service_name: str, object_path: str): + self.service_name = service_name + self.object_path = object_path + self._action_path = object_path + self._cached_items: List[DBusMenuItem] = [] + self._action_map: Dict[int, str] = {} + self._action_cache: Dict[str, _ActionInfo] = {} + + def _call(self, interface, method, params, reply_type=None, path=None): + try: + return _get_bus().call_sync( + self.service_name, + path or self.object_path, + interface, + method, + params, + reply_type, + Gio.DBusCallFlags.NONE, + 3000, + None, + ) + except Exception as e: + logger.warning( + f"[gtkmenu] return _get_bus().call_sync( self.service_name, path or s... failed: {e}" + ) + return None + + def _refresh_action_cache(self): + try: + res = self._call( + "org.gtk.Actions", + "DescribeAll", + None, + GLib.VariantType("(a{s(bgav)})"), + ) + if not res: + return + data = res.get_child_value(0).unpack() + self._action_cache.clear() + for name, (enabled, param_type_v, state_v) in data.items(): + param_type = str(param_type_v) if param_type_v else "" + state = ( + state_v.unpack() if state_v and state_v.n_children() > 0 else None + ) + state_type = "" + if state_v and state_v.n_children() > 0: + st = state_v.get_child_value(0) + state_type = st.get_type_string() if st else "" + self._action_cache[name] = _ActionInfo( + enabled=enabled, + parameter_type=param_type, + state=state, + state_type=state_type, + ) + except Exception as e: + logger.debug(f"[ActionMenuClient] DescribeAll cache error: {e}") + + def _describe_action(self, action_name: str) -> Optional[_ActionInfo]: + cached = self._action_cache.get(action_name) + if cached is not None: + return cached + try: + res = self._call( + "org.gtk.Actions", + "Describe", + GLib.Variant("(s)", (action_name,)), + GLib.VariantType("(bgav)"), + ) + if res: + enabled, param_type_v, state_v = res.unpack() + param_type = str(param_type_v) if param_type_v else "" + state = ( + state_v.unpack() if state_v and state_v.n_children() > 0 else None + ) + state_type = "" + if state_v and state_v.n_children() > 0: + st = state_v.get_child_value(0) + state_type = st.get_type_string() if st else "" + info = _ActionInfo(enabled, param_type, state, state_type) + self._action_cache[action_name] = info + return info + except Exception as e: + logger.debug(f"[ActionMenuClient] Describe error for {action_name}: {e}") + return None + + def _fetch_menu( + self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None + ) -> List[DBusMenuItem]: + if _depth > 10: + return [] + if _visited is None: + _visited = set() + if menu_id in _visited: + return [] + _visited.add(menu_id) + res = self._call( + "org.gtk.Menus", + "Start", + GLib.Variant("(au)", ([menu_id],)), + GLib.VariantType("(a(uuaa{sv}))"), + ) + if not res: + return [] + + try: + data = res.get_child_value(0).unpack() + if not data: + return [] + + entry_map = {} + for entry in data: + gid, mid, sections = entry + entry_map[(gid, mid)] = sections + + items: List[DBusMenuItem] = [] + + def resolve(sections, visited=None): + if visited is None: + visited = set() + for props in sections: + if not isinstance(props, dict): + continue + if ":section" in props: + ref = props[":section"] + if isinstance(ref, (list, tuple)) and len(ref) == 2: + key = (int(ref[0]), int(ref[1])) + if key not in visited: + visited.add(key) + child = entry_map.get(key) + if child is not None: + resolve(child, visited) + continue + if not props.get("label") and not any( + k in props for k in ("action", ":submenu", "submenu") + ): + continue + item = DBusMenuItem(id=hash((menu_id, len(items)))) + item.label = props.get("label", "").replace("_", "") + item.enabled = props.get("enabled", True) + item.visible = props.get("visible", True) + sub_ref = props.get(":submenu") or props.get("submenu") + if ( + sub_ref + and isinstance(sub_ref, (list, tuple)) + and len(sub_ref) == 2 + ): + item.has_submenu = True + item.children = self._fetch_menu( + int(sub_ref[0]), _depth + 1, _visited + ) + if "action" in props: + item.action_name = props["action"] + if "target" in props: + item.action_target = props["target"] + items.append(item) + + if data: + resolve(data[0][2]) + + return items + except Exception as e: + logger.error(f"[ActionMenuClient] Parse error: {e}") + return [] + + def get_layout( + self, parent_id: int = 0, force_refresh: bool = False + ) -> List[DBusMenuItem]: + if parent_id != 0: + return [] + self._refresh_action_cache() + + menu_items = self._fetch_menu(0) + if menu_items: + self._cached_items = menu_items + return menu_items + + res = self._call( + "org.gtk.Actions", + "DescribeAll", + None, + GLib.VariantType("(a{s(bgav)})"), + ) + if not res: + return self._cached_items + + try: + data = res.get_child_value(0).unpack() + items: List[DBusMenuItem] = [] + self._action_map = {} + for i, (name, (enabled, param_type_v, state_v)) in enumerate(data.items()): + label = name.replace("-", " ").replace("_", " ").strip().title() + param_type = str(param_type_v) if param_type_v else "" + state = None + state_type = "" + if state_v and state_v.n_children() > 0: + st = state_v.get_child_value(0) + state_type = st.get_type_string() if st else "" + try: + state = st.get_value() + except Exception: + state = state_v.unpack() if state_v else None + self._action_cache[name] = _ActionInfo( + enabled, param_type, state, state_type + ) + item = DBusMenuItem(id=hash((0, i))) + item.action_name = name + item.visible = not bool(param_type) or param_type == "b" + can_activate = not bool(param_type) + item.enabled = enabled if can_activate else False + item.label = label + if param_type == "b": + item.type = "toggle" + item.enabled = enabled + self._action_map[item.id] = name + items.append(item) + self._cached_items = items + return items + except Exception as e: + logger.error(f"[ActionMenuClient] parse error: {e}") + return self._cached_items + + def _find_item( + self, item_id: int, items: Optional[List[DBusMenuItem]] = None + ) -> Optional[DBusMenuItem]: + if items is None: + items = self._cached_items + for item in items: + if item.id == item_id: + return item + if item.children: + found = self._find_item(item_id, item.children) + if found: + return found + return None + + def about_to_show(self, item_id: int) -> bool: + return False + + def invalidate(self): + self._cached_items = [] + self._action_map = {} + self._action_cache.clear() + + def click_item( + self, + item_id: int, + action_name: str = "", + action_target=None, + pid: int = 0, + ) -> None: + item_label = "" + if not action_name: + item = self._find_item(item_id) + if item and item.action_name: + action_name = item.action_name + action_target = item.action_target + item_label = item.label or "" + if not action_name: + return + + action_info = self._describe_action(action_name) + use_change_state = False + if action_info and action_info.state is not None: + if action_info.state_type == "b" or action_info.parameter_type: + use_change_state = True + + try: + if use_change_state and action_info and action_info.state is not None: + new_state = ( + not action_info.state + if action_info.state_type == "b" + else action_info.state + ) + self._call( + "org.gtk.Actions", + "ChangeState", + GLib.Variant( + "(sv)", + (action_name, GLib.Variant(action_info.state_type, new_state)), + ), + ) + return + else: + params_arr = self._build_activate_params(action_info, action_target) + self._call( + "org.gtk.Actions", + "Activate", + GLib.Variant("(sava{sv})", (action_name, params_arr, {})), + ) + if pid and item_label: + atspi_activate_menu_item(pid, item_label) + return + except Exception as e: + logger.error(f"[ActionMenuClient] click failed: {e}") + + if not use_change_state and pid and item_label: + atspi_activate_menu_item(pid, item_label) + + @staticmethod + def _build_activate_params(action_info, action_target): + """Build the parameters array for Activate call.""" + if action_target is not None: + try: + return GLib.Variant("av", [action_target]) + except Exception as e: + logger.warning( + f"[gtkmenu] return GLib.Variant('av', [action_target]) failed: {e}" + ) + if action_info and action_info.parameter_type: + param_type_str = action_info.parameter_type + if param_type_str and param_type_str != "()": + try: + param_type = GLib.VariantType(param_type_str) + default_val = GLib.Variant.new_tuple(GLib.Variant(param_type, None)) + return GLib.Variant("av", [default_val]) + except Exception as e: + logger.warning( + f"[gtkmenu] param_type = GLib.VariantType(param_type_str) failed: {e}" + ) + return GLib.Variant("av", []) diff --git a/src/window/globalmenu/launch.py b/src/window/globalmenu/launch.py new file mode 100644 index 00000000..8d8fbb16 --- /dev/null +++ b/src/window/globalmenu/launch.py @@ -0,0 +1,84 @@ +"""GTK3-aware application launching for the global menu compatibility shim. + +The legacy LD_PRELOAD approach exported ``libmenu_button_shim.so`` globally. +That shim links against GTK3, so loading it into a GTK4 process crashes the +app. Instead we detect the toolkit of the target executable and inject the +shim only into GTK3 processes, leaving GTK4 (and other) apps untouched. + +Two launch paths are supported: + +* :func:`launch_command` — for raw command lines (used by the dock launcher + and forwarded through ``uwsm app``). The shim is prepended via ``env`` so it + is set only for the spawned process. +* :func:`launch_desktop_app` — for :class:`fabric.utils.DesktopApp` objects, + which launch through GIO and inherit the current process environment. We + temporarily set ``LD_PRELOAD`` around the launch call and restore it + immediately afterwards. +""" + +import os +import re + +from fabric.utils import exec_shell_command_async, logger + +from window.globalmenu.detection import executable_gtk_class +from window.globalmenu.environment import get_compiled_shim + +_PERCENT_RE = re.compile(r"%\w+") + + +def _gtk3_shim() -> str | None: + """Return the shim path if it is compiled, else ``None``.""" + shim = get_compiled_shim() + return str(shim) if shim else None + + +def shim_env_for_executable(executable: str | None) -> dict[str, str] | None: + """Return ``{"LD_PRELOAD": }`` for a GTK3 executable, else ``None``.""" + if executable_gtk_class(executable) != "gtk3": + return None + shim = _gtk3_shim() + if not shim: + return None + return {"LD_PRELOAD": shim} + + +def launch_command(command_line: str) -> None: + """Launch a command through ``uwsm app``, injecting the GTK3 shim if needed.""" + if not command_line: + return + + cleaned = _PERCENT_RE.sub("", command_line).strip() + if not cleaned: + return + + shim_env = shim_env_for_executable(cleaned) + if shim_env: + # Scope the shim to the launched process only. + cleaned = f"env LD_PRELOAD={shim_env['LD_PRELOAD']} {cleaned}" + logger.debug(f"[GlobalMenu] Injecting GTK3 shim for: {cleaned}") + + exec_shell_command_async( + f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'" + ) + + +def launch_desktop_app(app) -> None: + """Launch a :class:`fabric.utils.DesktopApp`, injecting the GTK3 shim if needed.""" + shim_env = shim_env_for_executable(getattr(app, "executable", None)) + if shim_env: + prev = os.environ.get("LD_PRELOAD") + os.environ["LD_PRELOAD"] = shim_env["LD_PRELOAD"] + logger.debug( + f"[GlobalMenu] Injecting GTK3 shim for desktop app: " + f"{getattr(app, 'display_name', app)}" + ) + try: + app.launch() + finally: + if prev is None: + os.environ.pop("LD_PRELOAD", None) + else: + os.environ["LD_PRELOAD"] = prev + else: + app.launch() diff --git a/src/window/globalmenu/libmenu_button_shim.c b/src/window/globalmenu/libmenu_button_shim.c new file mode 100644 index 00000000..29610328 --- /dev/null +++ b/src/window/globalmenu/libmenu_button_shim.c @@ -0,0 +1,51 @@ +#define _GNU_SOURCE +#include +#include + +typedef void (*orig_gtk_menu_item_activate)(GtkMenuItem *menu_item); + +static int in_hook = 0; + +typedef struct { + GtkWidget *widget; +} WidgetRef; + +static gboolean do_emit(gpointer data) { + WidgetRef *ref = data; + GtkWidget *w = ref->widget; + g_free(ref); + if (!GTK_IS_WIDGET(w)) + return G_SOURCE_REMOVE; + + GdkEvent event; + memset(&event, 0, sizeof(event)); + event.type = GDK_BUTTON_RELEASE; + event.any.window = gtk_widget_get_window(w); + event.any.send_event = TRUE; + + gboolean handled = FALSE; + g_signal_emit_by_name(w, "button-release-event", &event, &handled); + return G_SOURCE_REMOVE; +} + +void gtk_menu_item_activate(GtkMenuItem *menu_item) { + if (in_hook) { + orig_gtk_menu_item_activate orig = + (orig_gtk_menu_item_activate)dlsym(RTLD_NEXT, "gtk_menu_item_activate"); + orig(menu_item); + return; + } + in_hook = 1; + + orig_gtk_menu_item_activate orig = + (orig_gtk_menu_item_activate)dlsym(RTLD_NEXT, "gtk_menu_item_activate"); + orig(menu_item); + + in_hook = 0; + + /* After real activate, schedule synthetic button-release-event so apps + that connect to button-release-event instead of activate respond. */ + WidgetRef *ref = g_new(WidgetRef, 1); + ref->widget = GTK_WIDGET(menu_item); + g_timeout_add(50, do_emit, ref); +} diff --git a/src/window/globalmenu/service.py b/src/window/globalmenu/service.py new file mode 100644 index 00000000..b0b6b7d8 --- /dev/null +++ b/src/window/globalmenu/service.py @@ -0,0 +1,1097 @@ +"""Global Menu Service — event-driven, persistent importer architecture.""" + +import json +import re +import threading +import time +from dataclasses import dataclass +from typing import Optional, Union + +from fabric.core.service import Property, Service, Signal +from fabric.utils import ( + Gio, + GLib, + exec_shell_command, + idle_add, + logger, + os, +) + +from window.globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus +from window.globalmenu.environment import setup_global_menu_environment +from window.globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient + +_NODE_NAME_RE = re.compile(r' str: + if not path or not GLib.Variant.is_object_path(path): + return "" + key = f"{service}:{path}" + with self._lock: + cached = self._cache.get(key) + if cached is not None: + return cached + xml = _dbus_introspect(bus, service, path, timeout) + with self._lock: + self._cache[key] = xml + return xml + + def invalidate(self, service: str): + prefix = f"{service}:" + with self._lock: + self._cache = { + k: v for k, v in self._cache.items() if not k.startswith(prefix) + } + + +@dataclass +class AppServiceInfo: + service_name: str + object_path: str = "" + pid: int = 0 + executable: str = "" + wm_class: str = "" + app_id: str = "" + importer: Optional[_MenuClient] = None + revision: int = 0 + connected_signals: bool = False + + +def _resolve_executable(pid: int) -> str: + try: + return os.path.realpath(f"/proc/{pid}/exe") + except Exception as e: + logger.warning( + f"[service] return os.path.realpath(f'/proc/(pid)/exe') failed: {e}" + ) + return "" + + +def _dbus_introspect(bus, service: str, path: str, timeout: int) -> str: + if not path or not GLib.Variant.is_object_path(path): + return "" + try: + res = bus.call_sync( + service, + path, + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + GLib.VariantType("(s)"), + Gio.DBusCallFlags.NONE, + timeout, + None, + ) + return res.get_child_value(0).get_string() if res else "" + except Exception as e: + logger.warning( + f"[service] res = bus.call_sync( service, path, 'org.freedesktop.DBus... failed: {e}" + ) + return "" + + +def _dbus_get_pid(bus, service: str, timeout: int) -> int: + try: + res = bus.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetConnectionUnixProcessID", + GLib.Variant("(s)", (service,)), + GLib.VariantType("(u)"), + Gio.DBusCallFlags.NONE, + timeout, + None, + ) + return res.get_child_value(0).get_uint32() if res else 0 + except Exception as e: + logger.warning( + f"[service] res = bus.call_sync( 'org.freedesktop.DBus', '/org/freede... failed: {e}" + ) + return 0 + + +REGISTRAR_XML = """ + + + + + + + + + + + + + + + + + + + +""" + + +class GlobalMenuService(Service): + """Service that extracts and provides application menus for the global menu bar.""" + + @Signal + def menu_changed(self, menu_items: object) -> None: ... + + @Signal + def active_app_changed(self, app_name: str, wm_class: str) -> None: ... + + @Property(object, flags="read-write") + def current_menu(self) -> Optional[list[DBusMenuItem]]: + return self._current_menu + + def __init__(self): + super().__init__() + self._current_menu: Optional[list[DBusMenuItem]] = None + self._current_app: str = "" + self._current_wm_class: str = "" + self._current_pid: int = 0 + + self._service_registry: dict[str, AppServiceInfo] = {} + self._pid_to_service: dict[int, str] = {} + + self._introspection_cache = _IntrospectionCache() + + self._seq_lock = threading.Lock() + self._extraction_seq = 0 + self._extracting_in_flight: set[str] = set() + + self._inflight_lock = threading.Lock() + self._registry_lock = threading.Lock() + self._state_lock = threading.Lock() + + self._registered_menus: dict[str, tuple[str, str]] = {} + + self._setup_registrar() + self._subscribe_name_owner_changed() + threading.Thread(target=self._setup_environment, daemon=True).start() + threading.Thread(target=self._scan_initial_services, daemon=True).start() + + logger.info("[GlobalMenuService] Initialized event-driven registry") + + def _subscribe_name_owner_changed(self): + try: + bus = _get_bus() + bus.signal_subscribe( + "org.freedesktop.DBus", + "org.freedesktop.DBus", + "NameOwnerChanged", + "/org/freedesktop/DBus", + None, + Gio.DBusCallFlags.NONE, + self._on_name_owner_changed, + ) + logger.debug("[GlobalMenuService] Subscribed to NameOwnerChanged") + except Exception as e: + logger.error( + f"[GlobalMenuService] Failed to subscribe NameOwnerChanged: {e}" + ) + + def _on_name_owner_changed( + self, + _connection, + _sender_name, + _object_path, + _interface_name, + _signal_name, + parameters, + ): + try: + name, old_owner, new_owner = parameters.unpack() + except Exception as e: + logger.warning( + f"[service] name, old_owner, new_owner = parameters.unpack() failed: {e}" + ) + return + + if not name or name.startswith(":") or name == "org.freedesktop.DBus": + return + + if new_owner and not old_owner: + threading.Thread( + target=self._on_service_appeared, + args=(name,), + daemon=True, + ).start() + elif not new_owner: + self._on_service_disappeared(name) + + def _on_service_appeared(self, service_name: str): + try: + bus = _get_bus() + pid = _dbus_get_pid(bus, service_name, _DBUS_TIMEOUT_FAST) + if pid <= 0: + return + executable = _resolve_executable(pid) + self._register_new_service(service_name, pid, executable) + self._introspection_cache.invalidate(service_name) + logger.debug( + f"[GlobalMenuService] Registered new service: {service_name} pid={pid}" + ) + except Exception as e: + logger.debug( + f"[GlobalMenuService] Failed to register new service {service_name}: {e}" + ) + + def _on_service_disappeared(self, service_name: str): + with self._registry_lock: + entry = self._service_registry.pop(service_name, None) + if entry is None: + return + if entry.pid: + self._pid_to_service.pop(entry.pid, None) + self._registered_menus.pop(service_name, None) + if isinstance(entry.importer, DBusMenuClient): + try: + entry.importer.disconnect_signals() + except Exception as e: + logger.warning( + f"[service] entry.importer.disconnect_signals() failed: {e}" + ) + + self._introspection_cache.invalidate(service_name) + + with self._state_lock: + if entry.wm_class and entry.wm_class == self._current_wm_class: + self._current_menu = None + + logger.debug( + f"[GlobalMenuService] Cleaned up disappeared service: {service_name}" + ) + + def _setup_registrar(self): + try: + self._bus = _get_bus() + self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML) + self._bus.register_object( + "/com/canonical/AppMenu/Registrar", + self._node.interfaces[0], + self._handle_registrar_method, + None, + None, + ) + Gio.bus_own_name_on_connection( + self._bus, + "com.canonical.AppMenu.Registrar", + Gio.BusNameOwnerFlags.NONE, + None, + None, + ) + logger.info("[GlobalMenuService] AppMenu Registrar DBus service started") + except Exception as e: + logger.error(f"[GlobalMenuService] Failed to start Registrar: {e}") + + def _setup_environment(self): + setup_global_menu_environment() + + def _handle_registrar_method( + self, + connection, + sender, + object_path, + _interface_name, + method_name, + parameters, + invocation, + ): + try: + if method_name == "RegisterWindow": + window_id, menu_path = parameters.unpack() + logger.debug( + f"[GlobalMenuService] App {sender} registered menu {menu_path} for window {window_id}" + ) + with self._registry_lock: + self._registered_menus[sender] = (sender, menu_path) + invocation.return_value(None) + + elif method_name == "UnregisterWindow": + with self._registry_lock: + self._registered_menus.pop(sender, None) + invocation.return_value(None) + + elif method_name == "GetMenuForWindow": + window_id = parameters.unpack()[0] + with self._registry_lock: + entry = self._registered_menus.get(sender) + if entry: + svc_name, svc_path = entry + invocation.return_value(GLib.Variant("(so)", (svc_name, svc_path))) + else: + invocation.return_value( + GLib.Variant("(so)", (sender, "/com/canonical/menu/0")) + ) + + elif method_name == "GetMenus": + with self._registry_lock: + menus = [ + (0, svc, path) + for sender, (svc, path) in self._registered_menus.items() + ] + invocation.return_value(GLib.Variant("(a(uso))", (menus,))) + except Exception as e: + logger.error(f"[GlobalMenuService] Registrar error {method_name}: {e}") + invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e)) + + def _uid(self, wm_class: str, pid: int) -> str: + return f"{wm_class}:{pid}" + + def _resolve_current_entry(self) -> tuple[Optional[AppServiceInfo], int]: + """Return the registry entry and PID for the currently active window.""" + with self._state_lock: + pid = self._current_pid + with self._registry_lock: + svc = self._pid_to_service.get(pid) + entry = self._service_registry.get(svc) if svc else None + return entry, pid + + def _register_new_service( + self, service_name: str, pid: int, executable: str = "" + ) -> None: + """Add a newly discovered service to the registry if not already present.""" + with self._registry_lock: + if service_name in self._service_registry: + return + self._service_registry[service_name] = AppServiceInfo( + service_name=service_name, + pid=pid, + executable=executable, + ) + if pid: + self._pid_to_service[pid] = service_name + + def update_active_window(self, app_name: str, wm_class: str): + target_pid = 0 + try: + out = exec_shell_command("hyprctl activewindow -j") + if out: + data = json.loads(out) + target_pid = data.get("pid", 0) + except Exception as e: + logger.warning( + f"[service] out = exec_shell_command('hyprctl activewindow -j') failed: {e}" + ) + + with self._state_lock: + same_class = wm_class == self._current_wm_class + same_pid = target_pid != 0 and target_pid == self._current_pid + + if same_class and same_pid: + with self._state_lock: + current_menu = self._current_menu + if current_menu is not None: + self.menu_changed(current_menu) + return + + logger.info( + f"[GlobalMenuService] Active window changed: {app_name} ({wm_class}) pid={target_pid}" + ) + + with self._state_lock: + self._current_app = app_name + self._current_wm_class = wm_class + self._current_pid = target_pid + + with self._seq_lock: + self._extraction_seq += 1 + seq = self._extraction_seq + + self.active_app_changed(app_name, wm_class) + + with self._registry_lock: + svc_name = self._pid_to_service.get(target_pid) if target_pid else None + registered_entry = ( + self._service_registry.get(svc_name) if svc_name else None + ) + + if registered_entry and registered_entry.importer: + cached_menu = getattr(registered_entry.importer, "_cached_items", None) + if cached_menu is not None and len(cached_menu) > 0: + with self._state_lock: + self._current_menu = cached_menu + logger.info(f"[GlobalMenuService] Registry cache hit for '{wm_class}'") + self.menu_changed(cached_menu) + return + thread = threading.Thread( + target=self._extract_from_importer, + args=(wm_class, seq, registered_entry.importer, ""), + daemon=True, + ) + thread.start() + return + + with self._state_lock: + self._current_menu = None + self.menu_changed([]) + + inflight_key = self._uid(wm_class, target_pid) + with self._inflight_lock: + if inflight_key in self._extracting_in_flight: + logger.debug( + f"[GlobalMenuService] Extraction in-flight for '{inflight_key}' — skipping" + ) + return + self._extracting_in_flight.add(inflight_key) + + logger.info( + f"[GlobalMenuService] Starting extraction for '{wm_class}' pid={target_pid}" + ) + thread = threading.Thread( + target=self._extract_menu, + args=(wm_class, seq, target_pid, inflight_key), + daemon=True, + ) + thread.start() + + def _connect_importer_signals(self, entry: AppServiceInfo): + if entry.connected_signals or not isinstance(entry.importer, DBusMenuClient): + return + + svc_name = entry.service_name + + def on_layout_updated(parent_id, revision, _properties): + threading.Thread( + target=self._handle_layout_updated, + args=(svc_name, parent_id, revision), + daemon=True, + ).start() + + def on_items_updated(updated_props, removed_ids): + threading.Thread( + target=self._handle_items_updated, + args=(svc_name, updated_props, removed_ids), + daemon=True, + ).start() + + entry.importer.connect_signals( + on_layout_updated=on_layout_updated, + on_items_updated=on_items_updated, + on_item_activated=lambda item_id, _ts: logger.debug( + f"[GlobalMenuService] Item activated: {item_id}" + ), + ) + entry.connected_signals = True + logger.debug(f"[GlobalMenuService] Connected signals for {entry.service_name}") + + def _handle_layout_updated(self, svc_name: str, parent_id: int, revision: int): + with self._registry_lock: + entry = self._service_registry.get(svc_name) + if not entry or not entry.importer: + return + entry.revision = revision + subtree = entry.importer.update_layout(parent_id, revision) + if subtree is None: + return + if parent_id == 0: + with self._state_lock: + if entry.wm_class == self._current_wm_class: + self._current_menu = subtree + with self._seq_lock: + self._extraction_seq += 1 + seq = self._extraction_seq + idle_add(self._emit_menu_changed, subtree, seq) + + def _handle_items_updated( + self, + svc_name: str, + updated_props: list[tuple[int, dict]], + removed_ids: list[int], + ): + with self._registry_lock: + entry = self._service_registry.get(svc_name) + if ( + not entry + or not entry.importer + or not isinstance(entry.importer, DBusMenuClient) + ): + return + entry.importer.apply_items_update(updated_props, removed_ids) + with self._state_lock: + if entry.wm_class == self._current_wm_class: + self._current_menu = entry.importer.get_layout() + + def _extract_from_importer( + self, + wm_class: str, + seq: int, + client: _MenuClient, + inflight_key: str, + ): + try: + with self._seq_lock: + if seq != self._extraction_seq: + return + + items = client.get_layout() or [] + + with self._seq_lock: + current_seq = self._extraction_seq + + if seq != current_seq: + return + + with self._state_lock: + if wm_class == self._current_wm_class: + self._current_menu = items + + idle_add(self._emit_menu_changed, items, current_seq) + except Exception as e: + logger.error( + f"[GlobalMenuService] Error extracting from importer for '{wm_class}': {e}" + ) + finally: + if inflight_key: + with self._inflight_lock: + self._extracting_in_flight.discard(inflight_key) + + def _scan_initial_services(self): + try: + bus = _get_bus() + res = bus.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "ListNames", + None, + GLib.VariantType("(as)"), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + if not res: + return + names = res.get_child_value(0).unpack() + for name in names: + if name.startswith(":"): + continue + pid = _dbus_get_pid(bus, name, _DBUS_TIMEOUT_FAST) + if pid <= 0: + continue + executable = _resolve_executable(pid) + self._register_new_service(name, pid, executable) + logger.debug( + f"[GlobalMenuService] Initial scan: registered {len(names)} services" + ) + except Exception as e: + logger.debug(f"[GlobalMenuService] Initial scan error: {e}") + + def _discover_dbus_client( + self, wm_class: str, target_pid: int = 0 + ) -> Optional[_MenuClient]: + try: + if target_pid <= 0: + out = exec_shell_command("hyprctl activewindow -j") + if not out: + return None + target_pid = json.loads(out).get("pid", 0) + if target_pid <= 0: + return None + + strategies = [ + self._find_registry, + self._find_registrar, + self._find_fallback_scan, + ] + + for strategy in strategies: + result = strategy(wm_class, target_pid) + if result is not None: + return result + except Exception as e: + logger.debug(f"[GlobalMenuService] Discovery failed: {e}") + return None + + def _find_registry(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]: + with self._registry_lock: + svc_name = self._pid_to_service.get(target_pid) + entry = self._service_registry.get(svc_name) if svc_name else None + if entry and entry.importer: + logger.info(f"[GlobalMenuService] Registry match: {svc_name}") + return entry.importer + return None + + def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]: + bus = _get_bus() + with self._registry_lock: + registered = dict(self._registered_menus) + for sender, (service, path) in registered.items(): + try: + pid = _dbus_get_pid(bus, service, _DBUS_TIMEOUT_FAST) + if pid == target_pid or self._is_same_app(pid, target_pid): + logger.info( + f"[GlobalMenuService] Registrar match: {service} {path}" + ) + client = DBusMenuClient(service, path) + if client.get_layout(): + self._cache_discovery(service, path, pid, wm_class, client) + return client + gtk_client = GtkMenuClient(service, path) + if gtk_client.get_layout(): + self._cache_discovery(service, path, pid, wm_class, gtk_client) + return gtk_client + self._cache_discovery(service, path, pid, wm_class, client) + return client + except Exception as e: + if "NameHasNoOwner" in str(e): + with self._registry_lock: + self._registered_menus.pop(sender, None) + return None + + def _find_fallback_scan( + self, wm_class: str, target_pid: int + ) -> Optional[_MenuClient]: + bus = _get_bus() + try: + res = bus.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "ListNames", + None, + GLib.VariantType("(as)"), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + if not res: + return None + names = res.get_child_value(0).unpack() + except Exception as e: + logger.warning( + f"[service] res = bus.call_sync( 'org.freedesktop.DBus', '/org/freede... failed: {e}" + ) + return None + + services = [ + n + for n in names + if n != "org.freedesktop.DBus" and not n.startswith("org.freedesktop.DBus") + ] + + for svc in services: + try: + pid = _dbus_get_pid(bus, svc, _DBUS_TIMEOUT_PID) + if not (pid == target_pid or self._is_same_app(pid, target_pid)): + continue + + with self._registry_lock: + if reg_entry := self._registered_menus.get(svc): + logger.info( + f"[GlobalMenuService] Found Registrar path {reg_entry[1]} for {svc}" + ) + client = DBusMenuClient(reg_entry[0], reg_entry[1]) + self._cache_discovery(svc, reg_entry[1], pid, wm_class, client) + return client + + _safe_cls = re.sub(r"[^A-Za-z0-9_]", "", wm_class) if wm_class else "" + _safe_cap = _safe_cls.capitalize() if _safe_cls else "" + + dynamic_bases = self._build_dynamic_bases(svc, _safe_cls, _safe_cap) + fallback_paths, _seen_fallback = self._introspect_paths( + bus, svc, dynamic_bases + ) + + for path in fallback_paths: + xml = self._introspection_cache.get( + svc, path, bus, _DBUS_TIMEOUT_INTROSPECT + ) + if "com.canonical.dbusmenu" in xml: + logger.info(f"[GlobalMenuService] DBusMenu found: {svc} {path}") + client = DBusMenuClient(svc, path) + self._cache_discovery(svc, path, pid, wm_class, client) + return client + + gtk_candidates = [] + for path in fallback_paths: + xml = self._introspection_cache.get( + svc, path, bus, _DBUS_TIMEOUT_INTROSPECT + ) + if "org.gtk.Menus" in xml: + gtk_candidates.append((svc, path)) + + existing_gtk_paths = {p for _, p in gtk_candidates} + for dfs_path in self._dfs_find_interface(svc, "org.gtk.Menus"): + if dfs_path not in existing_gtk_paths: + gtk_candidates.append((svc, dfs_path)) + existing_gtk_paths.add(dfs_path) + + for gsvc, gpath in gtk_candidates: + try: + test_client = GtkMenuClient(gsvc, gpath) + test_items = test_client.get_layout() + if not test_items: + for _ in range(4): + time.sleep(0.05) + if test_items := test_client.get_layout(): + break + if test_items: + logger.info( + f"[GlobalMenuService] GTK menu found: {gsvc} {gpath} ({len(test_items)} items)" + ) + self._cache_discovery( + gsvc, gpath, pid, wm_class, test_client + ) + return test_client + except Exception as e: + logger.warning( + f"[service] test_client = GtkMenuClient(gsvc, gpath) failed: {e}" + ) + + action_candidates = self._build_action_candidates( + svc, _safe_cls, _safe_cap + ) + action_candidates.extend( + self._dfs_find_interface(svc, "org.gtk.Actions") + ) + + for act_base in action_candidates: + xml = self._introspection_cache.get( + svc, act_base, bus, _DBUS_TIMEOUT_FAST + ) + if 'interface name="org.gtk.Actions"' in xml: + test_client = ActionMenuClient(svc, act_base) + if test_items := test_client.get_layout(): + logger.info( + f"[GlobalMenuService] Action menu: {svc} {act_base} ({len(test_items)} items)" + ) + self._cache_discovery( + svc, act_base, pid, wm_class, test_client + ) + return test_client + + logger.info( + f"[GlobalMenuService] No menu found for {svc} wm={wm_class}" + ) + except Exception as e: + logger.warning( + f"[service] pid = _dbus_get_pid(bus, svc, _DBUS_TIMEOUT_PID) failed: {e}" + ) + continue + return None + + def _build_action_candidates( + self, svc: str, safe_cls: str, safe_cap: str + ) -> list[str]: + candidates = ["/"] + name_path = "/" + svc.replace(".", "/") if not svc.startswith(":") else "" + if name_path and GLib.Variant.is_object_path(name_path): + candidates.append(name_path) + candidates.append("/org/gtk/Application") + + if safe_cls: + cls_lower = safe_cls.lower() + candidates.extend([f"/org/{cls_lower}/{safe_cap}", f"/org/{cls_lower}"]) + for ns in [*_DE_NAMESPACES, cls_lower]: + for app_part in (safe_cap, safe_cls): + p = f"/org/{ns}/{app_part}" + if GLib.Variant.is_object_path(p): + candidates.append(p) + + return candidates + + def _build_dynamic_bases(self, svc: str, _safe_cls: str, _safe_cap: str) -> list: + static_paths = [ + "/MenuBar", + "/com/canonical/menu", + "/org/appmenu/gtk/window/menus/menubar", + "/org/appmenu/gtk/window/menus/appmenu", + "/org/appmenu/gtk/window", + "/org/gtk/Application/menus", + "/org/gtk/Application", + "/org/xfce", + "/org/cinnamon", + "/org/libreoffice", + "/org/mate", + "/org/kde", + "/org/gnome", + "/org/enlightenment", + "/org/pantheon", + "/org/elementary", + "/com/deepin", + "/com/solus-project", + "/com/canonical", + ] + + svc_path = "/" + svc.replace(".", "/") if not svc.startswith(":") else "" + if svc_path and GLib.Variant.is_object_path(svc_path): + static_paths.append(svc_path) + + if _safe_cls: + cls_lower = _safe_cls.lower() + for ns in [*_DE_NAMESPACES, cls_lower]: + for app_part in (_safe_cap, _safe_cls): + for suffix in ("menus/menubar", "menus/appmenu", "menus"): + p = f"/org/{ns}/{app_part}/{suffix}" + if GLib.Variant.is_object_path(p): + static_paths.append(p) + return static_paths + + def _introspect_paths(self, bus, svc: str, bases: list) -> tuple[list, set]: + fallback_paths = list(bases) + seen_fallback = set(fallback_paths) + + def _add_paths(parent_path, xml_str): + for child in _NODE_NAME_RE.findall(xml_str): + child_path = f"{parent_path}/{child}" + if ( + GLib.Variant.is_object_path(child_path) + and child_path not in seen_fallback + ): + seen_fallback.add(child_path) + fallback_paths.insert(0, child_path) + yield child_path + + for base in bases: + if not GLib.Variant.is_object_path(base): + continue + xml_text = self._introspection_cache.get( + svc, base, bus, _DBUS_TIMEOUT_INTROSPECT + ) + for child_path in _add_paths(base, xml_text): + cxml_text = self._introspection_cache.get( + svc, child_path, bus, _DBUS_TIMEOUT_FAST + ) + list(_add_paths(child_path, cxml_text)) + + return fallback_paths, seen_fallback + + def _cache_discovery( + self, + service: str, + path: str, + pid: int, + wm_class: str, + client: _MenuClient, + ): + with self._registry_lock: + if service not in self._service_registry: + entry = AppServiceInfo( + service_name=service, + object_path=path, + pid=pid, + wm_class=wm_class, + importer=client, + ) + self._service_registry[service] = entry + if pid: + self._pid_to_service[pid] = service + else: + entry = self._service_registry[service] + entry.importer = client + entry.object_path = path + entry.wm_class = wm_class + if pid and not entry.pid: + entry.pid = pid + self._pid_to_service[pid] = service + + self._connect_importer_signals(entry) + + def _dfs_find_interface( + self, + service: str, + interface: str, + path: str = "/", + _depth: int = 0, + _visited: Optional[set] = None, + _node_count: Optional[list[int]] = None, + ) -> list[str]: + if _node_count is None: + _node_count = [0] + if ( + _depth > 6 + or _node_count[0] > 100 + or (_visited is not None and path in _visited) + ): + return [] + if _visited is None: + _visited = set() + _visited.add(path) + _node_count[0] += 1 + results: list[str] = [] + try: + xml = self._introspection_cache.get( + service, path, _get_bus(), _DBUS_TIMEOUT_INTROSPECT + ) + if not xml: + return results + + if f'interface name="{interface}"' in xml: + results.append(path) + + for child in _NODE_NAME_RE.findall(xml): + child_path = ( + path.rstrip("/") + "/" + child if path != "/" else "/" + child + ) + if not GLib.Variant.is_object_path(child_path): + continue + results.extend( + self._dfs_find_interface( + service, + interface, + child_path, + _depth + 1, + _visited, + _node_count, + ) + ) + except Exception as e: + logger.warning( + f"[service] xml = self._introspection_cache.get( service, path, _get_... failed: {e}" + ) + return results + + def _is_same_app(self, pid1: int, pid2: int) -> bool: + if pid1 == pid2: + return True + try: + exe1 = os.path.realpath(f"/proc/{pid1}/exe") + exe2 = os.path.realpath(f"/proc/{pid2}/exe") + if exe1 and exe2 and exe1 == exe2: + return True + try: + with open(f"/proc/{pid1}/comm") as f: + comm1 = f.read().strip() + with open(f"/proc/{pid2}/comm") as f: + comm2 = f.read().strip() + if comm1 and comm2 and comm1 == comm2: + return True + except OSError as e: + logger.warning( + f"[service] with open(f'/proc/(pid1)/comm') as f: comm1 = f.read().st... failed: {e}" + ) + + def _ppid(p: int) -> int: + try: + with open(f"/proc/{p}/stat") as f: + parts = f.read().split() + return int(parts[3]) if len(parts) > 4 else 0 + except Exception as e: + logger.warning( + f"[service] with open(f'/proc/(p)/stat') as f: parts = f.read().split... failed: {e}" + ) + return 0 + + if _ppid(pid1) == pid2 or _ppid(pid2) == pid1: + return True + except (OSError, PermissionError, FileNotFoundError) as e: + logger.warning( + f"[service] exe1 = os.path.realpath(f'/proc/(pid1)/exe') failed: {e}" + ) + return False + + def _extract_menu( + self, wm_class: str, seq: int, target_pid: int = 0, inflight_key: str = "" + ): + try: + with self._seq_lock: + if seq != self._extraction_seq: + return + + client = self._discover_dbus_client(wm_class, target_pid) + items = [] + + if client: + items = client.get_layout() + + if items: + logger.info( + f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items" + ) + else: + logger.info(f"[GlobalMenuService] No DBus menu found for '{wm_class}'") + + with self._seq_lock: + current_seq = self._extraction_seq + + if seq != current_seq: + return + + with self._state_lock: + if wm_class == self._current_wm_class: + self._current_menu = items + + idle_add(self._emit_menu_changed, items, current_seq) + + except Exception as e: + logger.error( + f"[GlobalMenuService] Error extracting menu for '{wm_class}': {e}" + ) + finally: + if inflight_key: + with self._inflight_lock: + self._extracting_in_flight.discard(inflight_key) + + def _emit_menu_changed(self, items: list[DBusMenuItem], seq: int = 0): + if seq: + with self._seq_lock: + if seq != self._extraction_seq: + return False + with self._state_lock: + self._current_menu = items + self.menu_changed(items) + return False + + def click_item(self, item_id: int) -> bool: + entry, pid = self._resolve_current_entry() + if entry and entry.importer: + entry.importer.click_item(item_id, pid=pid) + return True + return False + + def about_to_show(self, item_id: int) -> bool: + entry, _pid = self._resolve_current_entry() + if entry and entry.importer: + return entry.importer.about_to_show(item_id) + return False + + def refresh_menu_sync(self) -> list[DBusMenuItem]: + entry, pid = self._resolve_current_entry() + if not (entry and entry.importer): + return [] + items = entry.importer.get_layout(force_refresh=True) + with self._state_lock: + if pid == self._current_pid: + self._current_menu = items + return items + + +_global_menu_svc_instance = None + + +def get_global_menu_service() -> GlobalMenuService: + global _global_menu_svc_instance + if _global_menu_svc_instance is None: + _global_menu_svc_instance = GlobalMenuService() + return _global_menu_svc_instance diff --git a/lock.py b/src/window/lock.py similarity index 72% rename from lock.py rename to src/window/lock.py index 86a889c7..18bbe4be 100644 --- a/lock.py +++ b/src/window/lock.py @@ -1,36 +1,34 @@ -from widgets.circle_image import CircleImage as Image -from modules.panel.components.indicators import ( +import getpass + +import pam +from fabric import Application +from fabric.utils import Gdk, GLib, get_relative_path, logger, os +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.shapes import Corner +from fabric.widgets.window import Window +from gi.repository import GtkSessionLock # pyright: ignore[reportAttributeAccessIssue] + +from shared.widgets.circle_image import CircleImage as Image +from utils.functions import set_process_name +from utils.gtk_utils import svg_file +from window.panel.components.indicators import ( BatteryIndicator, BluetoothIndicator, NetworkIndicator, ) -from gi.repository import ( - Gdk, # pyright: ignore[reportMissingModuleSource] - GLib, - GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue] -) -from fabric.widgets.window import Window -from fabric.widgets.label import Label -from fabric.widgets.entry import Entry -from fabric.widgets.datetime import DateTime -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.box import Box -from fabric.utils import get_relative_path -from fabric import Application -import os -import getpass -import setproctitle -import gi -import pam -gi.require_version("Gdk", "3.0") -gi.require_version("Gtk", "3.0") - - -gi.require_version("GtkSessionLock", "0.1") -# from fabric.widgets.image import Image - -# from widgets.wayland import WaylandWindow as Window +for log in [ + "fabric.audio.service", + "fabric.core.application", + "fabric.bluetooth.service", + "fabric.notifications.service", + "services.network", +]: + logger.disable(log) class IndicatorBox(Box): @@ -62,7 +60,7 @@ def __init__(self, on_activate, *args, **kwargs): face_icon = os.path.expanduser("~/.face.icon") if not os.path.exists(face_icon): - face_icon = get_relative_path("./assets/default.png") + face_icon = get_relative_path("../assets/default.png") self.password_entry.set_property("xalign", 0.5) self.username_label = Label( label=f"{getpass.getuser().title()}", @@ -136,16 +134,20 @@ def __init__(self, on_activate, *args, **kwargs): class LockScreen(Window): def __init__(self, lock: GtkSessionLock.Lock): - self._hide_timeout_id = None # prevent AttributeError + self._hide_timeout_id = None self.lock = lock self.content = ContentBox(self.on_activate) + self.lock_notch = self._build_lock_notch() super().__init__( title="lock", visible=False, all_visible=False, name="lockscreen-bg", anchor="center", - child=self.content, + child=Box( + orientation="vertical", + children=[self.lock_notch, self.content], + ), ) self.content.password_entry.set_visible(False) @@ -153,9 +155,50 @@ def __init__(self, lock: GtkSessionLock.Lock): bg = os.path.expanduser("~/.current.wall") if not os.path.exists(bg): - bg = get_relative_path("./assets/wallpapers_example/example-1.png") + bg = get_relative_path("../assets/wallpapers_example/example-1.png") self.set_style(f"background-image: url('{bg}');") + def _build_lock_notch(self): + return CenterBox( + name="lock-notch", + orientation="h", + h_align="center", + v_align="center", + start_children=Box( + name="lock-notch-corner-left", + orientation="v", + h_align="start", + children=[ + Corner( + name="lock-notch-corner", + orientation="top-right", + size=20, + ), + ], + ), + center_children=CenterBox( + name="lock-notch-stack", + v_expand=True, + h_expand=True, + end_children=Box( + children=[svg_file("notch/lock.svg", size=16)], + style="margin-right: 12px;", + ), + ), + end_children=Box( + name="lock-notch-corner-right", + orientation="v", + h_align="end", + children=[ + Corner( + name="lock-notch-corner", + orientation="top-left", + size=20, + ), + ], + ), + ) + def _on_keypress(self, widget, event): keyval = event.keyval @@ -214,21 +257,26 @@ def initialize(): ), ) lockscreen.show() + return lock, lockscreen -if __name__ == "__main__": - setproctitle.setproctitle("lockscreen") - initialize() - lockscreen = LockScreen(GtkSessionLock.Lock()) +def main(): + set_process_name("lockscreen") + _lock, lockscreen = initialize() + global app app = Application("lock", lockscreen) def set_css(): app.set_stylesheet_from_file( - get_relative_path("main.css"), + get_relative_path("../styles/main.css"), ) app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue] app.set_css() # pyright: ignore[reportAttributeAccessIssue] app.run() + + +if __name__ == "__main__": + main() diff --git a/src/window/notification/__init__.py b/src/window/notification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/notification/notification.py b/src/window/notification/notification.py similarity index 69% rename from modules/notification/notification.py rename to src/window/notification/notification.py index 77be7469..f057abcb 100644 --- a/modules/notification/notification.py +++ b/src/window/notification/notification.py @@ -1,113 +1,67 @@ -import os import hashlib import time -import uuid -from fabric.utils import get_relative_path -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # type: ignore -from loguru import logger - -import config.data as data -from .unified_cache import ( - get_unified_cache_key, - save_to_cache, - get_from_cache, - cleanup_cache, - get_fallback_icon, - ensure_cache_dir -) from fabric.notifications import ( Notification, NotificationAction, NotificationCloseReason, ) +from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger, os from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image from fabric.widgets.label import Label -from utils.roam import modus_service -from utils.functions import escape_markup_text -from widgets.custom_image import CustomImage -from widgets.customrevealer import SlideRevealer -from widgets.wayland import WaylandWindow as Window +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data +from services.config import get_config, on_config_change from services.modus import notification_service +from shared.widgets.clipping_box import ClippingBox +from shared.widgets.custom_image import CustomImage +from shared.widgets.customrevealer import SlideRevealer +from utils.gtk_utils import setup_cursor_hover +from utils.functions import escape_markup_text +from utils.roam import modus_service +from utils.icon_resolver import IconResolver + +# ruff: noqa: I001 +from window.notification.unified_cache import ( + UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR, + cleanup_cache as _unified_cleanup_cache, + cleanup_old_cache_files as _unified_cleanup_old_cache_files, + ensure_cache_dir as _ensure_unified_cache_dir, + get_fallback_icon as _shared_fallback_icon, + get_from_cache as _unified_get_from_cache, + get_unified_cache_key, + save_to_cache as _unified_save_to_cache, +) -NOTIFICATION_WIDTH = 360 -NOTIFICATION_IMAGE_SIZE = 48 +_icon_resolver = IconResolver() NOTIFICATION_WIDTH = 360 NOTIFICATION_IMAGE_SIZE = 48 -# Unified notification cache directory (for both app icons and notification images) -UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") - -# Backward compatibility constants -NOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR -NOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR +# Use shared unified cache directory from unified_cache +NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR +NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR def ensure_notification_cache_dirs(): """Ensure unified notification cache directory exists""" - os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True) + _ensure_unified_cache_dir() def cleanup_old_cache_files(): """Clean up old notification cache files (older than 7 days)""" try: - if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): - return - - current_time = time.time() - week_ago = current_time - (7 * 24 * 60 * 60) # 7 days - - for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): - filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < week_ago: - os.unlink(filepath) - logger.debug(f"Cleaned up old notification cache: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _unified_cleanup_old_cache_files() except Exception as e: logger.warning(f"Failed to cleanup notification cache: {e}") -def get_unified_cache_key(source_data, size=None, app_name=None): - """Generate a unified cache key that works for both app icons and notification images""" - try: - if hasattr(source_data, "get_pixels"): - # For pixbuf data - use hash of pixel data for deterministic caching - try: - pixel_data = source_data.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] - return image_hash - except Exception: - # Fallback to timestamp if pixel data fails - return str(int(time.time()))[:8] - elif isinstance(source_data, str): - # For file paths - create hash-based name - if source_data.startswith("file://"): - source_data = source_data[7:] - - # Create hash from file path and size - hash_input = source_data - if size: - hash_input += f"_{size[0]}x{size[1]}" - - return hashlib.md5(hash_input.encode()).hexdigest()[:8] - else: - # Fallback to timestamp - return str(int(time.time()))[:8] - except Exception: - # Ultimate fallback - return str(int(time.time()))[:8] - - -# Backward compatibility get_cache_key = get_unified_cache_key @@ -115,15 +69,11 @@ def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir): """Save a pixbuf to the specified cache directory""" try: ensure_notification_cache_dirs() - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - - # Don't overwrite existing cache - if os.path.exists(cache_path): - return cache_path - - pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification icon: {cache_key}") - return cache_path + # Delegate to unified cache; ignore returned key for compatibility + result = _unified_save_to_cache(pixbuf, cache_key) + if result and result[0]: + return result[0] + return None except Exception as e: logger.warning(f"Failed to cache notification icon: {e}") return None @@ -135,13 +85,9 @@ def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None): cache_dir = NOTIFICATION_ICON_CACHE_DIR try: - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - if os.path.exists(cache_path): - logger.debug(f"Using cached notification icon: {cache_key}") - logger.debug(f"Using cached notification icon: {cache_key}") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, fallback_size[0], fallback_size[1], True - ) + pixbuf = _unified_get_from_cache(cache_key, fallback_size) + if pixbuf: + return pixbuf except Exception as e: logger.warning(f"Failed to load cached notification icon: {e}") return None @@ -219,27 +165,19 @@ def load_and_cache_local_icon(file_path, cache_key, size): def load_and_cache_theme_icon(icon_name, cache_key, size): """Load an icon from the current theme and cache it""" - # For simplicity, just use fallback since theme icon loading is complex in GTK4 - logger.debug(f"Using fallback for theme icon {icon_name}") + try: + pixbuf = _icon_resolver.get_icon_pixbuf(icon_name, size[0]) + if pixbuf: + save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR) + return pixbuf + except Exception as e: + logger.warning(f"Failed to load theme icon {icon_name}: {e}") + return get_fallback_notification_icon(size) def get_fallback_notification_icon(size=(48, 48)): - """Get the fallback notification icon""" - try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - fallback_path, size[0], size[1], True - ) - except Exception as e: - logger.warning(f"Failed to load fallback notification icon: {e}") - # Create a simple colored rectangle as ultimate fallback - try: - return GdkPixbuf.Pixbuf.new( - GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] - ) - except: - return None + return _shared_fallback_icon(size) def get_notification_image_cache_key(notification_id, image_pixbuf): @@ -256,15 +194,20 @@ def get_notification_image_cache_key(notification_id, image_pixbuf): try: width = image_pixbuf.get_width() height = image_pixbuf.get_height() - dimension_hash = hashlib.md5(f"{width}x{height}".encode()).hexdigest()[:8] + dimension_hash = hashlib.md5( + f"{width}x{height}".encode() + ).hexdigest()[:8] return dimension_hash - except Exception: - pass - + except Exception as e: + logger.error(f"An error occurred: {e}") + # Fallback to timestamp for invalid pixbufs return str(int(time.time()))[:8] - except Exception: + except Exception as e: # Ultimate fallback + logger.warning( + f"[notification] if image_pixbuf and hasattr(image_pixbuf, 'get_pixels'): ... failed: {e}" + ) return str(int(time.time()))[:8] @@ -275,23 +218,16 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): # Generate deterministic cache key based on image content cache_key = get_notification_image_cache_key(notification_id, image_pixbuf) - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - - # Check if already cached to avoid redundant work - if os.path.exists(cache_path): - logger.debug(f"Image cache hit - already exists: {cache_key}") - return cache_path, cache_key - - # Try to scale and save the image try: - scaled_pixbuf = image_pixbuf.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - scaled_pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Generated and cached notification image: {cache_key}") - return cache_path, cache_key + # Let unified cache handle scaling and saving + saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size) + if saved_path: + return saved_path, cache_key + return None, None except Exception as scale_error: - logger.debug(f"Failed to cache image (temp file likely gone): {scale_error}") + logger.debug( + f"Failed to cache image (temp file likely gone): {scale_error}" + ) return None, None except Exception as e: @@ -302,9 +238,7 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): def get_cached_notification_image(cache_key): """Get a cached notification image or return None if not found""" try: - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - return GdkPixbuf.Pixbuf.new_from_file(cache_path) + return _unified_get_from_cache(cache_key) except Exception as e: logger.warning(f"Failed to load cached notification image: {e}") return None @@ -314,23 +248,7 @@ def cleanup_notification_image_cache(cache_key=None): """Clean up notification image cache - specific key or all""" try: ensure_notification_cache_dirs() - - if cache_key: - # Remove specific cached image - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - os.unlink(cache_path) - logger.debug(f"Cleaned up cached notification image: {cache_key}") - else: - # Remove all cached images - for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification image: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _unified_cleanup_cache(cache_key) except Exception as e: logger.warning(f"Failed to cleanup notification image cache: {e}") @@ -342,18 +260,13 @@ def cleanup_notification_specific_caches( try: # Clean up notification image cache if notification_image_cache_key: - cleanup_notification_image_cache(notification_image_cache_key) + _unified_cleanup_cache(notification_image_cache_key) - # Clean up app icon cache for this specific source (only 35x35 version) + # Clean up app icon cache for this specific source (only 64x64 version) if app_icon_source: - # Only clean 35x35 version since we only cache this size now - cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35)) - cache_path_35 = os.path.join( - NOTIFICATION_ICON_CACHE_DIR, f"{cache_key_35}.png" - ) - if os.path.exists(cache_path_35): - os.unlink(cache_path_35) - logger.debug(f"Cleaned up cached app icon (35x35): {cache_key_35}") + # Only clean 64x64 version since we only cache this size now + cache_key_64 = get_unified_cache_key(app_icon_source, (64, 64)) + _unified_cleanup_cache(cache_key_64) except Exception as e: logger.warning(f"Failed to cleanup notification specific caches: {e}") @@ -362,22 +275,7 @@ def cleanup_notification_specific_caches( def cleanup_all_notification_caches(): """Clean up ALL notification caches (icons and images)""" try: - # Clean icon cache - if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): - for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification icon: {filename}") - except Exception as e: - logger.warning( - f"Failed to cleanup icon cache file {filename}: {e}" - ) - - # Clean image cache - cleanup_notification_image_cache() - logger.info("Cleaned up all notification caches") + _unified_cleanup_cache() except Exception as e: logger.warning(f"Failed to cleanup all notification caches: {e}") @@ -400,28 +298,18 @@ def verify_cache_persistence(): if f.endswith(".png") ] - logger.info( - f"Cache persistence check: {len(icon_cache_files)} icons, { - len(image_cache_files) - } images cached" - ) - # Test loading a few cached items to verify they work for cache_file in icon_cache_files[:2]: # Test first 2 icon files try: cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached icon: {cache_file}") + GdkPixbuf.Pixbuf.new_from_file(cache_path) except Exception as e: logger.warning(f"Failed to load cached icon {cache_file}: {e}") for cache_file in image_cache_files[:2]: # Test first 2 image files try: cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached image: {cache_file}") + GdkPixbuf.Pixbuf.new_from_file(cache_path) except Exception as e: logger.warning(f"Failed to load cached image {cache_file}: {e}") @@ -450,13 +338,9 @@ def migrate_persistent_notifications(): # Temp file is gone, ensure app icon is cached as fallback try: if hasattr(notification, "app_icon") and notification.app_icon: - cache_notification_icon(notification.app_icon, (35, 35)) + cache_notification_icon(notification.app_icon, (64, 64)) migrated_count += 1 - logger.debug( - f"Migrated notification for { - notification.app_name - } to use cached app icon" - ) + except Exception as cache_error: logger.debug( f"Failed to cache app icon for {notification.app_name}: { @@ -464,11 +348,6 @@ def migrate_persistent_notifications(): }" ) - if migrated_count > 0: - logger.info( - f"Migrated {migrated_count} persistent notifications to use cached assets" - ) - except Exception as e: logger.warning(f"Failed to migrate persistent notifications: {e}") @@ -488,11 +367,11 @@ def migrate_persistent_notifications(): def preload_notification_assets(notification): """Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE""" try: - # Cache app icon only at content size (35x35) - scale down for headers at runtime + # Cache app icon only at content size (64x64) - scale down for headers at runtime if hasattr(notification, "app_icon") and notification.app_icon: try: - # Only cache at 35x35 to reduce disk usage - headers will scale this down - cache_notification_icon(notification.app_icon, (35, 35)) + # Only cache at 64x64 to reduce disk usage - headers will scale this down + cache_notification_icon(notification.app_icon, (64, 64)) except Exception as icon_error: logger.debug( f"Failed to preload app icon for {notification.app_name}: { @@ -502,7 +381,9 @@ def preload_notification_assets(notification): # Cache notification image if available if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - cache_notification_image(notification.id, notification.image_pixbuf, (35, 35)) + cache_notification_image( + notification.id, notification.image_pixbuf, (128, 128) + ) except Exception as e: logger.warning(f"Failed to preload notification assets: {e}") @@ -551,11 +432,15 @@ class NotificationWidget(Box): def __init__( self, notification: Notification, - timeout_ms=data.NOTIFICATION_TIMEOUT, + timeout_ms=None, show_close_button=True, name="notification", **kwargs, ): + # Get current timeout from config manager if not provided + if timeout_ms is None: + timeout_ms = self._get_current_notification_timeout() + self.show_close_button = show_close_button self.close_button = None self._is_hovered = False @@ -565,57 +450,72 @@ def __init__( ) # Track app icon source for cleanup self._should_cleanup_cache = False # Only cleanup cache on manual dismissal + content_box = Box( + name=f"{name}-inner-content", + orientation="v", + children=[ + self.create_content(notification), + self.create_action_buttons(notification), + ], + ) + + self.hover_event_box = EventBox( + events=["enter-notify-event", "leave-notify-event"], child=content_box + ) + self.hover_event_box.connect("enter-notify-event", self._on_enter_notify) + self.hover_event_box.connect("leave-notify-event", self._on_leave_notify) + super().__init__( size=(NOTIFICATION_WIDTH, -1), name=name, orientation="v", h_align="fill", h_expand=True, - children=[ - self.create_content(notification), - self.create_action_buttons(notification), - ], + children=[self.hover_event_box], ) self.notification = notification self.timeout_ms = timeout_ms self._timeout_id = None - # Add hover events to the main notification widget - self.connect("enter-notify-event", self._on_enter_notify) - self.connect("leave-notify-event", self._on_leave_notify) - self.start_timeout() + self.show_all() def create_header(self, notification): """Create notification header with optimized cached app icon - SINGLE CACHE SIZE""" try: - # Get 35x35 cached icon and scale down to 24x24 for header + # Get 64x64 cached icon and scale down to 24x24 for header cached_app_icon_pixbuf = cache_notification_icon( - notification.app_icon, (35, 35) + notification.app_icon or notification.app_name, (64, 64) ) if cached_app_icon_pixbuf: - # Scale down the 35x35 cached icon to 24x24 for header display + # Scale down the 64x64 cached icon to 24x24 for header display header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple( 24, 24, GdkPixbuf.InterpType.BILINEAR ) - app_icon = CustomImage(pixbuf=header_icon_pixbuf) - app_icon.set_name("notification-icon") + app_icon = ClippingBox( + name="notification-icon", + children=Image(pixbuf=header_icon_pixbuf), + ) else: # Fallback to theme icon if caching fails completely - app_icon = Image( + app_icon = ClippingBox( name="notification-icon", - icon_name="notifications", - icon_size=24, + children=Image( + icon_name="notifications", + icon_size=24, + ), ) except Exception as e: logger.warning(f"Failed to load cached header icon: {e}") # Ultimate fallback - app_icon = Image( + app_icon = ClippingBox( name="notification-icon", - icon_name="notifications", - icon_size=24, + children=Image( + icon_name="notifications", + icon_size=24, + ), ) return CenterBox( @@ -633,21 +533,28 @@ def create_header(self, notification): ], ) ], - end_children=[ - self.create_close_button() if self.show_close_button else Box() - ], + end_children=[Box()], ) def create_content(self, notification): + if self.show_close_button: + self.close_button = Button( + name="notification-close-button", + image=CustomImage(icon_name="close-symbolic", icon_size=18), + on_clicked=lambda *_: self._manual_close(), + ) + self.close_button.get_style_context().add_class("mac-close-button") + setup_cursor_hover(self.close_button) + else: + self.close_button = None + return Box( name="notification-content", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), + children=Image(pixbuf=self._get_notification_pixbuf(notification)), ), Box( name="notification-text", @@ -660,22 +567,20 @@ def create_content(self, notification): children=[ Label( name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), h_align="start", max_chars_width=40, ellipsization="end", ), - # Label( - # name="notification-app-name", - # markup=" | " + notification.app_name, - # h_align="start", - # ellipsization="end", - # ), ], ), ( Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), h_align="start", max_chars_width=45, ellipsization="end", @@ -693,29 +598,28 @@ def create_content(self, notification): Box( orientation="v", children=[ - Button( - name="notification-close-button", - image=CustomImage(icon_name="close-symbolic", icon_size=18), - visible=True, # Initially hidden - on_clicked=lambda *_: self._manual_close(), - ), + self.close_button, Box(v_expand=True), ], - ), + ) + if self.show_close_button + else Box(h_expand=True), ], ) def _on_enter_notify(self, widget, event): + if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: + return False + self._is_hovered = True - if self.close_button: - self.close_button.set_visible(True) self.pause_timeout() return False def _on_leave_notify(self, widget, event): + if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: + return False + self._is_hovered = False - if self.close_button: - self.close_button.set_visible(False) self.resume_timeout() return False @@ -734,7 +638,6 @@ def get_pixbuf(self, icon_path, width, height): icon_path = icon_path[7:] if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") return get_fallback_notification_icon((width, height)) try: @@ -748,39 +651,84 @@ def get_pixbuf(self, icon_path, width, height): return get_fallback_notification_icon((width, height)) def _get_notification_pixbuf(self, notification): - """Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat""" - notification_id = getattr(notification, "id", int(time.time())) + """Notification pixbuf — loads notification image or app icon, scaled consistently.""" + pixbuf = None - try: - # Try to get cached notification image first - if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + # 1. Try cache_metadata keys first (cheap — no disk/pixbuf load) + cache_meta = getattr(notification, "cache_metadata", {}) or {} + notif_key = cache_meta.get("notification_image_cache_key") + if notif_key: + try: + pixbuf = get_cached_notification_image(notif_key) + except Exception as e: + logger.warning( + f"[notification] pixbuf = get_cached_notification_image(notif_key) failed: {e}" + ) + + # 2. Try notification image via image_pixbuf (only if no cached) + if not pixbuf: + try: + img = getattr(notification, "image_pixbuf", None) + if img is not None: + nid = getattr(notification, "id", 0) + ck = get_notification_image_cache_key(nid, img) + cached = get_cached_notification_image(ck) + if cached: + pixbuf = cached + else: + pixbuf = img + except Exception as e: + logger.warning( + f"[notification] img = getattr(notification, 'image_pixbuf', None) failed: {e}" + ) + + # 3. App icon via cache_metadata + if not pixbuf: + app_key = cache_meta.get("app_icon_cache_key") + if app_key: try: - cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf) - cached_image = get_cached_notification_image(cache_key) - if cached_image: - return cached_image - except Exception as image_error: - logger.debug(f"Notification image processing failed: {image_error}") - # Continue to app icon fallback + from window.notification.unified_cache import get_from_cache + + pixbuf = get_from_cache(app_key, (64, 64)) + except Exception as e: + logger.warning( + f"[notification] from window.notification.unified_cache import get_from_cache failed: {e}" + ) + + # 4. Direct app icon caching + if not pixbuf: + app_icon = getattr(notification, "app_icon", None) or getattr( + notification, "app_name", None + ) + if app_icon: + try: + pixbuf = cache_notification_icon(app_icon, (64, 64)) + except Exception as e: + logger.warning( + f"[notification] pixbuf = cache_notification_icon(app_icon, (64, 64)) failed: {e}" + ) - except Exception as e: - logger.debug(f"Failed to process notification image: {e}") + # 5. Fallback + if not pixbuf: + pixbuf = get_fallback_notification_icon((64, 64)) - # Use cached app icon as fallback try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - return cached_app_icon + return pixbuf.scale_simple( + NOTIFICATION_IMAGE_SIZE, + NOTIFICATION_IMAGE_SIZE, + GdkPixbuf.InterpType.BILINEAR, + ) except Exception as e: - logger.debug(f"Failed to get cached app icon: {e}") - - # Ultimate fallback - return get_fallback_notification_icon((35, 35)) + logger.warning( + f"[notification] return pixbuf.scale_simple( NOTIFICATION_IMAGE_SIZE, NOTI... failed: {e}" + ) + return pixbuf def create_action_buttons(self, notification): - return Box( + if not notification.actions: + return Box(name="notification-action-buttons", visible=False) + + action_box = Box( name="notification-action-buttons", spacing=4, h_expand=True, @@ -790,6 +738,35 @@ def create_action_buttons(self, notification): ], ) + revealer = Revealer( + transition_type="slide-up", + transition_duration=250, + child=action_box, + reveal_child=False, + ) + + # Wrap in EventBox to catch hover events specifically for the bottom area + event_box = EventBox( + name="notification-action-buttons-container", + events=["enter-notify-event", "leave-notify-event"], + child=revealer, + ) + # Give it a tiny minimum height so there's an invisible hit-zone to trigger the reveal + event_box.set_size_request(-1, 12) + + def _on_enter(*_): + revealer.set_reveal_child(True) + return False + + def _on_leave(*_): + revealer.set_reveal_child(False) + return False + + event_box.connect("enter-notify-event", _on_enter) + event_box.connect("leave-notify-event", _on_leave) + + return event_box + def start_timeout(self): self.stop_timeout() self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) @@ -825,9 +802,8 @@ def destroy(self): self, "notification_image_cache_key", None ), ) - logger.debug(f"Cleaned up caches for manually dismissed notification") else: - logger.debug(f"Preserved caches for timeout/auto-dismissed notification") + logger.debug("Preserved caches for timeout/auto-dismissed notification") super().destroy() # @staticmethod @@ -845,6 +821,20 @@ def unhover_button(self, button): # Don't resume timeout here since the notification itself might still be hovered self.set_pointer_cursor(button, "arrow") + @staticmethod + def _get_current_notification_timeout(): + """Get the current notification timeout from config manager.""" + try: + timeout_str = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + from shared.data import parse_timeout_string + + return parse_timeout_string(timeout_str) + except Exception as e: + logger.warning(f"Failed to get notification timeout from config: {e}") + return data.NOTIFICATION_TIMEOUT + class NotificationRevealer(SlideRevealer): def __init__( @@ -854,7 +844,7 @@ def __init__( parent_window=None, **kwargs, ): - self.notif_box = NotificationWidget(notification, show_close_button=False) + self.notif_box = NotificationWidget(notification, show_close_button=True) self.notification = notification self.on_transition_end = on_transition_end # Reference to NotificationCenter window for queue clearing @@ -879,6 +869,7 @@ def __init__( # Animation state self._animation_in_progress = False self._spring_timer_id = None + self._anim_timeout_id = None self._css_provider = None # Wrap notification in EventBox for swipe detection @@ -899,8 +890,8 @@ def __init__( smooth_revealer_animation(self) - # Connect our own handler that manages the slide animation - self.notification.connect("closed", self.on_resolved) + # Connect our own handler that manages the slide animation - track ID for cleanup + self._closed_handler_id = self.notification.connect("closed", self.on_resolved) self._animation_in_progress = True @@ -928,8 +919,6 @@ def _apply_transform(self, offset_x, opacity, scale): if style_context: # Use CSS provider for smooth transforms if not hasattr(self, "_css_provider") or not self._css_provider: - from gi.repository import Gtk - self._css_provider = Gtk.CssProvider() style_context.add_provider( self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION @@ -972,8 +961,8 @@ def animate_step(): self.notif_box._should_cleanup_cache = True try: self.notification.close("dismissed-by-user") - except: - pass + except Exception as e: + logger.error(f"An error occurred: {e}") return False return True @@ -1034,12 +1023,37 @@ def on_resolved( self.hide() # Consistent timing for smooth transitions timeout_duration = self.duration + 50 - GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True)) + self._anim_timeout_id = GLib.timeout_add( + timeout_duration, lambda: self._on_animation_complete(True) + ) def destroy(self): # Clean up CSS provider and timers if self._spring_timer_id: GLib.source_remove(self._spring_timer_id) + self._spring_timer_id = None + if self._anim_timeout_id: + GLib.source_remove(self._anim_timeout_id) + self._anim_timeout_id = None + + # Disconnect notification signal + if hasattr(self, "_closed_handler_id") and self._closed_handler_id: + try: + self.notification.disconnect(self._closed_handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + self._closed_handler_id = 0 + + # Clean up CSS provider from style context + if self._css_provider: + try: + style_context = self.notif_box.get_style_context() + if style_context: + style_context.remove_provider(self._css_provider) + except Exception as e: + logger.error(f"An error occurred: {e}") + self._css_provider = None + super().destroy() @@ -1052,6 +1066,17 @@ class NotificationState: class ModusNoti(Window): def __init__(self): + # Local config state mirroring services.config usage + self._current_config = { + "notification_timeout": data.NOTIFICATION_TIMEOUT_STR, + "notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY, + "notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY, + } + + # Subscribe to config changes and apply initial config + on_config_change(self._on_config_changed) + self._apply_initial_config() + self._server = notification_service self.notifications = Box( @@ -1076,7 +1101,8 @@ def __init__(self): self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications self._server.connect("notification-added", self.on_new_notification) - super().__init__( + Window.__init__( + self, anchor="top right", child=self.notifications, layer="overlay", @@ -1088,7 +1114,7 @@ def __init__(self): def on_new_notification(self, fabric_notif, id): notification: Notification = fabric_notif.get_notification_from_id(id) - + # Check if notification still exists (might have been removed already) if not notification: return @@ -1097,12 +1123,13 @@ def on_new_notification(self, fabric_notif, id): # Notification is already cached by the service, just don't show popup return + # Check if notification should be ignored based on current config + if self._should_ignore_notification(notification): + return + # Preload assets immediately for optimal caching and display performance preload_notification_assets(notification) - # Implement smart queue management for smooth transitions - current_time = GLib.get_monotonic_time() / 1000 - # If queue is getting full, remove oldest notifications smoothly if len(self.notification_queue) >= self.MAX_QUEUE_SIZE: # Remove oldest notification from queue (not current showing one) @@ -1110,8 +1137,8 @@ def on_new_notification(self, fabric_notif, id): oldest = self.notification_queue.pop(0) try: oldest.close("dismissed-by-user") - except: - pass + except Exception as e: + logger.error(f"An error occurred: {e}") # Add new notification to queue self.notification_queue.append(notification) @@ -1161,8 +1188,8 @@ def _start_smooth_transition(self): # Force close current notification with smooth animation try: self.current_notification.notification.close("expired") - except: - pass + except Exception as e: + logger.error(f"An error occurred: {e}") def _show_next_notification(self): if ( @@ -1172,14 +1199,14 @@ def _show_next_notification(self): return notification = self.notification_queue.pop(0) - + # Check if notification is still valid (might have been removed) - if not notification or not hasattr(notification, 'app_icon'): + if not notification or not hasattr(notification, "app_icon"): # Skip invalid notifications and try next one if self.notification_queue: self._show_next_notification() return - + self.notification_state = NotificationState.SHOWING new_box = NotificationRevealer( @@ -1190,12 +1217,13 @@ def _show_next_notification(self): self.current_notification = new_box - # Clear any existing children + # Clear and destroy any existing children for child in list(self.notifications.children): try: self.notifications.remove(child) - except: - pass + child.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") self.notifications.children = [new_box] @@ -1222,12 +1250,13 @@ def _on_notification_finished(self, notification_box): GLib.source_remove(self._transition_timer_id) self._transition_timer_id = None - # Safely remove notification box + # Safely remove and destroy notification box try: if notification_box in self.notifications.children: self.notifications.remove(notification_box) - except: - pass + notification_box.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") # Reset state self.current_notification = None @@ -1259,14 +1288,17 @@ def clear_notification_queue(self): for notification in list(self.notification_queue): try: notification.close("dismissed-by-user") - except: - pass + except Exception as e: + logger.error(f"An error occurred: {e}") self.notification_queue.clear() # Also clean current notification if showing if self.current_notification: - # Mark for cache cleanup when clearing queue self.current_notification.notif_box._should_cleanup_cache = True + try: + self.current_notification.notification.close("dismissed-by-user") + except Exception as e: + logger.error(f"An error occurred: {e}") # Clear animation timers if self._transition_timer_id: @@ -1279,3 +1311,62 @@ def clear_notification_queue(self): def get_queue_length(self): return len(self.notification_queue) + + def _should_ignore_notification(self, notification): + """Check if notification should be ignored based on current config.""" + try: + ignored_apps = self._current_config.get("notification_ignored_apps", []) + app_name = getattr(notification, "app_name", "") + + # Check if app name is in ignored list + return app_name in ignored_apps + except Exception as e: + logger.warning(f"Failed to check if notification should be ignored: {e}") + return False + + def update_config(self, new_config): + """Update notification configuration dynamically.""" + try: + # Update the current config + self._current_config.update(new_config) + + except Exception as e: + logger.error(f"[ModusNoti] Failed to update config: {e}") + + def _apply_initial_config(self): + try: + timeout_val = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + ignored_apps = get_config( + "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY + ) + limited_apps = get_config( + "notification_limited_apps_history", + data.NOTIFICATION_LIMITED_APPS_HISTORY, + ) + initial = { + "notification_timeout": timeout_val, + "notification_ignored_apps": ignored_apps, + "notification_limited_apps_history": limited_apps, + } + # Only apply if different + self.update_config(initial) + except Exception as e: + logger.error(f"[ModusNoti] Failed to apply initial config: {e}") + + def _on_config_changed(self, new_config: dict, old_config: dict): + try: + changes = {} + for key in ( + "notification_timeout", + "notification_ignored_apps", + "notification_limited_apps_history", + ): + if key in new_config and new_config.get(key) != old_config.get(key): + changes[key] = new_config.get(key) + + if changes: + self.update_config(changes) + except Exception as e: + logger.error(f"[ModusNoti] Error handling config change: {e}") diff --git a/modules/notification/notification_center.py b/src/window/notification/notification_center.py similarity index 76% rename from modules/notification/notification_center.py rename to src/window/notification/notification_center.py index 3aa77b08..c73ea168 100644 --- a/modules/notification/notification_center.py +++ b/src/window/notification/notification_center.py @@ -1,32 +1,35 @@ from collections import defaultdict -import time +from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GLib, GdkPixbuf -from loguru import logger -from modules.notification.notification import ( +import shared.data as data +from services.modus import notification_service +from shared.widgets.clipping_box import ClippingBox +from shared.widgets.custom_image import CustomImage +from shared.window.applet_window import AppletWindow +from utils.functions import clear_children, escape_markup_text +from window.notification.notification import ( NotificationWidget, cache_notification_icon, - cache_notification_image, - get_cached_notification_image, - get_notification_image_cache_key, - preload_notification_assets, cleanup_all_notification_caches, cleanup_notification_specific_caches, - get_fallback_notification_icon, + get_cached_notification_image, + preload_notification_assets, +) +from window.notification.unified_cache import ( + get_fallback_icon as get_fallback_notification_icon, +) +from window.notification.unified_cache import ( + get_from_cache, ) -from services.modus import notification_service -from utils.functions import escape_markup_text -from widgets.custom_image import CustomImage -from widgets.wayland import WaylandWindow as Window -from config import data class ExpandableNotificationGroup(Box): @@ -95,9 +98,9 @@ def create_collapsed_state(self): name="stack-main-notification", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( + children=Image( pixbuf=self._get_notification_pixbuf_for_group( latest_notification ) @@ -115,20 +118,33 @@ def create_collapsed_state(self): children=[ Label( name="notification-summary", - markup=f"{self.app_name}", + markup=escape_markup_text( + latest_notification._notification.summary.replace( + "\n", " " + ) + ), h_align="start", + max_chars_width=25, ellipsization="end", ), ], ), - Label( - name="notification-body", - markup=escape_markup_text(latest_notification._notification.summary.replace( - "\n", " " - )), - max_chars_width=25, - h_align="start", - ellipsization="end", + ( + Label( + markup=escape_markup_text( + latest_notification._notification.body.replace( + "\n", " " + ) + ), + max_chars_width=25, + h_align="start", + ellipsization="end", + ) + if latest_notification._notification.body + else Label( + markup="", + h_align="start", + ) ), ], ), @@ -142,8 +158,10 @@ def create_collapsed_state(self): icon_name="close-symbolic", icon_size=18 ), visible=True, - on_clicked=lambda *_: self._close_single_notification_and_stop_propagation( - latest_notification + on_clicked=lambda *_: ( + self._close_single_notification_and_stop_propagation( + latest_notification + ) ), ), Label( @@ -160,13 +178,10 @@ def create_collapsed_state(self): self.add(self.collapsed_eventbox) - # Create expanded state (hidden initially) - self.create_expanded_state() - def _get_notification_pixbuf_for_group(self, cached_notification): - """Get notification pixbuf using cached image key - fallback to app icon""" + """Get notification pixbuf at 64x64 - matches app icon size for consistency""" notification = cached_notification._notification - notification_id = getattr(notification, "id", None) + pixbuf = None # First try to get cached notification image using stored cache key if ( @@ -182,15 +197,12 @@ def _get_notification_pixbuf_for_group(self, cached_notification): notification_image_cache_key ) if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) - return cached_image + pixbuf = cached_image except Exception as e: logger.debug(f"Failed to load cached notification image: {e}") # Fallback to app icon using cached key - if ( + if not pixbuf and ( hasattr(cached_notification, "cache_metadata") and cached_notification.cache_metadata ): @@ -199,31 +211,34 @@ def _get_notification_pixbuf_for_group(self, cached_notification): ) if app_icon_cache_key: try: - from modules.notification.unified_cache import get_from_cache - - cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) + cached_app_icon = get_from_cache(app_icon_cache_key, (64, 64)) if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") - return cached_app_icon + pixbuf = cached_app_icon except Exception as e: logger.debug(f"Failed to load cached app icon: {e}") # Final fallback - try to cache app icon directly if available + if not pixbuf: + try: + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + cached_app_icon = cache_notification_icon(app_icon_source, (64, 64)) + if cached_app_icon: + pixbuf = cached_app_icon + except Exception as e: + logger.debug(f"Failed to get directly cached app icon: {e}") + + if not pixbuf: + pixbuf = get_fallback_notification_icon((64, 64)) + + # Always scale to 64x64 so screenshot thumbnails match app icon size try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) - return cached_app_icon + return pixbuf.scale_simple(64, 64, GdkPixbuf.InterpType.BILINEAR) except Exception as e: - logger.debug(f"Failed to get directly cached app icon: {e}") - - # Ultimate fallback - logger.debug("Using fallback notification icon") - return get_fallback_notification_icon((35, 35)) + logger.warning( + f"[notification_center] return pixbuf.scale_simple(64, 64, GdkPixbuf.InterpType.B... failed: {e}" + ) + return pixbuf def create_expanded_state(self): # Create main expanded container @@ -249,19 +264,27 @@ def create_expanded_state(self): label="Show less", on_clicked=self.collapse, h_align="end", + v_align="center", + ).build( + lambda btn: btn.get_style_context().add_class("mac-header-pill") ), Button( name="notification-close-summery", h_expand=False, v_expand=False, + h_align="end", + v_align="center", on_clicked=self.close_all, - image=CustomImage( + child=CustomImage( icon_name="close-symbolic", name="notification-close-header", - icon_size=18, - h_align="end", + icon_size=12, + h_align="center", + v_align="center", ), visible=True, + ).build( + lambda btn: btn.get_style_context().add_class("mac-header-circle") ), ], ) @@ -333,7 +356,6 @@ def expand(self, *args): # Small delay then animate notifications sliding down GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True)) - logger.debug(f"Expanded notification group: {self.app_name}") def collapse(self, *args): """Collapse with header sliding up, notifications crossfading, then sliding up""" @@ -351,18 +373,12 @@ def collapse(self, *args): 260, lambda: self.notifications_revealer.set_reveal_child(False) ) - logger.debug(f"Collapsed notification group: {self.app_name}") - def _show_collapsed_midway(self): """Show collapsed state and hide expanded container to prevent deformation""" self.collapsed_eventbox.set_visible(True) self.expanded_container.set_visible(False) return False # Don't repeat timeout - def _complete_collapse(self): - """Complete the collapse animation - no longer needed but kept for compatibility""" - return False # Don't repeat timeout - def close_all(self, *args): """Close all notifications in this group with proper cache cleanup""" # Close all notifications in this group @@ -372,10 +388,6 @@ def close_all(self, *args): cache_metadata = getattr(notification, "cache_metadata", {}) # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - cleanup_notification_specific_caches( app_icon_source=notification._notification.app_icon, notification_image_cache_key=cache_metadata.get( @@ -383,10 +395,6 @@ def close_all(self, *args): ), ) - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - notification_service.remove_cached_notification(notification.cache_id) except Exception as e: logger.error( @@ -400,10 +408,6 @@ def _close_single_notification(self, notification): cache_metadata = getattr(notification, "cache_metadata", {}) # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - cleanup_notification_specific_caches( app_icon_source=notification._notification.app_icon, notification_image_cache_key=cache_metadata.get( @@ -411,12 +415,7 @@ def _close_single_notification(self, notification): ), ) - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - notification_service.remove_cached_notification(notification.cache_id) - logger.debug(f"Closed single notification: {notification.cache_id}") except Exception as e: logger.error( f"Error removing single notification {notification.cache_id}: {e}" @@ -460,10 +459,13 @@ def __init__(self, notification, **kwargs): name="notification-centre-notifs", **kwargs, ) + # Clear the hardcoded NOTIFICATION_WIDTH=360 size so the widget + # fills the notification center container width instead of overflowing + self.set_size_request(-1, -1) def _get_notification_pixbuf(self, notification): - """Get notification pixbuf using cached image key - fallback to app icon""" - notification_id = getattr(notification, "id", None) + """Get notification pixbuf at 64x64 - matches app icon size for consistency""" + pixbuf = None # First try to get cached notification image using stored cache key if self.cache_metadata: @@ -476,77 +478,74 @@ def _get_notification_pixbuf(self, notification): notification_image_cache_key ) if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) - return cached_image + pixbuf = cached_image except Exception as e: logger.debug(f"Failed to load cached notification image: {e}") # Fallback to app icon using cached key - if self.cache_metadata: + if not pixbuf and self.cache_metadata: app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key") if app_icon_cache_key: try: - from modules.notification.unified_cache import get_from_cache - - cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) + cached_app_icon = get_from_cache(app_icon_cache_key, (64, 64)) if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") - return cached_app_icon + pixbuf = cached_app_icon except Exception as e: logger.debug(f"Failed to load cached app icon: {e}") # Final fallback - try to cache app icon directly if available + if not pixbuf: + try: + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + cached_app_icon = cache_notification_icon(app_icon_source, (64, 64)) + if cached_app_icon: + pixbuf = cached_app_icon + except Exception as e: + logger.debug(f"Failed to get directly cached app icon: {e}") + + if not pixbuf: + pixbuf = get_fallback_notification_icon((64, 64)) + + # Always scale to 64x64 so screenshot thumbnails match app icon size try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) - return cached_app_icon + return pixbuf.scale_simple(64, 64, GdkPixbuf.InterpType.BILINEAR) except Exception as e: - logger.debug(f"Failed to get directly cached app icon: {e}") - - # Ultimate fallback - logger.debug("Using fallback notification icon") - return get_fallback_notification_icon((35, 35)) + logger.warning( + f"[notification_center] return pixbuf.scale_simple(64, 64, GdkPixbuf.InterpType.B... failed: {e}" + ) + return pixbuf def create_content(self, notification): # Create our custom close button for notification center self.close_button = Button( - name="notif-close-button", + name="notification-close-button", image=CustomImage( icon_name="close-symbolic", name="notification-close", icon_size=18 ), - visible=True, # Always visible in notification center on_clicked=self._on_close_clicked, ) - self.close_button.connect( - "enter-notify-event", lambda *_: self.hover_button(self.close_button) - ) - self.close_button.connect( - "leave-notify-event", lambda *_: self.unhover_button(self.close_button) - ) + self.close_button.get_style_context().add_class("mac-close-button") + + from utils.gtk_utils import setup_cursor_hover + + setup_cursor_hover(self.close_button) # Create the content box manually with our custom close button return Box( name="notification-content", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), + children=Image(pixbuf=self._get_notification_pixbuf(notification)), ), Box( name="notification-text", orientation="v", v_align="center", + h_expand=True, children=[ Box( name="notification-summary-box", @@ -554,25 +553,28 @@ def create_content(self, notification): children=[ Label( name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), h_align="start", - max_chars_width=25, + max_chars_width=40, ellipsization="end", ), ], ), ( Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), h_align="start", - max_chars_width=35, + max_chars_width=45, ellipsization="end", ) if notification.body else Label( markup="", h_align="start", - ellipsization="end", ) ), ], @@ -581,16 +583,65 @@ def create_content(self, notification): Box( orientation="v", children=[ - self.close_button, # Use our custom close button + self.close_button, Box(v_expand=True), ], ), ], ) - # Override to disable the action buttons def create_action_buttons(self, notification): - return Box(name="notification-action-buttons") + # Create an options button to hold the context menu + options_button = Button( + name="notification-options-button", + label="Options ⌄", + h_align="end", + v_align="center", + ) + options_button.get_style_context().add_class("mac-options-button") + + # Build context menu + menu = Gtk.Menu() + menu.get_style_context().add_class("mac-context-menu") + + if notification.actions: + for action in notification.actions: + item = Gtk.MenuItem(label=action.label) + + # Connect action invocation + def on_activate(_widget, a=action): + self._should_cleanup_cache = True + a.invoke() + a.parent.close("dismissed-by-user") + + item.connect("activate", on_activate) + menu.append(item) + + menu.append(Gtk.SeparatorMenuItem()) + + close_item = Gtk.MenuItem(label="Close") + close_item.connect("activate", self._on_close_clicked) + menu.append(close_item) + + menu.show_all() + + def show_menu(btn): + # Show the menu under the button + menu.popup_at_widget( + btn, Gdk.Gravity.SOUTH_EAST, Gdk.Gravity.NORTH_EAST, None + ) + + options_button.connect("clicked", show_menu) + + # Add to a box that takes up the bottom space + action_box = Box( + h_expand=True, + children=[ + Box(h_expand=True), # Push to right + options_button, + ], + ) + return action_box def _on_close_clicked(self, *args): try: @@ -598,10 +649,6 @@ def _on_close_clicked(self, *args): cache_metadata = self.cache_metadata # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - cleanup_notification_specific_caches( app_icon_source=self.notification.app_icon, notification_image_cache_key=cache_metadata.get( @@ -609,10 +656,6 @@ def _on_close_clicked(self, *args): ), ) - logger.debug( - f"Cleaned up caches for notification center ID: {self.notification.id}" - ) - notification_service.remove_cached_notification(self.notification_id) except Exception as e: logger.error(f"Error removing notification {self.notification_id}: {e}") @@ -630,14 +673,15 @@ def close_notification(self): return False -class NotificationCenter(Window): - def __init__(self): +class NotificationCenter(AppletWindow): + def __init__(self, parent=None, pointing_to=None): super().__init__( + parent=parent, + pointing_to=pointing_to, layer="overlay", anchor="top right", visible=False, - keyboard_mode="on-demand", - title="modus", + title="modus-notificationcenter", ) NOTIFICATION_CENTER_WIDTH = 410 @@ -662,7 +706,12 @@ def __init__(self): name="noti-center-box", ) - self.scrolled = ScrolledWindow(h_expand=False, v_expand=False) + self.scrolled = ScrolledWindow( + h_expand=False, + v_expand=False, + h_scrollbar_policy="never", + propagate_width=False, + ) self.notifications_box = Box( v_expand=False, h_expand=False, @@ -700,7 +749,8 @@ def __init__(self): self._rebuild_notification_groups() self.add_keybinding("Escape", self._on_escape_pressed) - self.connect("destroy", self._on_destroy) + + MAX_GROUPS = 15 def _rebuild_notification_groups(self): """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" @@ -709,33 +759,30 @@ def _rebuild_notification_groups(self): self.group_widgets.clear() # Clear notifications box - for child in self.notifications_box.get_children(): - child.destroy() + clear_children(self.notifications_box) # Group notifications by app name and preload assets with debugging - rebuild_count = 0 for cached_notification in notification_service.cached_notifications: app_name = cached_notification._notification.app_name - notification_id = getattr(cached_notification._notification, "id", None) # Skip ignored apps during rebuild if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: continue - # Preload assets for each cached notification to ensure display consistency - preload_notification_assets(cached_notification._notification) - self.notification_groups[app_name].append(cached_notification) - rebuild_count += 1 - logger.info( - f"Rebuilt {rebuild_count} notifications into { - len(self.notification_groups) - } groups" - ) + # Sort groups by most recent notification + sorted_groups = sorted( + self.notification_groups.items(), + key=lambda item: max( + (getattr(n._notification, "id", 0) for n in item[1]), default=0 + ), + reverse=True, + )[: self.MAX_GROUPS] + self.notification_groups.clear() # Create group widgets and handle limited apps - for app_name, notifications in self.notification_groups.items(): + for app_name, notifications in sorted_groups: # Sort notifications by ID (highest ID first - newest notifications) # This ensures the latest notifications appear at the top of each group notifications.sort( @@ -746,11 +793,10 @@ def _rebuild_notification_groups(self): if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY: if len(notifications) > 5: # Keep only the 5 most recent notifications - self.notification_groups[app_name] = notifications[:5] + notifications = notifications[:5] - group_widget = ExpandableNotificationGroup( - app_name, self.notification_groups[app_name] - ) + group_widget = ExpandableNotificationGroup(app_name, notifications) + self.notification_groups[app_name] = notifications self.group_widgets[app_name] = group_widget self.notifications_box.add(group_widget) @@ -813,19 +859,19 @@ def on_notification_added(self, service, cached_notification): self.notifications_box.pack_start(group_widget, False, False, 0) group_widget.show_all() - logger.debug(f"Added notification to group {app_name}") except Exception as e: logger.error(f"Error adding notification to group: {e}") def _refresh_group_widget(self, group_widget): """Refresh a group widget's content""" try: - # Remove existing children - for child in group_widget.get_children(): - group_widget.remove(child) + clear_children(group_widget) - # Recreate content + # Recreate content (collapsed + expanded states) group_widget.create_collapsed_state() + group_widget.create_expanded_state() + group_widget.collapsed_eventbox.set_visible(True) + group_widget.expanded_container.set_visible(False) group_widget.show_all() except Exception as e: @@ -864,7 +910,6 @@ def on_notification_removed(self, service, cached_notification): ), ) - logger.debug(f"Removed notification from group {app_name}") except Exception as e: logger.error(f"Error removing notification from group: {e}") @@ -876,9 +921,7 @@ def on_clear_all(self, service): # Clear all remaining cached notification images AND icons cleanup_all_notification_caches() - for child in self.notifications_box.get_children(): - child.destroy() - logger.debug("Cleared all notification groups and remaining cached images") + clear_children(self.notifications_box) except Exception as e: logger.error(f"Error clearing notification groups: {e}") @@ -889,8 +932,8 @@ def on_count_changed(self, service, count=None): self.scrolled.set_visible(current_count > 0) # Auto-close notification center when no notifications remain - if current_count == 0 and hasattr(self, "mousecapture"): - self.mousecapture.hide_child_window() + if current_count == 0: + self.hide() def clear_all_notifications(self, *_): # Clear all groups @@ -899,25 +942,17 @@ def clear_all_notifications(self, *_): # Clear all remaining cached notification images AND icons when clear all is clicked cleanup_all_notification_caches() # Clear ALL caches (icons + images) + clear_children(self.notifications_box) notification_service.clear_all_cached_notifications() - if hasattr(self, "mousecapture"): - self.mousecapture.hide_child_window() + self.hide() def _on_escape_pressed(self, *_): - if hasattr(self, "mousecapture"): - self.mousecapture.hide_child_window() + self.hide() - def _init_mousecapture(self, mousecapture): - self.mousecapture = mousecapture - - def _set_mousecapture(self, visible): + def set_visible(self, visible: bool): """Control notification center visibility with slide-left animation""" + super().set_visible(visible) if visible: self.main_revealer.set_reveal_child(True) else: self.main_revealer.set_reveal_child(False) - logger.debug(f"Notification center visibility set to: {visible}") - - def _on_destroy(self, *_): - # Signals will be automatically disconnected when the object is destroyed - pass diff --git a/modules/notification/unified_cache.py b/src/window/notification/unified_cache.py similarity index 55% rename from modules/notification/unified_cache.py rename to src/window/notification/unified_cache.py index 0bd673b3..429c4416 100644 --- a/modules/notification/unified_cache.py +++ b/src/window/notification/unified_cache.py @@ -1,17 +1,44 @@ -import os import hashlib import time import uuid +from collections import OrderedDict -from fabric.utils import get_relative_path -from gi.repository import GdkPixbuf -from loguru import logger +from fabric.utils import GdkPixbuf, Gtk, get_relative_path, logger, os -import config.data as data +import shared.data as data # Unified notification cache directory (for both app icons and notification images) UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") +_MEMORY_PIXBUF_CACHE: OrderedDict[str, GdkPixbuf.Pixbuf] = OrderedDict() +_MEMORY_CACHE_MAX = 64 + + +def _get_icon_theme_name(): + """Get the current GTK icon theme name for cache invalidation""" + try: + settings = Gtk.Settings.get_default() + if settings: + return getattr(settings, "gtk_icon_theme_name", "") or "" + except Exception as e: + logger.warning( + f"[unified_cache] settings = Gtk.Settings.get_default() failed: {e}" + ) + # Fallback: read from settings.ini + try: + settings_path = os.path.expanduser("~/.config/gtk-3.0/settings.ini") + if os.path.exists(settings_path): + with open(settings_path) as f: + for line in f: + line = line.strip() + if line.startswith("gtk-icon-theme-name="): + return line.split("=", 1)[1].strip() + except Exception as e: + logger.warning( + f"[unified_cache] settings_path = os.path.expanduser('~/.config/gtk-3.0/set... failed: {e}" + ) + return "" + def ensure_cache_dir(): """Ensure unified notification cache directory exists""" @@ -27,25 +54,35 @@ def get_unified_cache_key(source_data, size=None, app_name=None): pixel_data = source_data.get_pixels() image_hash = hashlib.md5(pixel_data).hexdigest()[:8] return image_hash - except Exception: + except Exception as e: # Fallback to random UUID if pixel data fails + logger.warning( + f"[unified_cache] pixel_data = source_data.get_pixels() failed: {e}" + ) return str(uuid.uuid4())[:8] elif isinstance(source_data, str): # For file paths - create hash-based name if source_data.startswith("file://"): source_data = source_data[7:] - # Create hash from file path and size + # Create hash from file path, size, and icon theme hash_input = source_data if size: hash_input += f"_{size[0]}x{size[1]}" + # Include icon theme so cache invalidates on theme change + theme = _get_icon_theme_name() + if theme: + hash_input += f"|{theme}" return hashlib.md5(hash_input.encode()).hexdigest()[:8] else: # Fallback to random UUID return str(uuid.uuid4())[:8] - except Exception: + except Exception as e: # Ultimate fallback + logger.warning( + f"[unified_cache] if hasattr(source_data, 'get_pixels'): # For pixbuf data ... failed: {e}" + ) return str(uuid.uuid4())[:8] @@ -67,7 +104,6 @@ def save_to_cache(pixbuf, cache_key, size=None): ) pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification asset: {cache_key}") return cache_path, cache_key except Exception as e: logger.warning(f"Failed to cache notification asset: {e}") @@ -76,16 +112,23 @@ def save_to_cache(pixbuf, cache_key, size=None): def get_from_cache(cache_key, size=None): """Get a cached asset or return None if not found""" + mem_key = f"{cache_key}:{size}" + if mem_key in _MEMORY_PIXBUF_CACHE: + _MEMORY_PIXBUF_CACHE.move_to_end(mem_key) + return _MEMORY_PIXBUF_CACHE[mem_key] try: cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png") if os.path.exists(cache_path): - # logger.debug(f"Using cached asset: {cache_key}") if size: - return GdkPixbuf.Pixbuf.new_from_file_at_scale( + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( cache_path, size[0], size[1], True ) else: - return GdkPixbuf.Pixbuf.new_from_file(cache_path) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + while len(_MEMORY_PIXBUF_CACHE) >= _MEMORY_CACHE_MAX: + _MEMORY_PIXBUF_CACHE.popitem(last=False) + _MEMORY_PIXBUF_CACHE[mem_key] = pixbuf + return pixbuf except Exception as e: logger.warning(f"Failed to load cached asset: {e}") return None @@ -103,7 +146,9 @@ def cleanup_cache(cache_key=None): ) if os.path.exists(cache_path): os.unlink(cache_path) - logger.debug(f"Cleaned up cached asset: {cache_key}") + mem_keys = [k for k in _MEMORY_PIXBUF_CACHE if k.startswith(cache_key)] + for k in mem_keys: + _MEMORY_PIXBUF_CACHE.pop(k, None) else: # Remove all cached assets for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): @@ -111,9 +156,9 @@ def cleanup_cache(cache_key=None): filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) try: os.unlink(filepath) - logger.debug(f"Cleaned up cached asset: {filename}") except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _MEMORY_PIXBUF_CACHE.clear() except Exception as e: logger.warning(f"Failed to cleanup cache: {e}") @@ -134,7 +179,6 @@ def cleanup_old_cache_files(): file_mtime = os.path.getmtime(filepath) if file_mtime < week_ago: os.unlink(filepath) - logger.debug(f"Cleaned up old cache: {filename}") except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") except Exception as e: @@ -153,15 +197,11 @@ def verify_cache_persistence(): if f.endswith(".png") ] - logger.info(f"Cache persistence check: {len(cache_files)} assets cached") - # Test loading a few cached items to verify they work for cache_file in cache_files[:2]: # Test first 2 files try: cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached asset: {cache_file}") + GdkPixbuf.Pixbuf.new_from_file(cache_path) except Exception as e: logger.warning(f"Failed to load cached asset {cache_file}: {e}") @@ -175,7 +215,7 @@ def verify_cache_persistence(): def get_fallback_icon(size=(48, 48)): """Get the fallback notification icon""" try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") + fallback_path = get_relative_path("../../assets/icons/notification.png") return GdkPixbuf.Pixbuf.new_from_file_at_scale( fallback_path, size[0], size[1], True ) @@ -186,12 +226,71 @@ def get_fallback_icon(size=(48, 48)): return GdkPixbuf.Pixbuf.new( GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] ) - except: + except Exception as e: + logger.warning( + f"[unified_cache] return GdkPixbuf.Pixbuf.new( GdkPixbuf.Colorspace.RGB, Tr... failed: {e}" + ) return None +# Theme tracking for cache invalidation +_CURRENT_THEME_FILE = os.path.join(data.CACHE_DIR, ".current_icon_theme") + + +def _get_stored_theme(): + """Get the previously stored icon theme name""" + try: + if os.path.exists(_CURRENT_THEME_FILE): + with open(_CURRENT_THEME_FILE) as f: + return f.read().strip() + except Exception as e: + logger.warning( + f"[unified_cache] if os.path.exists(_CURRENT_THEME_FILE): with open(_CURREN... failed: {e}" + ) + return "" + + +def _store_theme(name): + """Store the current icon theme name""" + try: + os.makedirs(os.path.dirname(_CURRENT_THEME_FILE), exist_ok=True) + with open(_CURRENT_THEME_FILE, "w") as f: + f.write(name) + except Exception as e: + logger.warning( + f"[unified_cache] os.makedirs(os.path.dirname(_CURRENT_THEME_FILE), exist_o... failed: {e}" + ) + + +def cleanup_theme_changed_caches(): + """Clear notification pixbuf caches if icon theme changed since last run""" + current_theme = _get_icon_theme_name() + stored_theme = _get_stored_theme() + + if current_theme and stored_theme and current_theme != stored_theme: + logger.info( + f"[Cache] Icon theme changed: {stored_theme} -> {current_theme}, clearing notification caches" + ) + cleanup_cache() # clear all pixbuf caches + # Also clear the icon resolver cache since icon names may map differently + try: + from utils.icon_resolver import ICON_CACHE_FILE + + if os.path.exists(ICON_CACHE_FILE): + os.unlink(ICON_CACHE_FILE) + logger.info("[Cache] Cleared icon resolver cache") + except Exception as e: + logger.warning( + f"[unified_cache] from utils.icon_resolver import ICON_CACHE_FILE failed: {e}" + ) + + # Store current theme for next run + if current_theme: + _store_theme(current_theme) + + # Initialize cache on module load ensure_cache_dir() cleanup_old_cache_files() +cleanup_theme_changed_caches() verify_cache_persistence() - diff --git a/src/window/osd/__init__.py b/src/window/osd/__init__.py new file mode 100644 index 00000000..9b1b70c4 --- /dev/null +++ b/src/window/osd/__init__.py @@ -0,0 +1,5 @@ +from .components import ( # noqa: F401 + AudioOSDContainer, + BrightnessOSDContainer, +) +from .main import OSDWindow # noqa: F401 diff --git a/src/window/osd/components/__init__.py b/src/window/osd/components/__init__.py new file mode 100644 index 00000000..7564a3cf --- /dev/null +++ b/src/window/osd/components/__init__.py @@ -0,0 +1,2 @@ +from .audio import AudioOSDContainer # noqa: F401 +from .brightness import BrightnessOSDContainer # noqa: F401 diff --git a/src/window/osd/components/animated_scale.py b/src/window/osd/components/animated_scale.py new file mode 100644 index 00000000..46b8868d --- /dev/null +++ b/src/window/osd/components/animated_scale.py @@ -0,0 +1,30 @@ +from functools import partial + +from fabric.widgets.scale import Scale + +from shared.widgets.animator import Animator, cubic_bezier + + +class AnimatedScale(Scale): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.animator = ( + Animator( + duration=0.8, + timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0), + min_value=self.min_value, + max_value=self.value, + tick_widget=self, + notify_value=lambda anim: self.set_value(anim.value), + ) + .build() + .play() + .unwrap() + ) + + def animate_value(self, value: float): + if self.animator: + self.animator.pause() + self.animator.min_value = self.value + self.animator.max_value = value + self.animator.play() diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py new file mode 100644 index 00000000..e658dfd9 --- /dev/null +++ b/src/window/osd/components/audio.py @@ -0,0 +1,117 @@ +import math + +from fabric.audio import Audio +from fabric.utils import logger +from fabric.widgets.scale import ScaleMark + +from utils.gtk_utils import svg_file + +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class AudioOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.audio = Audio() + self._last_volume = None + self._last_muted = None + self._speaker_handler_id = None + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "volume/audio-volume.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.audio.connect("notify::speaker", self._on_speaker_changed) + if self.audio.speaker: + self._connect_speaker_signals() + + def _connect_speaker_signals(self): + if self.audio.speaker: + if self._speaker_handler_id is not None: + self.audio.speaker.disconnect(self._speaker_handler_id) + self._speaker_handler_id = self.audio.speaker.connect( + "changed", self._on_speaker_stream_changed + ) + self._sync_with_audio() + + def _on_speaker_changed(self, *_): + self._connect_speaker_signals() + self.update() + + def _on_speaker_stream_changed(self, *_): + if self.audio.speaker: + current_volume = round(self.audio.speaker.volume) + current_muted = self.audio.speaker.muted + if self._last_volume != current_volume or self._last_muted != current_muted: + self._last_volume = current_volume + self._last_muted = current_muted + self.update() + + def _sync_with_audio(self): + if self.audio.speaker: + self._last_volume = round(self.audio.speaker.volume) + self._last_muted = self.audio.speaker.muted + self.scale.set_value(self._last_volume) + self._update_display() + + def _update_display(self): + if not self.audio.speaker: + return + + volume = ( + self._last_volume + if self._last_volume is not None + else round(self.audio.speaker.volume) + ) + muted = ( + self._last_muted + if self._last_muted is not None + else self.audio.speaker.muted + ) + + display_volume = 0 if (volume == 0 or muted) else volume + level = 0 if display_volume == 0 else min(math.ceil(display_volume / 33), 3) + + self.osd_window_image.set_from_file(f"volume/audio-volume-{level}.svg") + + if muted or volume == 0: + self.scale.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + + self.scale.animate_value(volume) + + def update(self, *_): + self._update_display() + super().update() + + def destroy(self): + """Disconnect signals from Audio service""" + try: + self.audio.disconnect_by_func(self._on_speaker_changed) + if self.audio.speaker and self._speaker_handler_id is not None: + self.audio.speaker.disconnect(self._speaker_handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + super().destroy() diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py new file mode 100644 index 00000000..aef08fe2 --- /dev/null +++ b/src/window/osd/components/base.py @@ -0,0 +1,150 @@ +from abc import abstractmethod + +from fabric.utils import Gdk, GLib, invoke_repeater, logger, remove_handler, time +from fabric.widgets.box import Box +from fabric.widgets.wayland import WaylandWindow as Window + + +class BaseOSDContainer(Box): + MAX_VISIBLE_TIME = 6.0 + COOLDOWN_MS = 200 + + def __init__(self, window: Window, **kwargs): + super().__init__(**kwargs, orientation="v", spacing=12, name="osd-container") + self.last_handler: int = 0 + self.window = window + self.osd = None + self.show_timestamp: float = 0.0 + self.watchdog_handler: int = 0 + self._update_in_progress = False + self._is_hovered = False + self._last_trigger_time: float = 0.0 + + GLib.idle_add(self._setup_window_signals) + + def _setup_window_signals(self): + self.window.add_events( + Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + self.window.connect("enter-notify-event", self._on_enter_notify) + self.window.connect("leave-notify-event", self._on_leave_notify) + return False + + def destroy(self): + self.cleanup_all_handlers() + super().destroy() + + def _on_enter_notify(self, widget, event): + if event.detail != Gdk.NotifyType.INFERIOR: + self._is_hovered = True + + def _on_leave_notify(self, widget, event): + if event.detail != Gdk.NotifyType.INFERIOR: + self._is_hovered = False + + def is_hovered(self): + return self._is_hovered + + def remove_last_handler(self): + if self.last_handler: + try: + remove_handler(self.last_handler) + except Exception as e: + logger.error(f"An error occurred: {e}") + self.last_handler = 0 + + def remove_watchdog_handler(self): + if self.watchdog_handler: + try: + GLib.source_remove(self.watchdog_handler) + except Exception as e: + logger.error(f"An error occurred: {e}") + self.watchdog_handler = 0 + + def cleanup_all_handlers(self): + self.remove_last_handler() + self.remove_watchdog_handler() + self.show_timestamp = 0.0 + self._update_in_progress = False + + def hide_window(self): + if self.osd and hasattr(self.osd, "revealer"): + self.osd.revealer.set_reveal_child(False) + GLib.timeout_add(150, lambda: self.window.hide() or False) + else: + self.window.hide() + + def watchdog_force_hide(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + elapsed = time.time() - self.show_timestamp + if elapsed >= self.MAX_VISIBLE_TIME: + if not self.is_hovered(): + self.hide_window() + self.cleanup_all_handlers() + return False + else: + return True + return True + + def update(self, *_): + if self._update_in_progress: + return + + current_time = time.time() + time_since_last_trigger = (current_time - self._last_trigger_time) * 1000 + + if time_since_last_trigger < self.COOLDOWN_MS: + return + + self._last_trigger_time = current_time + self._update_in_progress = True + + self.cleanup_all_handlers() + self.show_timestamp = time.time() + + if self.osd: + self.osd.show_container(self) + + self.window.show() + + self.focus() + self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False) + self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide) + self._update_in_progress = False + + def focus(self, *_): + self.add_style_class("focused") + return False + + def unfocus(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + self.remove_style_class("focused") + self.remove_last_handler() + self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False) + return False + + def unpop(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + if not self.is_hovered(): + self.hide_window() + self.cleanup_all_handlers() + else: + self.last_handler = invoke_repeater(500, self.unpop, initial_call=False) + return False + + @abstractmethod + def _setup_specific_components(self): + pass + + @abstractmethod + def _connect_specific_signals(self): + pass diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py new file mode 100644 index 00000000..204aa15a --- /dev/null +++ b/src/window/osd/components/brightness.py @@ -0,0 +1,73 @@ +import math + +from fabric.utils import logger +from fabric.widgets.scale import ScaleMark + +from services.brightness import Brightness +from utils.gtk_utils import svg_file + +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class BrightnessOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.brightness_service = Brightness() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "brightness/brightness.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(0, 101, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.brightness_service.connect("screen", self._on_screen_brightness_changed) + + def _on_screen_brightness_changed(self, _sender, value, *_args): + self.update() + + def _get_normalized_brightness(self): + return ( + self.brightness_service.screen_brightness + / self.brightness_service.max_screen + ) * 100 + + def _update_display(self): + normalized = self._get_normalized_brightness() + level = 0 if normalized == 0 else min(math.ceil(normalized / 33), 3) + + self.osd_window_image.set_from_file(f"brightness/brightness-{level}.svg") + + self.scale.animate_value(normalized) + + def update(self, *_): + self._update_display() + super().update() + + def destroy(self): + """Disconnect signals from Brightness service""" + try: + self.brightness_service.disconnect_by_func( + self._on_screen_brightness_changed + ) + except Exception as e: + logger.error(f"An error occurred: {e}") + super().destroy() diff --git a/src/window/osd/main.py b/src/window/osd/main.py new file mode 100644 index 00000000..6d3930de --- /dev/null +++ b/src/window/osd/main.py @@ -0,0 +1,77 @@ +from fabric.widgets.box import Box +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +from .components import ( + AudioOSDContainer, + BrightnessOSDContainer, +) + + +class OSD(Box): + def __init__(self, window: Window, **kwargs): + super().__init__(title="modus-osd", name="osd", **kwargs) + self.window = window + + self.revealer = Revealer( + transition_type="slide-up", + transition_duration=100, + child_revealed=False, + ) + self.children = [self.revealer] + + self.containers = { + "audio": AudioOSDContainer(window), + "brightness": BrightnessOSDContainer(window), + } + + # Set the OSD reference in containers + for container in self.containers.values(): + container.osd = self + + self.current_container = None + + def show_container(self, container): + if self.current_container != container: + self.revealer.children = [container] + self.current_container = container + self.revealer.set_reveal_child(True) + + def show_audio_osd(self): + from services.config import get_config + + if not get_config("osd", True): + return + self.show_container(self.containers.get("audio")) + + def show_brightness_osd(self): + from services.config import get_config + + if not get_config("osd", True): + return + self.show_container(self.containers.get("brightness")) + + +class OSDWindow(Window): + def __init__(self, **kwargs): + super().__init__( + name="osd-window", + title="modus-osd", + anchor="bottom", + margin="0px 0px 40px 0px", + visible=False, + all_visible=False, + pass_through=True, + layer="overlay", + **kwargs, + ) + self.osd = OSD(window=self) + self.add(self.osd) + + +if __name__ == "__main__": + from fabric import Application + + osd_window = OSDWindow() + app = Application("modus-osd", osd_window) + app.run() diff --git a/src/window/panel/__init__.py b/src/window/panel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/panel/components/__init__.py b/src/window/panel/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/panel/components/custom_mods.py b/src/window/panel/components/custom_mods.py new file mode 100644 index 00000000..4b9ad3d7 --- /dev/null +++ b/src/window/panel/components/custom_mods.py @@ -0,0 +1,255 @@ +from pathlib import Path + +import tomlkit +from fabric.utils import Gio, GLib, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label + +from shared.window.applet_window import AppletWindow +from utils.functions import clear_children, run_command, thread +from utils.gtk_utils import setup_cursor_hover, svg_file, toml_file + +MODS_CONFIG_PATH = Path(toml_file("mods.toml")) +RELOAD_DELAY_MS = 200 + + +def dropdown_divider(): + return Box( + children=[Box(name="dropdown-divider", h_expand=True)], + name="dropdown-divider-box", + h_align="fill", + h_expand=True, + ) + + +class ModPopup(AppletWindow): + def __init__( + self, mod_name, options, parent_window, pointing_to, source_button, **kwargs + ): + self._mod_name = mod_name + self._source_button = source_button + + option_widgets = [] + for opt in sorted(options, key=lambda o: o.get("order", 99)): + if opt.get("divider"): + option_widgets.append(dropdown_divider()) + + btn = Button( + name="dropdown-option", + child=Label(label=opt["label"], name="dropdown-option-label"), + h_align="fill", + h_expand=True, + ) + cmd = opt["on-clicked"] + btn.connect("clicked", lambda *_, c=cmd: self._run_command(c)) + setup_cursor_hover(btn, "pointer") + option_widgets.append(btn) + + options_box = Box( + name="dropdown-options", + orientation="vertical", + spacing=0, + children=option_widgets, + ) + + main_box = Box(name="dropdown-menu", children=[options_box]) + + super().__init__( + parent=parent_window, + pointing_to=pointing_to, + name="dropdown-menu", + title="modus-dropdown", + layer="overlay", + visible=False, + **kwargs, + ) + self.children = [main_box] + self.connect("notify::visible", self._on_visible_changed) + + def _on_visible_changed(self, *_): + if self.get_visible(): + self._source_button.add_style_class("active") + else: + self._source_button.remove_style_class("active") + + def _run_command(self, cmd): + self.close_applet() + thread(run_command, ["sh", "-c", cmd], timeout=30) + + def toggle(self, pointing_to=None): + if pointing_to: + self.set_pointing_to(pointing_to) + super().toggle() + + +class CustomMods(Box): + def __init__(self, parent_window, **kwargs): + self._parent = parent_window + self._monitors = [] + self._reload_pending = False + self._mods_data = {} + self._mod_buttons = {} + self._mod_popups = {} + super().__init__(name="custom-mods", orientation="h", spacing=0, **kwargs) + self._load_mods() + self._setup_monitor() + + def _load_mods(self): + try: + if not MODS_CONFIG_PATH.exists(): + return + with open(MODS_CONFIG_PATH) as f: + data = tomlkit.load(f) + except Exception as e: + logger.error(f"[CustomMods] Failed to parse {MODS_CONFIG_PATH}: {e}") + return + + mods = {} + for mod_name, val in data.get("Mods", {}).items(): + icon = val.get("icon", "") + icon_size = val.get("icon-size", 16) + order = val.get("order", 99) + on_clicked = val.get("on-clicked") + options = val.get("options", []) + mod_data = { + "name": mod_name, + "icon": icon, + "icon_size": icon_size, + "order": order, + "on-clicked": on_clicked, + "options": options, + } + for k in ("on-left", "on-middle", "on-right"): + v = val.get(k) + if v is not None: + mod_data[k] = v + mods[mod_name] = mod_data + + self._rebuild(mods) + + def _on_btn_press(self, btn, event, mod): + button_map = {1: "on-left", 2: "on-middle", 3: "on-right"} + button_key = button_map.get(event.button) + per_button_cmd = mod.get(button_key) if button_key else None + popup = self._mod_popups.get(mod["name"]) + + if per_button_cmd is not None: + if popup and popup._is_open: + popup.toggle() + thread(run_command, ["sh", "-c", per_button_cmd], timeout=30) + return True + + cmd = mod.get("on-clicked") + if cmd: + if popup and popup._is_open: + popup.toggle() + thread(run_command, ["sh", "-c", cmd], timeout=30) + return True + + if event.button == 3: + if popup: + popup.toggle(pointing_to=btn) + return True + + if event.button == 1: + if popup and popup._is_open: + popup.toggle() + return True + + if popup: + popup.toggle(pointing_to=btn) + return True + + if event.button == 2 and popup: + popup.toggle(pointing_to=btn) + + return True + + def _rebuild(self, mods): + clear_children(self) + + for popup in self._mod_popups.values(): + if popup: + popup.destroy() + self._mod_popups.clear() + self._mod_buttons.clear() + self._mods_data.clear() + + sorted_mods = sorted(mods.values(), key=lambda m: m.get("order", 99)) + + for mod in sorted_mods: + icon_widget = svg_file(mod["icon"], size=mod["icon_size"]) + btn = Button(name="global-menu", child=icon_widget) + setup_cursor_hover(btn, "pointer") + + popup = None + if mod["options"]: + popup = ModPopup( + mod_name=mod["name"], + options=mod["options"], + parent_window=self._parent, + pointing_to=btn, + source_button=btn, + ) + + btn.connect( + "button-press-event", + lambda b, e, m=mod: self._on_btn_press(b, e, m), + ) + + self.add(btn) + self._mod_buttons[mod["name"]] = btn + self._mod_popups[mod["name"]] = popup + self._mods_data[mod["name"]] = mod + + self.show_all() + + def _setup_monitor(self): + try: + for path in [MODS_CONFIG_PATH, MODS_CONFIG_PATH.parent]: + if not path.exists(): + continue + gio_file = Gio.File.new_for_path(str(path)) + monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None) + monitor.connect("changed", self._on_file_changed) + self._monitors.append(monitor) + except Exception as e: + logger.error(f"[CustomMods] Failed to setup monitor: {e}") + + def _on_file_changed(self, monitor, gfile, _other_file, event_type): + if event_type == Gio.FileMonitorEvent.DELETED and gfile.get_path() == str( + MODS_CONFIG_PATH + ): + self._schedule_reload() + return + valid_events = [ + Gio.FileMonitorEvent.CHANGES_DONE_HINT, + Gio.FileMonitorEvent.CHANGED, + Gio.FileMonitorEvent.CREATED, + ] + if event_type in valid_events and not self._reload_pending: + path = gfile.get_path() + if path and path != str(MODS_CONFIG_PATH): + return + self._schedule_reload() + + def _schedule_reload(self): + self._reload_pending = True + GLib.timeout_add(RELOAD_DELAY_MS, self._reload_config) + + def _reload_config(self): + self._reload_pending = False + self._load_mods() + return False + + def destroy(self): + for monitor in self._monitors: + monitor.cancel() + for popup in self._mod_popups.values(): + if popup: + popup.destroy() + self._mod_popups.clear() + self._mod_buttons.clear() + self._mods_data.clear() + super().destroy() diff --git a/src/window/panel/components/enhanced_system_tray.py b/src/window/panel/components/enhanced_system_tray.py new file mode 100644 index 00000000..c5f3a3e4 --- /dev/null +++ b/src/window/panel/components/enhanced_system_tray.py @@ -0,0 +1,106 @@ +import weakref +from pathlib import Path + +from fabric.system_tray.widgets import SystemTrayItem +from fabric.utils import GdkPixbuf, GLib, logger + +from services.config import get_config, on_config_change + +# Track active tray items for hot-reloading +_tracked_items = weakref.WeakSet() + + +def should_hide(item) -> bool: + """Check if a tray item should be hidden based on config.""" + ignore_list = get_config("systray_ignore", []) + if not ignore_list: + return False + + title = item.title if item.title else "" + identifier = item.identifier if item.identifier else "" + + # Check both title and identifier (case-insensitive for convenience) + for pattern in ignore_list: + p = pattern.lower() + if p in title.lower() or p in identifier.lower(): + return True + return False + + +def patched_do_update_properties(self, *_): + # Determine the icon name to use + item = self._item + icon_name = item.icon_name + attention_icon_name = item.attention_icon_name + + # Log item info for user debugging + logger.info( + f"[SysTray] Item: title='{getattr(item, 'title', '')}', identifier='{getattr(item, 'identifier', '')}'" + ) + + # Visibility check + is_hidden = should_hide(item) + self.set_visible(not is_hidden) + + if is_hidden: + return + + if item.status == "NeedsAttention" and attention_icon_name: + preferred_icon_name = attention_icon_name + else: + preferred_icon_name = icon_name + + # 1. Try the standard fabric/GTK implementation first + pixbuf = item.get_preferred_icon_pixbuf(self._icon_size) + + # 2. If standard lookup fails, try your specific absolute path logic + if pixbuf is None and preferred_icon_name: + path = Path(preferred_icon_name) + if path.is_absolute() and path.exists(): + try: + target_size = self._icon_size if self._icon_size else 24 + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), + target_size, + target_size, + True, # Preserve aspect ratio + ) + except GLib.GError: + pixbuf = None + + # Apply the resulting pixbuf or the fallback "missing" icon + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + else: + self._image.set_from_icon_name("image-missing", self._icon_size) + + tooltip = item.tooltip + self.set_tooltip_markup( + tooltip.description + or tooltip.title + or (item.title.title() if item.title else None) + or "Unknown" + ) + + +def apply_enhanced_system_tray(): + # Patch __init__ to track instances + original_init = SystemTrayItem.__init__ + + def patched_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + _tracked_items.add(self) + + SystemTrayItem.__init__ = patched_init + + # Replace the method on the class so all tray items use this logic + SystemTrayItem.do_update_properties = patched_do_update_properties + + # Setup hot-reload listener + def on_reload(new_config, old_config): + if new_config.get("systray_ignore") != old_config.get("systray_ignore"): + logger.info("[SysTray] Config changed, updating tray item visibility") + for item_widget in _tracked_items: + item_widget.do_update_properties() + + on_config_change(on_reload) diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py new file mode 100644 index 00000000..8bdc6371 --- /dev/null +++ b/src/window/panel/components/globalmenu.py @@ -0,0 +1,476 @@ +from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow +from fabric.utils import FormattedString, exec_shell_command_async, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from shared.dialogs.about import AboutApp, get_about_window +from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns +from utils.app_name_resolver import format_window +from utils.gtk_utils import setup_cursor_hover +from utils.roam import modus_service +from window.globalmenu.service import get_global_menu_service +from window.settings.main import get_settings_window + + +def has_active_window(): + return ( + modus_service.current_active_app_name + and modus_service.current_active_app_name != "Modus" + ) + + +def create_menu_button(label, on_clicked=None): + button_kwargs = { + "label": label, + "name": "global-menu", + } + + if on_clicked is not None: + button_kwargs["on_clicked"] = on_clicked + + button = Button(**button_kwargs) + setup_cursor_hover(button, "pointer") + return button + + +def create_dropdown_with_capture(dropdown_id, parent, dropdown_children): + dropdown = ModusDropdown( + dropdown_id=dropdown_id, + parent=parent, + dropdown_children=dropdown_children, + ) + return dropdown + + +def manage_button_style_classes(buttons, active_button=None, style_class="active"): + for button in buttons: + if button: + button.remove_style_class(style_class) + + if active_button: + active_button.add_style_class(style_class) + + +def show_about_app(_=None): + if not has_active_window(): + return + + app_name = modus_service.current_active_app_name + wmclass = getattr(modus_service, "current_active_wm_class", "") + about_window = AboutApp(app_name=app_name, wmclass=wmclass) + about_window.toggle(None) + + +def on_click_subthread(button, on_clicked, on_click): + if on_clicked: + on_clicked(button) + else: + exec_shell_command_async(on_click, lambda *_: None) + + for dropdown in dropdowns: + if dropdown.is_visible(): + dropdown.hide() + break + + +def dropdown_option( + label: str = "", + keybind: str = "", + on_click='echo "ModusPanelDropdown Action"', + on_clicked=None, +): + btn = Button( + child=CenterBox( + start_children=[ + Label(label=label, h_align="start", name="dropdown-option-label"), + ], + end_children=[ + Label(label=keybind, h_align="end", name="dropdown-option-keybind") + ], + orientation="horizontal", + h_align="fill", + h_expand=True, + v_expand=True, + ), + name="dropdown-option", + h_align="fill", + on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click), + h_expand=True, + v_expand=True, + ) + setup_cursor_hover(btn, "pointer") + return btn + + +def _make_menu_item_click(item_id): + """Create a click handler for a menu item.""" + + def handler(_, __=None): + global_menu_svc = get_global_menu_service() + if global_menu_svc: + global_menu_svc.click_item(item_id) + + return handler + + +def _menu_item_to_dropdown(item): + """Convert a DBusMenuItem to a dropdown_option widget.""" + if item.type == "separator": + return dropdown_divider("---------------------") + + keybind = item.shortcut if item.shortcut else "" + + return dropdown_option( + item.label if item.label else "", + keybind, + on_click=None, + on_clicked=_make_menu_item_click(item.id), + ) + + +class SystemDropdown(ModusDropdown): + def __init__(self, parent, **kwargs): + super().__init__( + dropdown_id="os-menu", + parent=parent, + dropdown_children=[ + dropdown_option( + "About this PC", on_clicked=lambda _: get_about_window().toggle() + ), + dropdown_divider("---------------------"), + dropdown_option( + "System Settings...", + on_clicked=lambda _: get_settings_window().toggle(), + ), + dropdown_divider("---------------------"), + dropdown_option( + "Force Quit", "", "hyprctl dispatch 'hl.dsp.window.kill()'" + ), + dropdown_divider("---------------------"), + dropdown_option("Sleep", "", "systemctl suspend"), + dropdown_option("Restart", "", "systemctl reboot"), + dropdown_option("Shut Down", "", "shutdown now"), + dropdown_divider("---------------------"), + dropdown_option("Lock Screen", "󰘳 L", "hyprlock"), + ], + **kwargs, + ) + + +class GlobalMenuDropdowns: + def __init__(self, parent, menu_box=None): + self.parent = parent + self._menu_box = menu_box # The GlobalMenu Box widget to update + self._imac_button = None + + # Get the global menu service + self._global_menu_svc = get_global_menu_service() + self._has_extracted_menu = False + + self.system_dropdown = SystemDropdown(parent=parent) + + self.global_title_menu_about = dropdown_option( + f"About {modus_service.current_active_app_name}", + on_clicked=show_about_app, + ) + self.global_title_menu_quit = dropdown_option( + f"Quit {modus_service.current_active_app_name}", + on_click="hyprctl dispatch 'hl.dsp.window.close()'", + ) + self.global_menu_title = create_dropdown_with_capture( + "global-menu-title", + parent, + [self.global_title_menu_about, self.global_title_menu_quit], + ) + + # Dynamic menu dropdowns (populated from extracted app menus) + self._dynamic_dropdowns: list[ModusDropdown] = [] + + modus_service.connect( + "current-active-app-name-changed", self._on_active_app_changed + ) + + # Connect to global menu service signals (Moved to the bottom of __init__) + + self.global_menu_button_title = ActiveWindow( + formatter=FormattedString( + "{ format_window(win_title, win_class) }", + format_window=format_window, + ), + name="global-menu", + on_clicked=self._on_title_button_clicked, + ) + setup_cursor_hover(self.global_menu_button_title, "pointer") + + self.global_menu_title.set_pointing_to(self.global_menu_button_title) + + # Start with only title + self.all_menu_buttons = [ + self.global_menu_button_title, + ] + + self.dropdown_button_map = { + "global-menu-title": self.global_menu_button_title, + "os-menu": None, + } + + modus_service.connect("current-dropdown-changed", self.changed_dropdown) + modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) + + # Connect to global menu service signals + if self._global_menu_svc: + self._global_menu_svc.connect("menu-changed", self._on_menu_changed) + + def set_imac_button(self, button): + self._imac_button = button + self.dropdown_button_map["os-menu"] = button + + def _get_all_buttons(self): + buttons = list(self.all_menu_buttons) + if self._imac_button and self._imac_button not in buttons: + buttons.append(self._imac_button) + return buttons + + def hide_dropdowns(self, *_): + manage_button_style_classes(self._get_all_buttons()) + + def changed_dropdown(self, _, dropdown_id): + active_button = self.dropdown_button_map.get(dropdown_id) + manage_button_style_classes(self._get_all_buttons(), active_button) + + def _on_title_button_clicked(self, _): + if has_active_window(): + self.global_menu_title.toggle() + + def _on_active_app_changed(self, _, value): + logger.info(f"[GlobalMenu] Active app changed: {value}") + try: + self.global_title_menu_about.get_child().get_start_children()[0].set_label( + f"About {value}" + ) + self.global_title_menu_quit.get_child().get_start_children()[0].set_label( + f"Quit {value}" + ) + except Exception as e: + logger.warning( + f"[globalmenu] self.global_title_menu_about.get_child().get_start_childr... failed: {e}" + ) + + # Notify the global menu service about the active window change + if self._global_menu_svc: + wm_class = getattr(modus_service, "current_active_wm_class", "") + logger.info( + f"[GlobalMenu] Calling update_active_window('{value}', '{wm_class}')" + ) + self._global_menu_svc.update_active_window(value, wm_class) + + def _on_menu_changed(self, _, menu_items: list): + """Handle menu changes from the GlobalMenuService.""" + self._rebuild_dynamic_menus(menu_items) + + def _rebuild_dynamic_menus(self, menu_items: list): + """Rebuild the menu bar buttons based on extracted menu items. + + If the app has a menu bar → show extracted menus (File, Edit, etc.) + If not → show fallback Hyprland window controls (View, Window, Help) + """ + logger.info(f"[GlobalMenu] _rebuild_dynamic_menus: {len(menu_items)} items") + + # Destroy old buttons to prevent massive GTK memory leaks + if hasattr(self, "all_menu_buttons"): + for btn in self.all_menu_buttons: + if btn not in (self.global_menu_button_title,): + try: + if btn.get_parent(): + btn.get_parent().remove(btn) + btn.destroy() + except Exception as e: + logger.warning( + f"[globalmenu] if btn.get_parent(): btn.get_parent().remove(btn) failed: {e}" + ) + + # Clean up any previous dynamic dropdowns + for dd in self._dynamic_dropdowns: + try: + dd.destroy() + except Exception as e: + logger.warning(f"[globalmenu] dd.destroy() failed: {e}") + self._dynamic_dropdowns.clear() + + # Filter to visible top-level menu items only + top_level = [ + item + for item in menu_items + if hasattr(item, "label") and item.label and getattr(item, "visible", True) + ] + logger.info(f"[GlobalMenu] top_level after filter: {len(top_level)} items") + + if not top_level: + # No extracted menu → show only title button + self._has_extracted_menu = False + self.all_menu_buttons = [ + self.global_menu_button_title, + ] + else: + # Has extracted menu → build dynamic buttons + self._has_extracted_menu = True + new_buttons = [ + self.global_menu_button_title, + ] + + for item in top_level: + is_action = not item.has_submenu and not item.children + + if is_action: + # Top-level item without children (e.g., VLC Play/Stop) + btn = create_menu_button( + item.label, + _make_menu_item_click(item.id), + ) + new_buttons.append(btn) + else: + # Build initial children for this dropdown + dropdown_children = [] + for child in item.children if item.children else []: + if not getattr(child, "visible", True): + continue + dropdown_children.append(_menu_item_to_dropdown(child)) + + if not dropdown_children: + dropdown_children = [ + dropdown_option(f"No items in {item.label}") + ] + + dropdown_id = f"global-menu-{item.label.lower()}" + dropdown = create_dropdown_with_capture( + dropdown_id, self.parent, dropdown_children + ) + + btn = create_menu_button( + item.label, + lambda _, dd=dropdown: dd.toggle(), + ) + dropdown.set_pointing_to(btn) + + # Dynamically fetch submenu on hover + def make_on_enter(menu_item, dd): + def on_enter(widget, event): + if not menu_item.has_submenu: + return False + svc = get_global_menu_service() + if svc: + svc.about_to_show(menu_item.id) + new_menu = svc.refresh_menu_sync() + if new_menu: + # find the updated item + new_item = next( + (i for i in new_menu if i.id == menu_item.id), + None, + ) + if new_item: + children = [] + for child in ( + new_item.children + if new_item.children + else [] + ): + if not getattr(child, "visible", True): + continue + children.append( + _menu_item_to_dropdown(child) + ) + if not children: + children = [ + dropdown_option( + f"No items in {new_item.label}" + ) + ] + dd.dropdown.children = children + return False + + return on_enter + + btn.connect("enter-notify-event", make_on_enter(item, dropdown)) + + self._dynamic_dropdowns.append(dropdown) + self.dropdown_button_map[dropdown_id] = btn + new_buttons.append(btn) + + self.all_menu_buttons = new_buttons + + # Update the panel (the actual GTK Box widget) + if self._menu_box is not None: + self._menu_box.children = self.all_menu_buttons + + def destroy(self): + """Clean up all dropdowns and signal connections""" + try: + modus_service.disconnect_by_func(self._on_active_app_changed) + modus_service.disconnect_by_func(self.changed_dropdown) + modus_service.disconnect_by_func(self.hide_dropdowns) + except Exception as e: + logger.error(f"An error occurred: {e}") + + # Disconnect global menu service + if self._global_menu_svc: + try: + self._global_menu_svc.disconnect_by_func(self._on_menu_changed) + except Exception as e: + logger.warning( + f"[globalmenu] self._global_menu_svc.disconnect_by_func(self._on_menu_ch... failed: {e}" + ) + + # Destroy dynamic dropdowns + for dd in self._dynamic_dropdowns: + try: + dd.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + self._dynamic_dropdowns.clear() + + # Destroy static dropdown captures + dropdown_captures = [ + getattr(self, "system_dropdown", None), + getattr(self, "global_menu_title", None), + ] + for capture in dropdown_captures: + try: + if capture and hasattr(capture, "destroy"): + capture.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + + super().destroy() + + +class GlobalMenu(Box): + def __init__(self, parent_window=None, **kwargs): + if parent_window is None: + parent_window = kwargs.pop("parent_window", None) + + super().__init__( + name="globalmenu", orientation="horizontal", spacing=0, **kwargs + ) + + self.dropdown_system = GlobalMenuDropdowns(parent=parent_window, menu_box=self) + + self.children = self.dropdown_system.all_menu_buttons + + def set_imac_button(self, button): + self.dropdown_system.set_imac_button(button) + + def show_system_dropdown(self, imac_button): + self.dropdown_system.set_imac_button(imac_button) + self.dropdown_system.system_dropdown.set_pointing_to(imac_button) + self.dropdown_system.system_dropdown.toggle() + + def destroy(self): + """Clean up the global menu and its dropdowns""" + if hasattr(self, "dropdown_system") and self.dropdown_system: + try: + self.dropdown_system.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + super().destroy() diff --git a/src/window/panel/components/indicators.py b/src/window/panel/components/indicators.py new file mode 100644 index 00000000..bf41e733 --- /dev/null +++ b/src/window/panel/components/indicators.py @@ -0,0 +1,553 @@ +from fabric.utils import logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label + +from services.battery import Battery +from services.bluetooth import BluetoothClient +from services.network import NetworkClient +from shared.window.applet_window import AppletWindow +from shared.window.battery_widget import BatteryControl +from utils.functions import format_duration, get_wifi_icon_for_strength +from utils.gtk_utils import setup_cursor_hover, svg_file +from utils.roam import modus_service +from window.controlcenter.bluetooth import BluetoothConnections +from window.controlcenter.wifi import WifiConnections + + +def create_control_window(name_prefix, parent=None, pointing_to=None): + return AppletWindow( + parent=parent, + pointing_to=pointing_to, + layer="overlay", + title="modus-controlcenter", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + name=f"{name_prefix}-window", + visible=False, + ) + + +def setup_control_center( + show_window, name_prefix, widget_class, parent, pointing_to=None, **widget_kwargs +): + if not show_window: + return None, None + + window = create_control_window(name_prefix, parent=parent, pointing_to=pointing_to) + widget = widget_class(parent, **widget_kwargs) + window.children = [widget] + + return window, widget + + +def handle_indicator_click(window): + if window: + window.toggle() + + +def hide_control_center(window): + if window: + window.hide() + + +class BluetoothIndicator(Box): + def __init__(self, show_window=True, **kwargs): + super().__init__(name="bluetooth-indicator", orientation="h", **kwargs) + self.show_window = show_window + + self.bluetooth = BluetoothClient() + self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22) + + self.bt_button = Button( + name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked + ) + + self.add(self.bt_button) + + # Set pointer cursor on hover + setup_cursor_hover(self.bt_button, "pointer") + + # Setup control center using shared function + ( + self.bluetooth_window, + self.bluetooth_widget, + ) = setup_control_center( + self.show_window, + "bluetooth", + BluetoothConnections, + self, + pointing_to=self.bt_button, + show_back_button=False, + ) + + modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) + self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) + self.bluetooth.connect("device-added", self.on_device_added) + self.bluetooth.connect("device-removed", self.on_device_removed) + # Fix #5 — update icon/state whenever any device connects or disconnects + self.bluetooth.connect( + "notify::connected-devices", self.on_bluetooth_direct_changed + ) + + self.update_modus_service_bluetooth_state() + self.update_state() + + def update_state(self): + if not self.bluetooth.enabled: + self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg") + tooltip = "Bluetooth disabled" + else: + connected_devices = self.bluetooth.connected_devices + if connected_devices: + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") + if len(connected_devices) >= 1: + self.bt_icon.dynamic_file("applets/bluetooth-paired.svg") + device = connected_devices[0] + tooltip = f"Connected to {device.alias}" + if device.battery_percentage > 0: + tooltip += f" ({device.battery_percentage:.0f}%)" + else: + tooltip = f"Connected to {len(connected_devices)} devices" + else: + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") + tooltip = "No devices connected" + + self.bt_button.set_tooltip_text(tooltip) + + def on_bluetooth_changed(self, service, _new_bluetooth_state): + self.update_state() + + def on_bluetooth_direct_changed(self, *args): + self.update_modus_service_bluetooth_state() + self.update_state() + + def on_device_added(self, _, address): + self.update_modus_service_bluetooth_state() + self.update_state() + + def on_device_removed(self, _, address): + self.update_modus_service_bluetooth_state() + self.update_state() + + def update_modus_service_bluetooth_state(self): + if not self.bluetooth.enabled: + bluetooth_state = "disabled" + else: + connected_devices = self.bluetooth.connected_devices + if connected_devices: + if len(connected_devices) == 1: + device = connected_devices[0] + bluetooth_state = f"connected:{device.alias}" + if ( + hasattr(device, "battery_percentage") + and device.battery_percentage > 0 + ): + bluetooth_state += f":{device.battery_percentage:.0f}%" + else: + bluetooth_state = f"connected:{len(connected_devices)}_devices" + else: + bluetooth_state = "enabled" + + modus_service.bluetooth = bluetooth_state + + def on_bluetooth_clicked(self, *args): + handle_indicator_click(self.bluetooth_window) + + def close_bluetooth(self, *args): + hide_control_center(self.bluetooth_window) + + def hide_controlcenter(self, *args): + hide_control_center(self.bluetooth_window) + + +class NetworkIndicator(Box): + def __init__(self, show_window=True, **kwargs): + super().__init__(name="network-indicator", orientation="h", **kwargs) + self.show_window = show_window + + self.network_service = NetworkClient() + + self.network_icon = svg_file("applets/wifi-clear.svg", size=22) + + self.network_button = Button( + name="network-button", + child=self.network_icon, + on_clicked=self.on_wifi_clicked, + ) + + self.add(self.network_button) + + # Set pointer cursor on hover + setup_cursor_hover(self.network_button, "pointer") + + # Setup control center using shared function + ( + self.wifi_window, + self.wifi_widget, + ) = setup_control_center( + self.show_window, + "wifi", + WifiConnections, + self, + pointing_to=self.network_button, + show_back_button=False, + ) + if self.wifi_window: + self.wifi_window._pointing_widget = self.network_button + + modus_service.connect("wlan-changed", self.on_wlan_changed) + self.network_service.connect("device-ready", self.on_wifi_device_added) + self.network_service.connect("device-ready", self.on_ethernet_device_added) + self.update_modus_service_wlan_state() + self.update_state() + + def on_wlan_changed(self, service, _new_wlan_state): + self.update_state() + + def on_wifi_device_added(self, *args): + """Called when WiFi device is added""" + if self.network_service.wifi_device: + self.network_service.wifi_device.connect( + "changed", self.on_network_direct_changed + ) + self.update_modus_service_wlan_state() + self.update_state() + + def on_ethernet_device_added(self, *args): + if self.network_service.ethernet_device: + self.network_service.ethernet_device.connect( + "changed", self.on_network_direct_changed + ) + self.update_modus_service_wlan_state() + self.update_state() + + def on_network_direct_changed(self, *args): + self.update_modus_service_wlan_state() + self.update_state() + + def on_network_changed(self, *args): + self.update_modus_service_wlan_state() + self.update_state() + + def update_modus_service_wlan_state(self): + wlan_state = "disconnected" + + if self.network_service.wifi_device: + wifi = self.network_service.wifi_device + if not wifi.enabled: + wlan_state = "disabled" + elif wifi.ssid: + wlan_state = f"connected:{wifi.ssid}" + if wifi.strength >= 0: + wlan_state += f":{wifi.strength}%" + else: + wlan_state = "enabled" + + # Check Ethernet if WiFi is not connected + elif self.network_service.ethernet_device: + ethernet = self.network_service.ethernet_device + if ethernet.internet == "activated": + wlan_state = "ethernet:connected" + if hasattr(ethernet, "speed") and ethernet.speed: + wlan_state += f":{ethernet.speed}" + elif ethernet.internet == "activating": + wlan_state = "ethernet:connecting" + else: + wlan_state = "ethernet:disconnected" + + modus_service.wlan = wlan_state + + def update_state(self): + tooltip = "No network connection" + icon_file = "wifi-off-clear.svg" + + # Check WiFi first (prioritize WiFi over Ethernet) + if self.network_service.wifi_device: + wifi = self.network_service.wifi_device + if not wifi.enabled: + icon_file = "wifi-off-clear.svg" + tooltip = "WiFi disabled" + elif wifi.ssid: + wifi_icon_path = get_wifi_icon_for_strength(wifi.strength) + self.network_icon.set_from_file(wifi_icon_path) + tooltip = f"Connected to {wifi.ssid}" + if wifi.strength >= 0: + tooltip += f" ({wifi.strength}%)" + self.network_button.set_tooltip_text(tooltip) + return # Early return to avoid setting icon again + else: + icon_file = "wifi-off-clear.svg" + tooltip = "WiFi disconnected" + + # Check Ethernet if WiFi is not connected + elif self.network_service.ethernet_device: + ethernet = self.network_service.ethernet_device + if ethernet.internet == "activated": + icon_file = "network-wired.svg" + tooltip = "Ethernet connected" + if hasattr(ethernet, "speed") and ethernet.speed: + tooltip += f" ({ethernet.speed})" + elif ethernet.internet == "activating": + icon_file = "network-wired.svg" + tooltip = "Ethernet connecting..." + else: + icon_file = "network-wired-offline.svg" + tooltip = "Ethernet disconnected" + + self.network_icon.dynamic_file(f"applets/{icon_file}") + self.network_button.set_tooltip_text(tooltip) + + def on_wifi_clicked(self, *args): + handle_indicator_click(self.wifi_window) + + def close_wifi(self, *args): + hide_control_center(self.wifi_window) + + def on_wifi_direct_changed(self, *args): + hide_control_center(self.wifi_window) + + def on_network_clicked(self, *args): + handle_indicator_click(self.wifi_window) + + def hide_controlcenter(self, *args): + hide_control_center(self.wifi_window) + + +class BatteryIndicator(Box): + def __init__(self, show_window=True, **kwargs): + super().__init__(name="battery-indicator", orientation="h", **kwargs) + self.show_window = show_window + + self.battery_service = Battery.get_initial() + + self.battery_icon = svg_file("battery/battery-100.svg", size=23) + + self.battery_button = Button( + name="battery-button", + child=self.battery_icon, + on_clicked=self.on_battery_clicked, + ) + + self.battery_label = Label(name="battery-label", label="--- %") + + self.add(self.battery_label) + self.add(self.battery_button) + + # Set pointer cursor on hover + setup_cursor_hover(self.battery_button, "pointer") + + ( + self.battery_window, + self.battery_widget, + ) = setup_control_center( + self.show_window, + "battery", + BatteryControl, + self, + pointing_to=self.battery_button, + show_back_button=False, + ) + if self.battery_window: + self.battery_window._pointing_widget = self.battery_button + + modus_service.connect("battery-changed", self.on_battery_changed) + self.battery_service.connect("changed", self.on_battery_direct_changed) + + self.update_modus_service_battery_state() + self.update_state() + + def on_battery_changed(self, service, _new_battery_state): + self.update_state() + + def on_battery_direct_changed(self, *args): + self.update_modus_service_battery_state() + self.update_state() + hide_control_center(self.battery_window) + + def _get_percentage(self): + return int(self.battery_service.percent) + + def _get_state(self): + if self.battery_service.charging: + return "CHARGING" + if self.battery_service.discharging: + return "DISCHARGING" + if self.battery_service.charged: + return "FULLY_CHARGED" + return "UNKNOWN" + + def _is_present(self): + return self.battery_service.available + + def _get_time_to_empty(self): + return self.battery_service.time_remaining + + def _get_time_to_full(self): + return self.battery_service.time_to_full + + def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str: + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + + def update_modus_service_battery_state(self): + if not self._is_present(): + battery_state = "not_present" + else: + percentage = self._get_percentage() + state_str = self._get_state().lower() + + battery_state = f"{state_str}:{percentage}%" + + if state_str == "discharging": + time_to_empty = format_duration(self._get_time_to_empty()) + if time_to_empty != "N/A": + battery_state += f":{time_to_empty}" + elif state_str == "charging": + time_to_full = format_duration(self._get_time_to_full()) + if time_to_full != "N/A": + battery_state += f":{time_to_full}" + + modus_service.battery = battery_state + + def get_battery_tooltip(self, percentage, state): + tooltip = f"Battery: {percentage}%" + + if state == "CHARGING": + tooltip += " (Charging)" + time_to_full = format_duration(self._get_time_to_full()) + if time_to_full != "N/A": + tooltip += f" - {time_to_full} until full" + elif state == "DISCHARGING": + time_to_empty = format_duration(self._get_time_to_empty()) + if time_to_empty != "N/A": + tooltip += f" - {time_to_empty} remaining" + elif state == "FULLY_CHARGED": + tooltip += " (Fully charged)" + + return tooltip + + def update_state(self): + if not self._is_present(): + logger.warning("No battery detected, hiding indicator") + self.hide() + return + else: + self.show() + + percentage = self._get_percentage() + state = self._get_state() + is_charging = state in ["CHARGING", "FULLY_CHARGED"] + + icon_file = self._get_battery_icon_file(percentage, is_charging) + tooltip = self.get_battery_tooltip(percentage, state) + percentage_text = f"{percentage}%" + + self.battery_icon.dynamic_file(icon_file) + self.battery_button.set_tooltip_text(tooltip) + self.battery_label.set_label(percentage_text) + + def on_battery_clicked(self, *args): + handle_indicator_click(self.battery_window) + + def close_battery(self, *args): + hide_control_center(self.battery_window) + + def hide_controlcenter(self, *args): + hide_control_center(self.battery_window) + + def destroy(self): + """Clean up resources when the indicator is destroyed.""" + # Disconnect signals + try: + modus_service.disconnect_by_func(self.on_battery_changed) + if hasattr(self, "battery_service") and self.battery_service: + self.battery_service.disconnect_by_func(self.on_battery_direct_changed) + except Exception as e: + logger.error(f"Error disconnecting battery indicator signals: {e}") + + # Destroy window and widget + try: + if hasattr(self, "battery_widget") and self.battery_widget: + self.battery_widget.destroy() + if hasattr(self, "battery_window") and self.battery_window: + self.battery_window.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + + super().destroy() + + +# Add destroy methods to other classes in this file as well +def add_destroy_to_indicators(): + # BluetoothIndicator + + def bt_destroy(self): + try: + modus_service.disconnect_by_func(self.on_bluetooth_changed) + if hasattr(self, "bluetooth") and self.bluetooth: + self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed) + self.bluetooth.disconnect_by_func(self.on_device_added) + self.bluetooth.disconnect_by_func(self.on_device_removed) + except Exception as e: + logger.error(f"Error disconnecting bluetooth indicator signals: {e}") + try: + if hasattr(self, "bluetooth_widget") and self.bluetooth_widget: + self.bluetooth_widget.destroy() + if hasattr(self, "bluetooth_window") and self.bluetooth_window: + self.bluetooth_window.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + Box.destroy(self) + + BluetoothIndicator.destroy = bt_destroy + + # NetworkIndicator + def net_destroy(self): + try: + modus_service.disconnect_by_func(self.on_wlan_changed) + if hasattr(self, "network_service") and self.network_service: + self.network_service.disconnect_by_func(self.on_wifi_device_added) + self.network_service.disconnect_by_func(self.on_ethernet_device_added) + self.network_service.disconnect_by_func(self.on_network_changed) + + # Also disconnect from the devices themselves if they exist + if ( + hasattr(self.network_service, "wifi_device") + and self.network_service.wifi_device + ): + try: + self.network_service.wifi_device.disconnect_by_func( + self.on_network_direct_changed + ) + except Exception as e: + logger.error(f"An error occurred: {e}") + if ( + hasattr(self.network_service, "ethernet_device") + and self.network_service.ethernet_device + ): + try: + self.network_service.ethernet_device.disconnect_by_func( + self.on_network_direct_changed + ) + except Exception as e: + logger.error(f"An error occurred: {e}") + except Exception as e: + logger.error(f"Error disconnecting network indicator signals: {e}") + try: + if hasattr(self, "wifi_widget") and self.wifi_widget: + self.wifi_widget.destroy() + if hasattr(self, "wifi_window") and self.wifi_window: + self.wifi_window.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + Box.destroy(self) + + NetworkIndicator.destroy = net_destroy + + +# Apply the destroy methods +add_destroy_to_indicators() diff --git a/src/window/panel/components/pomodoro.py b/src/window/panel/components/pomodoro.py new file mode 100644 index 00000000..d637e53e --- /dev/null +++ b/src/window/panel/components/pomodoro.py @@ -0,0 +1,451 @@ +import os +from enum import Enum +from pathlib import Path + +from fabric.utils import GLib, Gtk, exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.label import Label + +from shared.widgets.flat_scale import FlatScale +from shared.widgets.smooth_switch import SmoothSwitch +from shared.window.applet_window import AppletWindow + + +class PomodoroState(Enum): + STOPPED = 0 + FOCUS = 1 + SHORT_BREAK = 2 + LONG_BREAK = 3 + + +class SettingsSpinner(Box): + def __init__(self, value, min_val, max_val, callback, **kwargs): + super().__init__(orientation="h", spacing=8, v_align="center", **kwargs) + self.value = value + self.min_val = min_val + self.max_val = max_val + self.callback = callback + + self.value_label = Label(label=str(self.value), name="pomodoro-spin-value") + self.value_label.set_size_request(24, -1) + + up_icon = Label(label="▲") + down_icon = Label(label="▼") + + up_btn = Button(child=up_icon, name="pomodoro-spin-btn") + up_btn.connect("clicked", self._on_up) + + down_btn = Button(child=down_icon, name="pomodoro-spin-btn") + down_btn.connect("clicked", self._on_down) + + btn_box = Box(orientation="v", spacing=0, children=[up_btn, down_btn]) + btn_box.set_name("pomodoro-spin-box") + + self.add(self.value_label) + self.add(btn_box) + + def _on_up(self, *_): + if self.value < self.max_val: + self.value += 1 + self.value_label.set_label(str(self.value)) + if self.callback: + self.callback() + + def _on_down(self, *_): + if self.value > self.min_val: + self.value -= 1 + self.value_label.set_label(str(self.value)) + if self.callback: + self.callback() + + def get_value(self): + return self.value + + +class Pomodoro(AppletWindow): + def __init__(self, parent=None, pointing_to=None, **kwargs): + super().__init__( + parent=parent, + pointing_to=pointing_to, + layer="top", + anchor="top right", + margin="2px 10px 0px 0px", + edge_margin=5, + name="pomodoro-popup", + title="modus-pomodoro", + **kwargs, + ) + + self.state = PomodoroState.STOPPED + self.current_cycle = 1 + + # Default settings + self.focus_mins = 25 + self.short_break_mins = 5 + self.long_break_mins = 15 + self.max_cycles = 4 + self.time_left = self.focus_mins * 60 + self.total_time = self.time_left + + self.is_running = False + self.auto_start = True + self.volume = 100 + + self._timeout_id = None + + self._build_ui() + self._update_display() + + def _build_ui(self): + # Progress Bar and Timer Display + self.time_label = Label(label="25:00", name="pomodoro-time") + self.time_label.set_margin_bottom(5) + self.cycle_label = Label(label="Cycle 1 / 4", name="pomodoro-cycle") + + timer_box = Box( + orientation="v", + v_align="center", + h_align="center", + children=[self.time_label, self.cycle_label], + ) + + self.progress_bar = CircularProgressBar( + value=1.0, + size=180, + line_width=12, + child=timer_box, + name="pomodoro-progress", + ) + + self.phase_buttons = {} + phase_box = Box(orientation="h", spacing=8, h_align="center") + + for phase in ["Focus", "Short Break", "Long Break"]: + btn = Button( + label=phase, + name="pomodoro-phase-btn", + on_clicked=lambda _, p=phase: self._switch_phase(p), + ) + self.phase_buttons[phase] = btn + phase_box.add(btn) + + header_box = Box( + orientation="v", + h_align="center", + spacing=10, + children=[phase_box, self.progress_bar], + ) + header_box.set_margin_top(15) + + # Primary Buttons (Start/Pause, Reset) + self.start_btn = Button( + label="Start", name="pomodoro-start-btn", on_clicked=self.toggle_timer + ) + self.reset_btn = Button( + label="Reset", name="pomodoro-reset-btn", on_clicked=self.reset_timer + ) + + primary_btn_box = Box( + orientation="h", + spacing=10, + h_align="center", + children=[self.start_btn, self.reset_btn], + ) + + # Secondary Buttons (Skip, Restart) + self.skip_btn = Button( + label="Skip", name="pomodoro-secondary-btn", on_clicked=self.skip_session + ) + self.restart_btn = Button( + label="Restart", + name="pomodoro-secondary-btn", + on_clicked=self.restart_session, + ) + + secondary_btn_box = Box( + orientation="h", + spacing=50, + h_align="center", + children=[self.skip_btn, self.restart_btn], + ) + secondary_btn_box.set_margin_bottom(15) + secondary_btn_box.set_margin_top(5) + + # Settings + self.focus_spin = SettingsSpinner( + self.focus_mins, 1, 120, self._on_settings_changed + ) + self.sbreak_spin = SettingsSpinner( + self.short_break_mins, 1, 60, self._on_settings_changed + ) + self.lbreak_spin = SettingsSpinner( + self.long_break_mins, 1, 60, self._on_settings_changed + ) + self.cycles_spin = SettingsSpinner( + self.max_cycles, 1, 10, self._on_settings_changed + ) + + settings_list = Box( + orientation="v", + spacing=10, + children=[ + self._setting_row("Focus", self.focus_spin, "min"), + self._setting_row("Short Break", self.sbreak_spin, "min"), + self._setting_row("Long Break", self.lbreak_spin, "min"), + self._setting_row("Cycles", self.cycles_spin, ""), + ], + ) + settings_list.set_margin_bottom(15) + + # Options + self.auto_start_switch = SmoothSwitch( + name="settings-switch", + active=self.auto_start, + on_user_toggle=self._on_auto_start_toggled, + ) + + auto_start_box = Box( + orientation="h", + spacing=10, + children=[ + Label(label="Auto-start next session", h_expand=True, h_align="start"), + self.auto_start_switch, + ], + ) + auto_start_box.set_margin_bottom(10) + + # Volume + self.volume_scale = FlatScale( + min_value=0, + max_value=100, + value=self.volume, + h_expand=True, + on_value_changed=self._on_volume_changed, + ) + self.volume_scale.set_size_request(-1, 30) + self.volume_scale.add_style_class("flat-scale") + + volume_box = Box( + orientation="h", + spacing=10, + children=[Label(label="Volume", h_align="start"), self.volume_scale], + ) + + main_box = Box( + orientation="v", + spacing=10, + name="pomodoro-container", + children=[ + header_box, + primary_btn_box, + secondary_btn_box, + Gtk.Separator(), + settings_list, + Gtk.Separator(), + auto_start_box, + volume_box, + ], + ) + main_box.set_margin_left(20) + main_box.set_margin_right(20) + main_box.set_margin_bottom(20) + + self.children = main_box + + def _setting_row(self, label, widget, suffix): + suffix_label = Label(label=suffix) if suffix else None + widget_box = Box(orientation="h", spacing=5, children=[widget]) + if suffix_label: + widget_box.add(suffix_label) + + return Box( + orientation="h", + children=[Label(label=label, h_expand=True, h_align="start"), widget_box], + ) + + def _on_settings_changed(self, *_): + self.focus_mins = int(self.focus_spin.get_value()) + self.short_break_mins = int(self.sbreak_spin.get_value()) + self.long_break_mins = int(self.lbreak_spin.get_value()) + self.max_cycles = int(self.cycles_spin.get_value()) + + if self.state == PomodoroState.STOPPED: + self.reset_timer() + self._update_display() + + def _on_auto_start_toggled(self, active: bool): + self.auto_start = active + + def _on_volume_changed(self, widget, value): + self.volume = value + + def toggle_timer(self, *_): + if self.is_running: + self.pause_timer() + else: + self.start_timer() + + def _switch_phase(self, phase): + self.pause_timer() + if phase == "Focus": + self.state = PomodoroState.FOCUS + self.total_time = self.focus_mins * 60 + elif phase == "Short Break": + self.state = PomodoroState.SHORT_BREAK + self.total_time = self.short_break_mins * 60 + elif phase == "Long Break": + self.state = PomodoroState.LONG_BREAK + self.total_time = self.long_break_mins * 60 + self.time_left = self.total_time + self._update_display() + + def start_timer(self): + if self.state == PomodoroState.STOPPED: + self.state = PomodoroState.FOCUS + self.total_time = self.focus_mins * 60 + self.time_left = self.total_time + + self.is_running = True + self.start_btn.set_label("Pause") + + if self._timeout_id is None: + self._timeout_id = GLib.timeout_add_seconds(1, self._tick) + + def pause_timer(self): + self.is_running = False + self.start_btn.set_label("Start") + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def reset_timer(self, *_): + self.pause_timer() + self.state = PomodoroState.STOPPED + self.current_cycle = 1 + self.total_time = self.focus_mins * 60 + self.time_left = self.total_time + self._update_display() + + def restart_session(self, *_): + self.pause_timer() + if self.state == PomodoroState.FOCUS: + self.total_time = self.focus_mins * 60 + elif self.state == PomodoroState.SHORT_BREAK: + self.total_time = self.short_break_mins * 60 + elif self.state == PomodoroState.LONG_BREAK: + self.total_time = self.long_break_mins * 60 + else: + self.total_time = self.focus_mins * 60 + self.state = PomodoroState.FOCUS + + self.time_left = self.total_time + self._update_display() + self.start_timer() + + def skip_session(self, *_): + self.pause_timer() + self._finish_session() + + def _tick(self): + if not self.is_running: + self._timeout_id = None + return False + + if self.time_left > 0: + self.time_left -= 1 + self._update_display() + return True + else: + self._timeout_id = None + self._finish_session() + return False + + def _finish_session(self): + if self.state == PomodoroState.FOCUS: + if self.current_cycle >= self.max_cycles: + self.state = PomodoroState.LONG_BREAK + self.total_time = self.long_break_mins * 60 + exec_shell_command_async( + 'notify-send "Pomodoro" "Focus session complete! Time for a long break."' + ) + else: + self.state = PomodoroState.SHORT_BREAK + self.total_time = self.short_break_mins * 60 + exec_shell_command_async( + 'notify-send "Pomodoro" "Focus session complete! Time for a short break."' + ) + else: + if self.state == PomodoroState.LONG_BREAK: + self.current_cycle = 1 + else: + self.current_cycle += 1 + + self.state = PomodoroState.FOCUS + self.total_time = self.focus_mins * 60 + exec_shell_command_async( + 'notify-send "Pomodoro" "Break over! Time to focus."' + ) + + self.play_sound() + self.time_left = self.total_time + self._update_display() + + if self.auto_start: + self.start_timer() + else: + self.is_running = False + self.start_btn.set_label("Start") + self._timeout_id = None + + def play_sound(self): + sound_file = "" + base_dir = ( + Path(__file__).parent.parent.parent.parent + / "assets" + / "sounds" + / "pomodoro" + ) + + if self.state == PomodoroState.FOCUS: + sound_file = str(base_dir / "alert-work.mp3") + elif self.state == PomodoroState.SHORT_BREAK: + sound_file = str(base_dir / "alert-short-break.mp3") + elif self.state == PomodoroState.LONG_BREAK: + sound_file = str(base_dir / "alert-long-break.mp3") + + if os.path.exists(sound_file): + vol = float(self.volume) / 100.0 + exec_shell_command_async(f"pw-play --volume={vol} '{sound_file}'") + + def _update_display(self): + mins = self.time_left // 60 + secs = self.time_left % 60 + self.time_label.set_label(f"{mins:02d}:{secs:02d}") + self.cycle_label.set_label(f"Cycle {self.current_cycle} / {self.max_cycles}") + + progress = self.time_left / self.total_time if self.total_time > 0 else 0 + self.progress_bar.value = progress + self.progress_bar.queue_draw() + + # Update phase button active states + active_name = { + PomodoroState.FOCUS: "Focus", + PomodoroState.SHORT_BREAK: "Short Break", + PomodoroState.LONG_BREAK: "Long Break", + }.get(self.state, "Focus") + + for name, btn in self.phase_buttons.items(): + if name == active_name: + btn.add_style_class("active") + else: + btn.remove_style_class("active") + + # Update progress bar color + if self.state == PomodoroState.SHORT_BREAK: + self.progress_bar.set_name("pomodoro-progress-short") + elif self.state == PomodoroState.LONG_BREAK: + self.progress_bar.set_name("pomodoro-progress-long") + else: + self.progress_bar.set_name("pomodoro-progress") diff --git a/modules/todo/todo_widget.py b/src/window/panel/components/todo.py similarity index 75% rename from modules/todo/todo_widget.py rename to src/window/panel/components/todo.py index a19cb96d..14dbfc78 100644 --- a/modules/todo/todo_widget.py +++ b/src/window/panel/components/todo.py @@ -1,21 +1,15 @@ -# Standard library imports from datetime import datetime -# Fabric imports -from fabric.utils import get_relative_path +from fabric.utils import GLib from fabric.widgets.box import Box from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox from fabric.widgets.entry import Entry from fabric.widgets.label import Label -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.svg import Svg -from gi.repository import GLib -# Local imports -from services.todo import todo_service -from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window +from services.todo import get_todo_service +from shared.window.animated_scrollwindow import AnimatedScrollable +from shared.window.applet_window import AppletWindow +from utils.gtk_utils import svg_file class TodoItem(Box): @@ -30,7 +24,6 @@ def __init__(self, todo_data, todo_list_widget, **kwargs): name="todo-item", orientation="h", spacing=8, - style_classes=["menu"], **kwargs, ) @@ -44,12 +37,10 @@ def _build_ui(self): if self.todo_data["completed"] else "checkbox-uncheck.svg" ) - self.checkbox_icon = Svg( + self.checkbox_icon = svg_file( + "todo/" + checkbox_icon, name="todo-checkbox-icon", size=24, - svg_file=get_relative_path( - "../../config/assets/icons/todo/" + checkbox_icon - ), ) self.checkbox = Button( name="todo-checkbox", @@ -109,10 +100,10 @@ def _build_ui(self): # ) # # Edit button - using SVG icon - self.edit_icon = Svg( + self.edit_icon = svg_file( + "todo/edit.svg", name="todo-edit-icon", size=12, - svg_file=get_relative_path("../../config/assets/icons/todo/edit.svg"), ) self.edit_button = Button( name="todo-edit", @@ -121,12 +112,10 @@ def _build_ui(self): ) # Delete button - using SVG icon - self.delete_icon = Svg( + self.delete_icon = svg_file( + "todo/delete-symbolic.svg", name="todo-delete-icon", size=12, - svg_file=get_relative_path( - "../../config/assets/icons/todo/delete-symbolic.svg" - ), ) self.delete_button = Button( name="todo-delete", @@ -144,24 +133,25 @@ def _build_ui(self): ], ) - self.children = [ + for child in [ self.checkbox, self.text_container, - # self.priority_button, self.edit_button, self.delete_button, - ] + ]: + self.add(child) def _toggle_completion(self, *_): """Toggle todo completion status""" - todo_service.toggle_todo(self.todo_data["id"]) + get_todo_service().toggle_todo(self.todo_data["id"]) def _cycle_priority(self, *_): """Cycle through priority levels""" priorities = ["low", "medium", "high"] - current_index = priorities.index(self.todo_data["priority"]) + current_priority = self.todo_data.get("priority", "medium") + current_index = priorities.index(current_priority) new_priority = priorities[(current_index + 1) % len(priorities)] - todo_service.set_priority(self.todo_data["id"], new_priority) + get_todo_service().set_priority(self.todo_data["id"], new_priority) def _start_edit(self, *_): """Start editing the todo text""" @@ -184,7 +174,7 @@ def _save_edit(self, *_): new_text = self.text_entry.get_text().strip() if new_text: - todo_service.edit_todo(self.todo_data["id"], new_text) + get_todo_service().edit_todo(self.todo_data["id"], new_text) self._cancel_edit() @@ -199,7 +189,7 @@ def _cancel_edit(self): def _delete_todo(self, *_): """Delete this todo""" - todo_service.delete_todo(self.todo_data["id"]) + get_todo_service().delete_todo(self.todo_data["id"]) def update_from_data(self, todo_data): """Update the widget from new todo data""" @@ -209,14 +199,15 @@ def update_from_data(self, todo_data): checkbox_icon = ( "checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg" ) - new_checkbox_icon = Svg( + new_checkbox_icon = svg_file( + "todo/" + checkbox_icon, name="todo-checkbox-icon", size=20, - svg_file=get_relative_path( - "../../config/assets/icons/todo/" + checkbox_icon - ), ) - self.checkbox.set_child(new_checkbox_icon) + old_child = self.checkbox.get_child() + if old_child: + self.checkbox.remove(old_child) + self.checkbox.add(new_checkbox_icon) self.checkbox_icon = new_checkbox_icon # Update text and styling with markup @@ -235,25 +226,27 @@ def update_from_data(self, todo_data): self.date_label.set_label(date_text) -class TodoListWidget(Window): - """Main todo list widget window""" +class TodoListWidget(AppletWindow): + """Main todo list window""" - def __init__(self, **kwargs): + def __init__(self, parent=None, pointing_to=None, **kwargs): super().__init__( + parent=parent, + pointing_to=pointing_to, + layer="top", title="modus-todo", anchor="top right", margin="2px 10px 0px 0px", exclusivity="auto", - keyboard_mode="on-demand", - name="todo-list-window", - visible=False, # Back to hidden by default + name="todo-list-menu", + visible=False, **kwargs, ) self.todo_items = {} # Maps todo IDs to TodoItem widgets # Register callback with todo service - todo_service.add_callback(self._on_todo_event) + get_todo_service().add_callback(self._on_todo_event) self._build_ui() self._refresh_todos() @@ -294,12 +287,10 @@ def _build_ui(self): self.new_todo_entry.connect("activate", self._add_todo) # Add button - using SVG icon - self.add_icon = Svg( + self.add_icon = svg_file( + "todo/plus-symbolic.svg", name="add-todo-icon", size=12, - svg_file=get_relative_path( - "../../config/assets/icons/todo/plus-symbolic.svg" - ), ) self.add_button = Button( name="add-todo-button", @@ -311,7 +302,6 @@ def _build_ui(self): name="todo-add-section", orientation="h", spacing=8, - style_classes=["menu"], children=[ self.new_todo_entry, self.add_button, @@ -323,17 +313,19 @@ def _build_ui(self): name="todos-container", orientation="v", spacing=4, + v_expand=True, ) # Scrolled window for todos - self.scrolled = ScrolledWindow( + self.scrolled = AnimatedScrollable( name="todos-scrolled", - min_content_height=300, - max_content_height=500, - min_content_width=400, + min_content_size=(400, 300), + max_content_size=(-1, 500), child=self.todos_container, - policy="automatic", - v_expand=True, # Allow vertical expansion + h_scrollbar_policy="automatic", + v_scrollbar_policy="automatic", + propagate_height=False, + v_expand=True, ) # Clear completed button @@ -349,7 +341,6 @@ def _build_ui(self): name="todo-main-container", orientation="v", spacing=8, - style_classes=["menu"], children=[ self.header, self.add_section, @@ -358,26 +349,26 @@ def _build_ui(self): ], ) - self.children = [self.main_container] + self.add(self.main_container) def _add_todo(self, *_): """Add a new todo""" text = self.new_todo_entry.get_text().strip() if text: - todo_service.add_todo(text) + get_todo_service().add_todo(text) self.new_todo_entry.set_text("") def _clear_completed(self, *_): """Clear all completed todos""" - todo_service.clear_completed() + get_todo_service().clear_completed() def _on_todo_event(self, event_type, data=None): """Handle todo service events via callback""" - if event_type == "todos-changed": - GLib.idle_add(self._refresh_todos) - elif event_type == "todo-added": - GLib.idle_add(self._refresh_todos) - elif event_type == "todo-deleted": + if ( + event_type == "todos-changed" + or event_type == "todo-added" + or event_type == "todo-deleted" + ): GLib.idle_add(self._refresh_todos) elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]: if data and data["id"] in self.todo_items: @@ -388,38 +379,40 @@ def _on_todo_event(self, event_type, data=None): def _refresh_todos(self, *_): """Refresh the entire todo list""" - # Clear existing items + from utils.functions import clear_children + self.todo_items.clear() - self.todos_container.children = [] + clear_children(self.todos_container) # Get all todos - todos = todo_service.todos + todos = get_todo_service().todos # Sort todos: incomplete first, then by priority, then by creation date def sort_key(todo): priority_order = {"high": 0, "medium": 1, "low": 2} return ( todo["completed"], # False (incomplete) comes before True (completed) - priority_order.get(todo["priority"], 1), + priority_order.get(todo.get("priority", "medium"), 1), todo["created_at"], ) sorted_todos = sorted(todos, key=sort_key) - # Create todo item widgets + # Create todo item widgets and add them to the container for todo in sorted_todos: todo_item = TodoItem(todo, self) self.todo_items[todo["id"]] = todo_item + self.todos_container.add(todo_item) - # Update container children - self.todos_container.children = list(self.todo_items.values()) + # Ensure they are visible + self.todos_container.show_all() # Update stats self._update_stats() def _update_stats(self): """Update the statistics display""" - stats = todo_service.get_stats() + stats = get_todo_service().get_stats() stats_text = f"{stats['pending']} pending, {stats['completed']} completed" self.stats_label.set_label(stats_text) @@ -429,28 +422,12 @@ def set_visible(self, visible): def hide_todo_list(self, *_): """Hide the todo list""" - if hasattr(self, "_mousecapture_parent"): - self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) - - def _init_mousecapture(self, mousecapture): - """Initialize mousecapture parent reference""" - self._mousecapture_parent = mousecapture + self.hide() + self.hide() def destroy(self): """Clean up when destroyed""" # Remove callback from todo service - todo_service.remove_callback(self._on_todo_event) + get_todo_service().remove_callback(self._on_todo_event) super().destroy() - - -class TodoListCapture(MouseCapture): - """MouseCapture wrapper for the todo list""" - - def __init__(self, **kwargs): - super().__init__( - layer="top", - child_window=TodoListWidget(), - **kwargs, - ) diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py new file mode 100644 index 00000000..74b07cc8 --- /dev/null +++ b/src/window/panel/components/workspace.py @@ -0,0 +1,113 @@ +from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton +from fabric.utils import logger +from fabric.widgets.box import Box + +from services.config import get_config, on_config_change +from utils.functions import is_special_workspace_id +from utils.gtk_utils import setup_cursor_hover + +# TODO: Support multi-monitor setups + + +class LuaHyprlandWorkspaces(HyprlandWorkspaces): + def do_action_next(self): + ws = "e" if not self._empty_scroll else "" + return self.connection.send_command( + f"batch/dispatch hl.dsp.focus({{workspace=[[{ws}+1]]}})" + ) + + def do_action_previous(self): + ws = "e" if not self._empty_scroll else "" + return self.connection.send_command( + f"batch/dispatch hl.dsp.focus({{workspace=[[{ws}-1]]}})" + ) + + def do_button_clicked(self, button: WorkspaceButton): + return self.connection.send_command( + f"batch/dispatch hl.dsp.focus({{workspace=[[{button.id}]]}})" + ) + + +class WorkspaceIndicator(Box): + def __init__(self, **kwargs): + Box.__init__( + self, name="workspace-indicator", orientation="h", spacing=4, **kwargs + ) + self._current_config = {"hide_special_workspace": True} + on_config_change(self._on_config_changed) + + self.workspaces = LuaHyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=self._get_button_factory(), + ) + + self.add(self.workspaces) + self.show_all() + + self._apply_initial_config() + + def _apply_initial_config(self): + new_value = get_config("hide_special_workspace", True) + if new_value != self._current_config["hide_special_workspace"]: + self._current_config["hide_special_workspace"] = new_value + self.update_config({"hide_special_workspace": new_value}) + + def _on_config_changed(self, new_config: dict, old_config: dict): + if "hide_special_workspace" in new_config and new_config.get( + "hide_special_workspace" + ) != old_config.get("hide_special_workspace"): + self._current_config["hide_special_workspace"] = new_config.get( + "hide_special_workspace", True + ) + self.update_config( + { + "hide_special_workspace": self._current_config[ + "hide_special_workspace" + ] + } + ) + + def _get_button_factory(self): + if self._current_config.get("hide_special_workspace", True): + return self._create_filtered_button + else: + return lambda ws_id: self._create_button_with_hover(ws_id) + + def _create_filtered_button(self, ws_id): + if self._current_config.get( + "hide_special_workspace", True + ) and is_special_workspace_id(ws_id): + return None + return self._create_button_with_hover(ws_id) + + def _create_button_with_hover(self, ws_id): + button = WorkspaceButton(id=ws_id, label=str(ws_id)) + setup_cursor_hover(button, "pointer") + return button + + def update_config(self, new_config: dict): + if "hide_special_workspace" in new_config: + button_factory = self._get_button_factory() + if hasattr(self, "workspaces") and self.workspaces: + self.workspaces.destroy() + + self.workspaces = LuaHyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=button_factory, + ) + self.add(self.workspaces) + self.workspaces.show_all() + + def destroy(self): + try: + from services.config import _config_handlers + + if self._on_config_changed in _config_handlers: + _config_handlers.remove(self._on_config_changed) + except Exception as e: + logger.error(f"An error occurred: {e}") + if hasattr(self, "workspaces") and self.workspaces: + self.workspaces.destroy() + Box.destroy(self) diff --git a/src/window/panel/main.py b/src/window/panel/main.py new file mode 100644 index 00000000..2cb49f9e --- /dev/null +++ b/src/window/panel/main.py @@ -0,0 +1,406 @@ +from fabric.system_tray.widgets import SystemTray +from fabric.utils import Gdk, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +from services.config import get_config_all, on_config_change +from services.modus import notification_service +from utils.gtk_utils import setup_cursor_hover, svg_file +from utils.roam import modus_service +from window.controlcenter.main import ModusControlCenter +from window.notification.notification_center import NotificationCenter +from window.panel.components.custom_mods import CustomMods +from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray +from window.panel.components.globalmenu import GlobalMenu +from window.panel.components.indicators import ( + BatteryIndicator, + BluetoothIndicator, + NetworkIndicator, +) +from window.panel.components.workspace import WorkspaceIndicator +from window.panel.notch import Notch + +apply_enhanced_system_tray() + + +class Panel(Window): + def __init__(self, **kwargs): + super().__init__( + title="mdous-bar", + name="bar", + layer="top", + anchor="left top right", + exclusivity="auto", + visible=False, + all_visible=False, + ) + self.globalmenu = GlobalMenu(parent_window=self) + + self.imac = Button( + name="panel-button", + child=svg_file("misc/logo.svg", size=18), + on_clicked=lambda *_: self.globalmenu.show_system_dropdown(self.imac), + ) + setup_cursor_hover(self.imac, "pointer") + self.globalmenu.set_imac_button(self.imac) + + self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20) + + self.tray_revealer = Revealer( + name="tray-revealer", + child=self.tray, + child_revealed=False, + transition_type="slide-left", + transition_duration=300, + ) + + self.chevron_button = Button( + name="panel-button", + child=svg_file("misc/chevron-right.svg", size=16), + on_clicked=self.toggle_tray, + ) + setup_cursor_hover(self.chevron_button, "pointer") + + # Hide tray elements if empty + self.tray.connect("add", self._update_tray_visibility) + self.tray.connect("remove", self._update_tray_visibility) + + self._spotlight_toggle_cb = None + self.search = Button( + name="panel-button", + child=svg_file("misc/search.svg", size=22), + on_clicked=lambda *_: ( + self._spotlight_toggle_cb() if self._spotlight_toggle_cb else None + ), + ) + setup_cursor_hover(self.search, "pointer") + + self.control_center_btn = Button( + name="panel-button", + child=svg_file("misc/control-center.svg", size=22), + ) + setup_cursor_hover(self.control_center_btn, "pointer") + + self._control_center_instance = None + self.control_center_btn.connect( + "clicked", + lambda *_: self.control_center.toggle(), + ) + + self.notification_icon = svg_file( + "notifications/notification-inactive.svg", size=22 + ) + + self.notification_center_btn = Button( + name="panel-button", + child=self.notification_icon, + on_clicked=self.on_notification_icon_clicked, + ) + setup_cursor_hover(self.notification_center_btn, "pointer") + + self._notification_center_instance = None + + self.datetime_btn = Button( + name="panel-button", + child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), + ) + self.datetime_btn.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK + ) + setup_cursor_hover(self.datetime_btn, "pointer") + + self._pomodoro_instance = None + self._todo_instance = None + self.datetime_btn.connect("clicked", self.on_datetime_clicked) + self.datetime_btn.connect("button-press-event", self.on_datetime_right_click) + + self.custom_mods = CustomMods(parent_window=self) + self.workspace_indicator = WorkspaceIndicator() + + # Create persistent indicators + self.battery_indicator = BatteryIndicator() + self.network_indicator = NetworkIndicator() + self.bluetooth_indicator = BluetoothIndicator() + + self.indicators = Box( + name="indicators", + orientation="h", + spacing=4, + children=[ + self.battery_indicator, + self.network_indicator, + self.bluetooth_indicator, + ], + ) + + # Create boxes and mount + self.left_box = Box(name="window-left") + self.notch = Notch() + self.center_box = Box(name="window-center", children=self.notch) + self.right_box = Box(name="window-right", spacing=4, orientation="h") + + self.children = CenterBox( + name="panel", + start_children=self.left_box, + center_children=self.center_box, + end_children=self.right_box, + ) + + # Connect to DND state changes for notification icon + modus_service.connect("dont-disturb-changed", self.on_dnd_changed) + + # Hide/show panel on fullscreen toggle + modus_service.connect("fullscreen-changed", self._on_fullscreen_changed) + + # Connect to notification service for icon state updates + notification_service.connect( + "notify::count", self.on_notification_count_changed + ) + + # Set initial notification icon state + self.update_notification_icon() + + # Initial layout build + self._rebuild_layout_from_config(get_config_all()) + + # Live updates + on_config_change(self._on_config_changed) + + self._update_tray_visibility() + self.show() + + def set_spotlight_toggle(self, callback): + self._spotlight_toggle_cb = callback + + def on_dnd_changed(self, _, dnd_state): + self.update_notification_icon() # Update notification icon when DND changes + + def _on_fullscreen_changed(self, _, is_fullscreen): + self.set_visible(not is_fullscreen) + + def on_notification_count_changed(self, service, *args): + self.update_notification_icon() + + def on_notification_icon_clicked(self, *_): + self.notification_center.toggle() + + def on_datetime_clicked(self, *_): + self.pomodoro.toggle() + + def on_datetime_right_click(self, widget, event): + logger.info(f"[Panel] Right click detected: button={event.button}") + if event.button == 3: # Right click + self.todo_widget.toggle() + return True + return False + + @property + def pomodoro(self): + if self._pomodoro_instance is None: + from window.panel.components.pomodoro import Pomodoro + + self._pomodoro_instance = Pomodoro( + parent=self, pointing_to=self.datetime_btn + ) + return self._pomodoro_instance + + @property + def todo_widget(self): + if self._todo_instance is None: + from window.panel.components.todo import TodoListWidget + + self._todo_instance = TodoListWidget( + parent=self, pointing_to=self.datetime_btn + ) + return self._todo_instance + + @property + def control_center(self): + if self._control_center_instance is None: + self._control_center_instance = ModusControlCenter( + parent=self, pointing_to=self.control_center_btn + ) + return self._control_center_instance + + @property + def notification_center(self): + if self._notification_center_instance is None: + self._notification_center_instance = NotificationCenter( + parent=self, pointing_to=self.notification_center_btn + ) + return self._notification_center_instance + + def update_notification_icon(self): + count = notification_service.count + dnd_enabled = modus_service.dont_disturb + + if dnd_enabled: + # DND is enabled - show disabled icon + icon_file = "notification-disabled.svg" + elif count > 0: + # Has notifications - show active icon + icon_file = "notification-active.svg" + else: + # No notifications - show inactive icon + icon_file = "notification-inactive.svg" + + self.notification_icon.dynamic_file(f"notifications/{icon_file}") + + def _rebuild_layout_from_config(self, config_data=None): + if config_data is None: + config_data = get_config_all() + + # Update indicator visibility directly + battery_visible = config_data.get("battery", True) + network_visible = config_data.get("network", True) + bluetooth_visible = config_data.get("bluetooth", True) + + self.battery_indicator.set_visible(battery_visible) + self.network_indicator.set_visible(network_visible) + self.bluetooth_indicator.set_visible(bluetooth_visible) + + # Ensure they update their internal state + if battery_visible: + self.battery_indicator.update_state() + if network_visible: + self.network_indicator.update_state() + if bluetooth_visible: + self.bluetooth_indicator.update_state() + + # Left + left_children = [] + if config_data.get("imac_button", True): + left_children.append(self.imac) + if config_data.get("global_menu", True): + left_children.append(self.globalmenu) + + for child in left_children: + child.show() + self.left_box.children = left_children + + # Right + right_children = [] + if config_data.get("custom_mods", True): + right_children.append(self.custom_mods) + if config_data.get("workspace_indicator", True): + right_children.append(self.workspace_indicator) + + if config_data.get("systray", True): + right_children.extend([self.tray_revealer, self.chevron_button]) + + right_children.append(self.indicators) + + if config_data.get("search", True): + right_children.append(self.search) + if config_data.get("control_center", True): + right_children.append(self.control_center_btn) + if config_data.get("date_time", True): + right_children.append(self.datetime_btn) + if config_data.get("notification_center", True): + right_children.append(self.notification_center_btn) + + for child in right_children: + child.show() + self.right_box.children = right_children + + self.indicators.show() + self._update_tray_visibility() + self.show() + + # Force a layout recalculation + self.queue_resize() + + def _on_config_changed(self, new_config, old_config): + logger.debug("Config changed, checking keys...") + keys = { + "imac_button", + "global_menu", + "custom_mods", + "workspace_indicator", + "systray", + "battery", + "network", + "bluetooth", + "search", + "control_center", + "date_time", + "notification_center", + } + changed_keys = [k for k in keys if new_config.get(k) != old_config.get(k)] + if changed_keys: + logger.debug(f"Rebuilding due to changes in: {changed_keys}") + self._rebuild_layout_from_config(new_config) + + self._update_tray_visibility() + + def _update_tray_visibility(self, *_): + # We check if there are any visible children in the tray + visible_children = [ + child for child in self.tray.get_children() if child.get_visible() + ] + has_items = len(visible_children) > 0 + + self.tray_revealer.set_visible(has_items) + self.chevron_button.set_visible(has_items) + + if not has_items: + # Reset state if it becomes hidden + self.tray_revealer.child_revealed = False + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") + # Add extra spacing between workspace indicator and indicators when tray is hidden + self.indicators.set_margin_left(10) + else: + self.indicators.set_margin_left(0) + + def toggle_tray(self, *_): + current_state = self.tray_revealer.child_revealed + self.tray_revealer.child_revealed = not current_state + + if self.tray_revealer.child_revealed: + self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg") + else: + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") + + def destroy(self): + """Clean up all signals and components""" + try: + modus_service.disconnect_by_func(self.on_dnd_changed) + notification_service.disconnect_by_func(self.on_notification_count_changed) + from services.config import _config_handlers + + if self._on_config_changed in _config_handlers: + _config_handlers.remove(self._on_config_changed) + except Exception as e: + logger.error(f"An error occurred: {e}") + + # Destroy components + for component in [ + self.globalmenu, + self.notch, + self.custom_mods, + self.workspace_indicator, + self.indicators, + ]: + try: + component.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + + # Destroy MouseCapture windows + for mc in [ + self.control_center, + self.notification_center, + self._pomodoro_instance, + self._todo_instance, + ]: + try: + if mc: + mc.destroy() + except Exception as e: + logger.error(f"An error occurred: {e}") + + super().destroy() diff --git a/src/window/panel/notch/__init__.py b/src/window/panel/notch/__init__.py new file mode 100644 index 00000000..1efcb277 --- /dev/null +++ b/src/window/panel/notch/__init__.py @@ -0,0 +1,8 @@ +from window.panel.notch.indicators import ( + CapsLockIndicator, + KeyboardLayoutIndicator, + NumLockIndicator, +) +from window.panel.notch.notch import Notch + +__all__ = ["CapsLockIndicator", "KeyboardLayoutIndicator", "Notch", "NumLockIndicator"] diff --git a/src/window/panel/notch/indicator_base.py b/src/window/panel/notch/indicator_base.py new file mode 100644 index 00000000..eb37b3f0 --- /dev/null +++ b/src/window/panel/notch/indicator_base.py @@ -0,0 +1,80 @@ +from fabric.utils import logger +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from utils.gtk_utils import svg_file +from utils.timer import ManagedTimer + +DISPLAY_MS = 2000 # how long each indicator stays visible (ms) + + +class BaseNotchIndicator(Box): + """Shared timer + layout scaffolding for notch indicators. + + Subclasses override :meth:`_on_activate` (called when the indicator + should become visible) and :meth:`_on_destroy` (optional extra cleanup). + """ + + def __init__( + self, + name: str, + icon_name: str, + label_text: str, + show_cb, + hide_cb, + *, + icon_size: int = 16, + icon_first: bool = True, + **kwargs, + ): + super().__init__( + name=name, + orientation="h", + h_expand=True, + v_align="center", + **kwargs, + ) + self._show_cb = show_cb + self._hide_cb = hide_cb + self._hide_timer = ManagedTimer() + + self._icon = svg_file( + icon_name, + size=icon_size, + name="notch-indicator-icon", + v_align="center", + ) + self._label = Label( + name="notch-indicator-label", + label=label_text, + v_align="center", + ) + + if icon_first: + start, end = self._icon, self._label + else: + start, end = self._label, self._icon + + self.add( + CenterBox( + orientation="h", + h_expand=True, + start_children=start, + end_children=end, + ) + ) + + def _schedule_hide(self): + self._hide_timer.schedule(DISPLAY_MS, self._hide_cb) + + def destroy(self): + self._hide_timer.cancel() + try: + self._on_destroy() + except Exception as e: + logger.warning(f"[NotchIndicators] {self.get_name()} destroy: {e}") + super().destroy() + + def _on_destroy(self): + """Override for extra signal disconnection.""" diff --git a/src/window/panel/notch/indicators.py b/src/window/panel/notch/indicators.py new file mode 100644 index 00000000..01df828d --- /dev/null +++ b/src/window/panel/notch/indicators.py @@ -0,0 +1,184 @@ +from fabric.audio import Audio +from fabric.utils import GLib, logger + +from services.battery import Battery +from services.capslock import CapsLock +from services.keyboard_layout import KeyboardLayout +from services.numlock import NumLock + +from .indicator_base import BaseNotchIndicator + + +class KeyboardLayoutIndicator(BaseNotchIndicator): + def __init__(self, show_cb, hide_cb, **kwargs): + self._kbd = KeyboardLayout.get_initial() + super().__init__( + name="notch-kbd-layout", + icon_name="notch/keyboard-layout.svg", + label_text=self._kbd.current_layout.upper(), + show_cb=show_cb, + hide_cb=hide_cb, + **kwargs, + ) + self._kbd.connect("layout_changed", self._on_layout_changed) + + def _on_layout_changed(self, _service, layout: str): + self._label.set_label(layout.upper()) + self._show_cb(self) + self._schedule_hide() + + def _on_destroy(self): + self._kbd.disconnect_by_func(self._on_layout_changed) + + +class CapsLockIndicator(BaseNotchIndicator): + def __init__(self, show_cb, hide_cb, **kwargs): + self._caps = CapsLock.get_initial() + super().__init__( + name="notch-capslock", + icon_name="notch/caps-lock.svg", + label_text="On" if self._caps.is_on else "Off", + show_cb=show_cb, + hide_cb=hide_cb, + **kwargs, + ) + self._caps.connect("state_changed", self._on_state_changed) + + def _on_state_changed(self, _service, is_on: bool): + self._label.set_label("On" if is_on else "Off") + self._show_cb(self) + self._schedule_hide() + + def _on_destroy(self): + self._caps.disconnect_by_func(self._on_state_changed) + + +class NumLockIndicator(BaseNotchIndicator): + def __init__(self, show_cb, hide_cb, **kwargs): + self._num = NumLock.get_initial() + super().__init__( + name="notch-numlock", + icon_name="notch/num-lock.svg", + label_text="On" if self._num.is_on else "Off", + show_cb=show_cb, + hide_cb=hide_cb, + **kwargs, + ) + self._num.connect("state_changed", self._on_state_changed) + + def _on_state_changed(self, _service, is_on: bool): + self._label.set_label("On" if is_on else "Off") + self._show_cb(self) + self._schedule_hide() + + def _on_destroy(self): + self._num.disconnect_by_func(self._on_state_changed) + + +class ChargingIndicator(BaseNotchIndicator): + def __init__(self, show_cb, hide_cb, **kwargs): + self._battery = Battery.get_initial() + super().__init__( + name="notch-charging", + icon_name="notch/charging.svg", + label_text="Charging", + show_cb=show_cb, + hide_cb=hide_cb, + icon_size=24, + icon_first=False, + **kwargs, + ) + self._battery.connect("changed", self._on_battery_changed) + GLib.timeout_add(500, self._initial_check) + + def _is_charger_connected(self) -> bool: + return self._battery.charging or self._battery.charged + + def _initial_check(self): + self._last_charger_state = self._is_charger_connected() + if self._last_charger_state: + self._show_cb(self) + self._schedule_hide() + return False # one-shot + + def _on_battery_changed(self, _battery): + connected = self._is_charger_connected() + if connected and not self._last_charger_state: + self._show_cb(self) + self._schedule_hide() + self._last_charger_state = connected + + def _on_destroy(self): + self._battery.disconnect_by_func(self._on_battery_changed) + + +class MicrophoneIndicator(BaseNotchIndicator): + def __init__(self, show_cb, hide_cb, **kwargs): + self._audio = Audio() + self._mic_muted = True + super().__init__( + name="notch-mic", + icon_name="notch/mic-off.svg", + label_text="Microphone", + show_cb=show_cb, + hide_cb=hide_cb, + icon_first=False, + **kwargs, + ) + self._audio.connect("notify::microphone", self._on_mic_device_changed) + if self._audio.microphone: + self._connect_mic_signals() + + def _connect_mic_signals(self): + mic = self._audio.microphone + if mic is None: + return + old = getattr(self, "_mic_device", None) + if old is not None and old is not mic: + try: + old.disconnect_by_func(self._on_mic_stream_changed) + except Exception as e: + logger.warning( + f"[indicators] old.disconnect_by_func(self._on_mic_stream_changed) failed: {e}" + ) + try: + mic.disconnect_by_func(self._on_mic_stream_changed) + except Exception as e: + logger.warning( + f"[indicators] mic.disconnect_by_func(self._on_mic_stream_changed) failed: {e}" + ) + mic.connect("changed", self._on_mic_stream_changed) + self._mic_device = mic + self._sync_state() + + def _on_mic_device_changed(self, *_): + self._connect_mic_signals() + self._show_cb(self) + self._schedule_hide() + + def _on_mic_stream_changed(self, *_): + self._sync_state() + self._show_cb(self) + self._schedule_hide() + + def _sync_state(self): + if self._audio.microphone: + self._mic_muted = self._audio.microphone.muted + self._icon.dynamic_file( + "notch/mic-off.svg" if self._mic_muted else "notch/mic-on.svg" + ) + + def _on_destroy(self): + try: + self._audio.disconnect_by_func(self._on_mic_device_changed) + except Exception as e: + logger.warning( + f"[indicators] self._audio.disconnect_by_func(self._on_mic_device_changed) failed: {e}" + ) + old = getattr(self, "_mic_device", None) + target = old if old is not None else self._audio.microphone + if target is not None: + try: + target.disconnect_by_func(self._on_mic_stream_changed) + except Exception as e: + logger.warning(f"[NotchIndicators] mic disconnect: {e}") diff --git a/src/window/panel/notch/notch.py b/src/window/panel/notch/notch.py new file mode 100644 index 00000000..34cd616c --- /dev/null +++ b/src/window/panel/notch/notch.py @@ -0,0 +1,292 @@ +from fabric.utils import Gdk, GLib, Gtk, logger +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.shapes import Corner +from fabric.widgets.stack import Stack + +from services.modus import screen_recorder_service +from window.controlcenter.player import get_shared_mpris_manager +from window.panel.notch.indicators import ( + CapsLockIndicator, + ChargingIndicator, + KeyboardLayoutIndicator, + MicrophoneIndicator, + NumLockIndicator, +) +from window.panel.notch.player import NotchPlayer +from window.panel.notch.recording import RecordingIndicator + + +class Notch(Box): + def __init__(self, **kwargs): + super().__init__( + name="panel-notch-wrap", + orientation="h", + spacing=0, + v_align="center", + h_align="center", + h_expand=False, + **kwargs, + ) + + self.recording_indicator = RecordingIndicator() + self.recording_indicator.set_hexpand(True) + self.recording_indicator.set_halign(Gtk.Align.FILL) + + self.player_widget = NotchPlayer() + self.player_widget.set_hexpand(True) + self.player_widget.set_halign(Gtk.Align.FILL) + + self.idle_widget = Box( + name="notch-idle", + h_expand=True, + ) + + self._last_scroll_index = 0 + self._last_scroll_time = 0 + self._last_recording_check = 0 + self._is_active_recording = False + self._music_services: list[str] = [] + # Must be set BEFORE indicator widgets are created + self._transient_active = False + + # Transient indicator pages — shown on change, hidden after 2 s + self.kbd_indicator = KeyboardLayoutIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + self.caps_indicator = CapsLockIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + self.num_indicator = NumLockIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + self.mic_indicator = MicrophoneIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + # Persistent indicator — now also transient (2s), deferred via idle_add + self.charging_indicator = ChargingIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + + self._mpris = get_shared_mpris_manager() + self._mpris.connect("new-player", self._on_new_player) + self._mpris.connect("player-vanish", self._on_player_vanish) + + screen_recorder_service.connect("started", self._on_recording_started) + screen_recorder_service.connect("stopped", self._on_recording_stopped) + + self.notch_stack = Stack( + name="panel-notch-stack", + v_expand=True, + h_expand=True, + transition_type="none", + children=[ + self.idle_widget, + self.recording_indicator, + self.player_widget, + self.kbd_indicator, + self.caps_indicator, + self.num_indicator, + self.mic_indicator, + self.charging_indicator, + ], + ) + self._last_stack_page = 0 + + self.left_corner = Box( + name="panel-notch-corner-left", + orientation="v", + h_align="start", + children=[ + Corner( + name="panel-notch-corner", + orientation="top-right", + size=20, + ) + ], + ) + + self.right_corner = Box( + name="panel-notch-corner-right", + orientation="v", + h_align="end", + children=[ + Corner( + name="panel-notch-corner", + orientation="top-left", + size=20, + ) + ], + ) + + self.notch_box = CenterBox( + name="panel-notch", + orientation="h", + h_align="center", + v_align="center", + start_children=self.left_corner, + center_children=self.notch_stack, + end_children=self.right_corner, + ) + + self.add(self.notch_box) + + self._init_state() + + self.notch_stack.connect("scroll-event", self._on_scroll) + + # ── Transient indicator show / hide ────────────────────────────────────── + + def _show_transient(self, widget): + """Make a transient indicator the visible notch page.""" + self._transient_active = True + if self.notch_stack.get_visible_child() != widget: + self.notch_stack.set_visible_child(widget) + + def _hide_transient(self): + """Return to normal notch state after the indicator timer expires.""" + self._transient_active = False + self._apply_stack_state() + + # ── Init / MPRIS / recording ────────────────────────────────────────────── + + def _init_state(self): + self._is_active_recording = screen_recorder_service.recording + if self._is_active_recording: + self.recording_indicator.start_timer() + + for name, svc in self._mpris.get_all_services().items(): + self._music_services.append(name) + svc.connect("artwork-change", self._on_any_artwork) + + self._apply_stack_state() + + def _on_new_player(self, _manager, name: str, service): + service.connect("artwork-change", self._on_any_artwork) + if name not in self._music_services: + self._music_services.append(name) + self._apply_stack_state() + + def _on_recording_started(self, service, path: str): + self._is_active_recording = True + self.recording_indicator.start_timer() + self._apply_stack_state() + + def _on_recording_stopped(self, service, path: str): + self._is_active_recording = False + self.recording_indicator.stop_timer() + self._apply_stack_state() + + def _on_any_artwork(self, service, local_path: str): + self.player_widget._on_artwork_change(service, local_path) + + def _on_player_vanish(self, _manager, name: str): + if name in self._music_services: + self._music_services.remove(name) + try: + svc = self._mpris.get_player_service(name) + if svc is not None: + svc.disconnect_by_func(self._on_any_artwork) + except Exception as e: + logger.warning( + f"[notch] svc = self._mpris.get_player_service(name) failed: {e}" + ) + self._apply_stack_state() + + # ── Scroll ──────────────────────────────────────────────────────────────── + + def _on_scroll(self, widget, event): + now = GLib.get_monotonic_time() + if now - self._last_scroll_time < 100_000: + return True + self._last_scroll_time = now + + # Cancel transient indicator so scroll can navigate + if self._transient_active: + self._transient_active = False + + dy = 0 + if event.direction == Gdk.ScrollDirection.UP: + dy = -1 + elif event.direction == Gdk.ScrollDirection.DOWN: + dy = 1 + elif event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y < -0.1: + dy = -1 + elif event.delta_y > 0.1: + dy = 1 + else: + return True + else: + return True + + self._last_scroll_index += dy + self._apply_stack_state() + return True + + # ── Stack state ─────────────────────────────────────────────────────────── + + def _apply_stack_state(self): + # Don't override a transient indicator that is still counting down + if self._transient_active: + return + + is_recording = self._is_active_recording + + now = GLib.get_monotonic_time() + if now - self._last_recording_check > 3_000_000: + self._last_recording_check = now + if self._is_active_recording and not screen_recorder_service.recording: + self._is_active_recording = False + is_recording = False + self.recording_indicator.stop_timer() + + has_music = len(self._music_services) > 0 + + if has_music: + name = self._music_services[0] + svc = self._mpris.get_player_service(name) + self.player_widget.set_service(svc) + else: + self.player_widget.set_service(None) + + active_pages = [] + if is_recording: + active_pages.append(1) + if has_music: + active_pages.append(2) + + if not active_pages: + if self.notch_stack.get_visible_child() != self.idle_widget: + self.notch_stack.set_visible_child(self.idle_widget) + return + + pages = { + 1: self.recording_indicator, + 2: self.player_widget, + } + + if len(active_pages) == 1: + target = active_pages[0] + else: + target = active_pages[self._last_scroll_index % len(active_pages)] + + desired = pages[target] + + if self.notch_stack.get_visible_child() != desired: + self.notch_stack.set_visible_child(desired) + + def destroy(self): + self.recording_indicator.destroy() + self.player_widget.destroy() + self.kbd_indicator.destroy() + self.caps_indicator.destroy() + self.num_indicator.destroy() + self.mic_indicator.destroy() + self.charging_indicator.destroy() + super().destroy() diff --git a/src/window/panel/notch/player.py b/src/window/panel/notch/player.py new file mode 100644 index 00000000..6f056b4a --- /dev/null +++ b/src/window/panel/notch/player.py @@ -0,0 +1,214 @@ +import math +import os +import time + +from fabric.utils import GdkPixbuf, GLib, Gtk, logger +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox + +from services.mpris import PlayerService +from shared.widgets.custom_image import CustomImage +from utils.gtk_utils import svg_file + +# tunables +_BARS = 8 # number of bars +_TICK_MS = 150 # ~7 fps — smooth with sine, very cheap +_H_MIN = 2 # minimum total bar height (px, both halves) +_H_MAX = 16 # maximum total bar height (px) +_BAR_W = 1 # bar pixel width +_SPACING = 4 # gap between bars + +# Per-bar sine speeds (rad/s) and phases — makes motion look organic +_SPEEDS = [2.1, 3.3, 1.8, 2.9, 3.7, 1.5, 2.6, 3.1, 1.9, 2.4] +_PHASES = [0.0, 1.2, 2.4, 0.8, 1.9, 3.1, 0.5, 2.0, 1.4, 0.3] + +_IDLE_HEIGHTS = [_H_MIN] * _BARS + + +class Visualizer(Box): + """ + Center-anchored bar visualizer with a magenta glow. + Bars grow symmetrically up AND down from the centre line, + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._t0 = time.monotonic() + self._heights = list(_IDLE_HEIGHTS) + + self.set_valign(Gtk.Align.CENTER) + self.set_vexpand(False) + + # Fixed widget size so all bars always fit + total_w = _BARS * _BAR_W + (_BARS - 1) * _SPACING + self.set_size_request(total_w, _H_MAX + 4) # +4 gives glow room + self.set_style("margin-right: 8px; margin-bottom: 2px;") + self.connect("draw", self._on_draw) + + def animate(self) -> None: + t = time.monotonic() - self._t0 + new = [ + int( + _H_MIN + + (math.sin(t * _SPEEDS[i] + _PHASES[i]) + 1) * 0.5 * (_H_MAX - _H_MIN) + ) + for i in range(_BARS) + ] + if new != self._heights: + self._heights = new + self.queue_draw() + + def reset(self) -> None: + if self._heights != _IDLE_HEIGHTS: + self._heights = list(_IDLE_HEIGHTS) + self.queue_draw() + + def _on_draw(self, widget, cr) -> None: + w = self.get_allocated_width() + h = self.get_allocated_height() + if w < 3 or h < 4: + return + + ctx = self.get_style_context() + found, color = ctx.lookup_color("primary") + if found: + r, g, b = color.red, color.green, color.blue + else: + r, g, b = 1.0, 1.0, 1.0 # fallback: white + + cy = h / 2.0 # vertical centre line + + for i, bh in enumerate(self._heights): + x = i * (_BAR_W + _SPACING) + half = bh / 2.0 + top = cy - half + + cr.set_source_rgba(r, g, b, 0.22) + cr.rectangle(x - 2, top - 2, _BAR_W + 4, bh + 4) + cr.fill() + + cr.set_source_rgb(r, g, b) + cr.rectangle(x, top, _BAR_W, bh) + cr.fill() + + +class NotchPlayer(Box): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._service: PlayerService | None = None + self._timer: int | None = None + self._sigs: list[int] = [] + + self.fallback_icon = svg_file("music.svg", size=18) + + self.artwork_image = CustomImage() + self.artwork_image.set_size_request(18, 18) + self.artwork_image.set_no_show_all(True) + self.artwork_image.set_visible(False) + + self.album_art = Box( + name="notch-player-art", + children=[self.fallback_icon, self.artwork_image], + ) + + self.visualizer = Visualizer() + + self.player_box = CenterBox( + orientation="h", + h_expand=True, + start_children=self.album_art, + end_children=self.visualizer, + ) + self.add(self.player_box) + + def set_service(self, service: PlayerService | None) -> None: + if self._service is service: + return + + self._disconnect_signals() + self._service = service + self._stop_timer() + self._show_fallback() + + if service is None: + return + + self._update_from_service() + self._sigs.append(service.connect("play", self._on_play)) + self._sigs.append(service.connect("pause", self._on_pause)) + self._sigs.append(service.connect("artwork-change", self._on_artwork_change)) + + if service.playback_status == "Playing": + self._start_timer() + + def _disconnect_signals(self) -> None: + if self._service is not None: + for sid in self._sigs: + try: + self._service.disconnect(sid) + except Exception as e: + logger.warning( + f"[player] self._service.disconnect(sid) failed: {e}" + ) + self._sigs.clear() + + def _on_play(self, *_) -> None: + self._start_timer() + + def _on_pause(self, *_) -> None: + self._stop_timer() + + def _start_timer(self) -> None: + if self._timer is not None: + return + self._timer = GLib.timeout_add(_TICK_MS, self._tick) + + def _stop_timer(self) -> None: + if self._timer is not None: + GLib.source_remove(self._timer) + self._timer = None + self.visualizer.reset() + + def _tick(self) -> bool: + if self._service is None: + return False + self.visualizer.animate() + return True + + def _show_fallback(self) -> None: + self.artwork_image.set_visible(False) + self.fallback_icon.set_visible(True) + + def _show_artwork(self, path: str) -> None: + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + path, 18, 18, preserve_aspect_ratio=False + ) + self.artwork_image.set_from_pixbuf(pixbuf) + self.fallback_icon.set_visible(False) + self.artwork_image.set_visible(True) + except Exception as e: + logger.warning( + f"[player] pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( path, 1... failed: {e}" + ) + self._show_fallback() + + def _update_from_service(self) -> None: + if self._service is None: + return + cached = self._service.get_artwork() + if cached and os.path.exists(cached): + self._show_artwork(cached) + else: + self._show_fallback() + + def _on_artwork_change(self, service, local_path: str) -> None: + if os.path.exists(local_path): + self._show_artwork(local_path) + + def destroy(self) -> None: + self._disconnect_signals() + self._stop_timer() + self._service = None + super().destroy() diff --git a/src/window/panel/notch/recording.py b/src/window/panel/notch/recording.py new file mode 100644 index 00000000..53716af8 --- /dev/null +++ b/src/window/panel/notch/recording.py @@ -0,0 +1,81 @@ +from fabric.utils import GLib, logger, time +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from services.modus import screen_recorder_service +from utils.gtk_utils import setup_cursor_hover, svg_file + + +class RecordingIndicator(Box): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.recording_start_time = None + self.timer_timeout_id = None + + self.recording_icon = svg_file("notch/media-record.svg", size=24) + + self.recording_button = Button( + name="panel-button", + child=self.recording_icon, + ) + self.recording_button.connect("clicked", self.on_stop_recording) + setup_cursor_hover(self.recording_button, "pointer") + + self.time_label = Label( + name="notch-recording-time", + markup="00:00", + max_width_chars=5, + ellipsize="none", + ) + + self.recording_box = CenterBox( + orientation="h", + h_expand=True, + start_children=self.recording_button, + end_children=self.time_label, + ) + + self.add(self.recording_box) + + def start_timer(self): + if self.timer_timeout_id is not None: + return + + self.recording_start_time = time.time() + self._update_display() + self.timer_timeout_id = GLib.timeout_add(1000, self._update_display) + + def stop_timer(self): + self.recording_start_time = None + + if self.timer_timeout_id is not None: + GLib.source_remove(self.timer_timeout_id) + self.timer_timeout_id = None + + def _update_display(self): + if self.recording_start_time is None: + return False + + elapsed_seconds = int(time.time() - self.recording_start_time) + minutes = elapsed_seconds // 60 + seconds = elapsed_seconds % 60 + + self.time_label.set_markup(f"{minutes:02d}:{seconds:02d}") + self.set_tooltip_text( + f"Recording in progress ({minutes:02d}:{seconds:02d}) - Click to stop" + ) + return True + + def on_stop_recording(self, *args): + try: + screen_recorder_service.stop() + self.stop_timer() + except Exception as e: + logger.warning(f"[recording] screen_recorder_service.stop() failed: {e}") + + def destroy(self): + self.stop_timer() + super().destroy() diff --git a/src/window/screencapture/__init__.py b/src/window/screencapture/__init__.py new file mode 100644 index 00000000..235742d7 --- /dev/null +++ b/src/window/screencapture/__init__.py @@ -0,0 +1,3 @@ +from window.screencapture.main import ScreenCaptureWindow + +__all__ = ["ScreenCaptureWindow"] diff --git a/src/window/screencapture/main.py b/src/window/screencapture/main.py new file mode 100644 index 00000000..aebabea0 --- /dev/null +++ b/src/window/screencapture/main.py @@ -0,0 +1,358 @@ +from fabric.utils import Gdk, GLib, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +from services.modus import screen_recorder_service, screenshot_service +from window.screencapture.options_menu import OptionsMenu +from window.screencapture.tool_button import ToolButton, ToolGroup + + +class ScreenCaptureWindow(Window): + _TOOLS = [ + ("region.svg", "Screenshot Region", "screenshot-region"), + ("monitor.svg", "Screenshot Screen", "screenshot-screen"), + ("window.svg", "Screenshot Window", "screenshot-window"), + ("region-video.svg", "Record Region", "record-region"), + ("monitor-video.svg", "Record Screen", "record-screen"), + ("window-video.svg", "Record Window", "record-window"), + ] + + def __init__(self, **kwargs): + super().__init__( + name="sc-window", + title="modus-screencapture", + anchor="bottom", + layer="top", + keyboard_mode="exclusive", + margin="0px 0px 120px 0px", + visible=False, + **kwargs, + ) + + self._tool_group = ToolGroup() + self._options_menu: OptionsMenu | None = None + self._delay_timeout_id: int | None = None + + close_lbl = Label(label="✕") + close_btn = Button(name="sc-close-btn", child=close_lbl) + close_btn.connect("clicked", lambda *_: self.hide()) + + tool_boxes = [] + for i, (icon, tip, tid) in enumerate(self._TOOLS): + if i == 3: + tool_boxes.append(Box(name="sc-separator")) + btn = ToolButton( + icon=icon, tooltip=tip, tool_id=tid, group=self._tool_group + ) + self._tool_group.add(btn) + tool_boxes.append(btn) + + self._tool_bar = Box( + name="sc-tool-bar", + orientation="h", + spacing=2, + children=tool_boxes, + ) + + # Options button — opens a Gtk.Menu + self._options_btn = Button(name="sc-options-btn") + opt_inner = Box( + orientation="h", + spacing=2, + children=[ + Label(name="sc-options-label", label="Options"), + Label(name="sc-options-arrow", label="⌄"), + ], + ) + self._options_btn.add(opt_inner) + self._options_btn.connect("clicked", self._toggle_options) + + # Action button + self._action_btn = Button(name="sc-action-btn", label="Capture") + self._action_btn.connect("clicked", self._on_action) + + # Assemble toolbar + toolbar = Box( + name="sc-toolbar", + orientation="h", + spacing=0, + children=[ + close_btn, + self._tool_bar, + self._options_btn, + self._action_btn, + ], + ) + + # Wrap in a transparent container to prevent solid square background issues + container = Box( + name="sc-container", + orientation="v", + h_align="center", + v_align="center", + expand=True, + children=[toolbar], + ) + + self.add(container) + + self._tool_group.select_id("screenshot-screen") + self._tool_group.on_change(self._on_tool_changed) + + screen_recorder_service.connect("started", self._on_recording_started) + screen_recorder_service.connect("stopped", self._on_recording_stopped) + + self.connect("key-press-event", self._on_key_press) + + def do_focus(self, direction): + """ + Disable GTK's built-in Tab/Shift+Tab focus traversal so those keys + reach our key-press-event handler instead of moving widget focus. + """ + return False + + def _on_key_press(self, _, event: Gdk.EventKey) -> bool: + kv = event.keyval + mods = event.state + + # Escape — close window + if kv == Gdk.KEY_Escape: + self.hide() + return True + + # Enter / Space — trigger action + if kv in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space): + self._on_action() + return True + + # Left / h — previous tool (clamp) + if kv in (Gdk.KEY_Left, Gdk.KEY_h): + idx = self._tool_group.current_index() + self._tool_group.select_index(max(0, idx - 1)) + return True + + # Right / l — next tool (clamp) + if kv in (Gdk.KEY_Right, Gdk.KEY_l): + idx = self._tool_group.current_index() + self._tool_group.select_index(min(len(self._TOOLS) - 1, idx + 1)) + return True + + # Shift+Tab — previous tool (wrap) + if kv in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab) and ( + mods & Gdk.ModifierType.SHIFT_MASK + ): + idx = self._tool_group.current_index() + self._tool_group.select_index((idx - 1) % len(self._TOOLS)) + return True + + # Tab — next tool (wrap) + if kv == Gdk.KEY_Tab: + idx = self._tool_group.current_index() + self._tool_group.select_index((idx + 1) % len(self._TOOLS)) + return True + + # 1–6 — jump directly to tool + if Gdk.KEY_1 <= kv <= Gdk.KEY_6: + self._tool_group.select_index(kv - Gdk.KEY_1) + return True + + return False + + def _toggle_options(self, *_): + if self._options_menu is None: + self._options_menu = OptionsMenu() + self._options_menu.popup_below(self._options_btn) + + def _on_tool_changed(self, tool_id: str): + is_recording_tool = tool_id.startswith("record-") + is_active = screen_recorder_service.recording + + ctx = self._action_btn.get_style_context() + if is_active: + self._action_btn.set_label("Stop") + ctx.add_class("recording") + elif is_recording_tool: + self._action_btn.set_label("Record") + ctx.remove_class("recording") + else: + self._action_btn.set_label("Capture") + ctx.remove_class("recording") + + def _on_action(self, *_): + # Stop an active recording + if screen_recorder_service.recording: + screen_recorder_service.stop() + return + + delay = 0 + if self._options_menu: + delay = self._options_menu.get_timer_delay() + + self.hide() + + if delay > 0: + self._delay_timeout_id = GLib.timeout_add_seconds(delay, self._do_action) + else: + GLib.timeout_add(80, self._do_action) + + def _do_action(self): + tool = self._tool_group.selected_id + self._delay_timeout_id = None + + # Gather options once + use_audio = ( + self._options_menu.get_mic_enabled() if self._options_menu else False + ) + show_cursor = ( + self._options_menu.get_show_cursor() if self._options_menu else False + ) + + if tool == "screenshot-screen": + self._do_screenshot("active") + elif tool == "screenshot-window": + self._do_screenshot("window") # hyprshot -m window + elif tool == "screenshot-region": + self._do_screenshot("region") + elif tool == "record-screen": + screen_recorder_service.start( + "active", use_audio=use_audio, show_cursor=show_cursor + ) + elif tool == "record-window": + screen_recorder_service.start( + "window", use_audio=use_audio, show_cursor=show_cursor + ) + elif tool == "record-region": + screen_recorder_service.start( + "selection", use_audio=use_audio, show_cursor=show_cursor + ) + + return False + + def _do_screenshot(self, target: str): + """ + Take a screenshot. + - On success: service emits captured(path) → we stay hidden. + - On cancel: service emits failed(reason) → we re-show. + - If screenshot() itself fails immediately → we re-show. + The handlers are always disconnected after one fire to avoid leaks. + """ + # Gather options + output_dir = None + show_cursor = False + if self._options_menu: + output_dir = self._options_menu.get_save_dir() + show_cursor = self._options_menu.get_show_cursor() + + _handler_ids = [None, None] + + def _on_done(svc, *_args): + for hid in _handler_ids: + if hid is not None: + try: + svc.disconnect(hid) + except Exception as e: + logger.warning(f"[main] disconnect failed: {e}") + _handler_ids[0] = _handler_ids[1] = None + + def _on_captured(svc, path, *_): + _on_done(svc) + # success — stay hidden + + def _on_failed(svc, _reason, *_): + _on_done(svc) + GLib.timeout_add(120, self.show) + + _handler_ids[0] = screenshot_service.connect("captured", _on_captured) + _handler_ids[1] = screenshot_service.connect("failed", _on_failed) + + def _safe_disconnect(): + if any(hid is not None for hid in _handler_ids): + for hid in _handler_ids: + if hid is not None: + try: + screenshot_service.disconnect(hid) + except Exception as e: + logger.warning(f"[main] safe disconnect failed: {e}") + _handler_ids[0] = _handler_ids[1] = None + return False + + # Safety net: disconnect if the service never emits (e.g. capture failed + # silently), otherwise the one-shot handler would leak on every shot. + GLib.timeout_add_seconds(10, _safe_disconnect) + + ok = screenshot_service.screenshot( + target, + output_dir=output_dir, + show_cursor=show_cursor, + ) + if not ok: + # screenshot() returned False — clean up and re-show immediately + if any(hid is not None for hid in _handler_ids): + for hid in _handler_ids: + if hid is not None: + try: + screenshot_service.disconnect(hid) + except Exception as e: + logger.warning(f"[main] disconnect failed: {e}") + _handler_ids[0] = _handler_ids[1] = None + GLib.timeout_add(120, self.show) + + def _on_recording_started(self, *_): + self._action_btn.set_label("Stop") + self._action_btn.get_style_context().add_class("recording") + self.hide() + + def _on_recording_stopped(self, *_): + tool = self._tool_group.selected_id or "" + ctx = self._action_btn.get_style_context() + self._action_btn.set_label( + "Record" if tool.startswith("record-") else "Capture" + ) + ctx.remove_class("recording") + + def toggle(self, ss: str | None = None, sr: str | None = None): + """ + Toggle the window, or trigger a direct action via CLI. + :param ss: screenshot mode (e.g. 'region', 'fullscreen', 'selectwindow') + :param sr: record mode (e.g. 'region', 'fullscreen', 'selectwindow') + """ + if ss: + target_map = { + "region": "region", + "fullscreen": "active", + "selectwindow": "window", + } + self.hide() + target = target_map.get(ss, ss) + screenshot_service.screenshot(target) + return + + if sr: + if screen_recorder_service.recording: + screen_recorder_service.stop() + return + + target_map = { + "region": "selection", + "fullscreen": "active", + "selectwindow": "window", + } + use_audio = ( + self._options_menu.get_mic_enabled() if self._options_menu else False + ) + show_cursor = ( + self._options_menu.get_show_cursor() if self._options_menu else False + ) + screen_recorder_service.start( + target_map.get(sr, sr), + use_audio=use_audio, + show_cursor=show_cursor, + ) + return + + if self.get_visible(): + self.hide() + else: + self.show() diff --git a/src/window/screencapture/options_menu.py b/src/window/screencapture/options_menu.py new file mode 100644 index 00000000..9438964f --- /dev/null +++ b/src/window/screencapture/options_menu.py @@ -0,0 +1,238 @@ +from fabric.utils import Gdk, Gtk + +from shared.widgets.smooth_switch import SmoothSwitch + + +class SwitchMenuItem(Gtk.MenuItem): + """ + A Gtk.MenuItem that contains a label + SmoothSwitch side by side. + Clicking the row toggles the switch. + """ + + def __init__(self, label: str, active: bool = True): + super().__init__() + self.set_name("sc-menu-item") + + self._switch = SmoothSwitch( + active=active, + width=34, + height=18, + style_classes=["sc-switch"], + v_align="center", + ) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + row.set_hexpand(True) + + lbl = Gtk.Label(label=label) + lbl.set_halign(Gtk.Align.START) + lbl.set_hexpand(True) + row.pack_start(lbl, True, True, 0) + row.pack_end(self._switch, False, False, 0) + row.show_all() + + self.add(row) + self.connect("activate", self._on_activate) + + def _on_activate(self, *_): + # Toggle the switch when the menu item is clicked + self._switch._on_click(self._switch, type("e", (), {"button": 1})()) + + def get_active(self) -> bool: + return self._switch.get_active() + + def set_active(self, value: bool): + self._switch.set_active(value) + + +class OptionsMenu: + """ + Gtk.Menu that pops up below the Options button. + Contains: Save to, Timer, Microphone, and toggle options. + State is preserved across opens. + """ + + _TIMER_LABELS = ["None", "5 seconds", "10 seconds"] + _MIC_LABELS = ["None", "Built-in Microphone"] + + def __init__(self): + self._timer_idx = 0 + self._mic_idx = 0 + self._menu = Gtk.Menu() + self._menu.set_name("sc-menu") + + self._add_section("Save to") + self._save_path = "~/Pictures/Screenshots" + save_item = Gtk.MenuItem() + save_item.set_name("sc-menu-item") + save_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._save_lbl = Gtk.Label(label=self._save_path) + self._save_lbl.set_halign(Gtk.Align.START) + self._save_lbl.set_hexpand(True) + browse_lbl = Gtk.Label(label="Other…") + browse_lbl.get_style_context().add_class("sc-menu-hint") + save_box.pack_start(self._save_lbl, True, True, 0) + save_box.pack_end(browse_lbl, False, False, 0) + save_box.show_all() + save_item.add(save_box) + save_item.connect("activate", self._choose_save_dir) + self._menu.append(save_item) + + self._add_separator() + + self._timer_hint_lbl = Gtk.Label(label=self._TIMER_LABELS[0]) + self._timer_hint_lbl.get_style_context().add_class("sc-menu-hint") + timer_parent = self._make_submenu_item("Timer", self._timer_hint_lbl) + timer_sub = Gtk.Menu() + timer_sub.set_name("sc-menu") + self._timer_items: list[Gtk.CheckMenuItem] = [] + for i, lbl in enumerate(self._TIMER_LABELS): + item = Gtk.CheckMenuItem(label=lbl) + item.set_name("sc-menu-item") + item.set_draw_as_radio(True) + item.set_active(i == 0) + item.connect("activate", self._on_timer_select, i) + timer_sub.append(item) + self._timer_items.append(item) + timer_sub.show_all() + timer_parent.set_submenu(timer_sub) + self._menu.append(timer_parent) + + self._add_separator() + + self._mic_hint_lbl = Gtk.Label(label=self._MIC_LABELS[0]) + self._mic_hint_lbl.get_style_context().add_class("sc-menu-hint") + mic_parent = self._make_submenu_item("Microphone", self._mic_hint_lbl) + mic_sub = Gtk.Menu() + mic_sub.set_name("sc-menu") + self._mic_items: list[Gtk.CheckMenuItem] = [] + for i, lbl in enumerate(self._MIC_LABELS): + item = Gtk.CheckMenuItem(label=lbl) + item.set_name("sc-menu-item") + item.set_draw_as_radio(True) + item.set_active(i == 0) + item.connect("activate", self._on_mic_select, i) + mic_sub.append(item) + self._mic_items.append(item) + mic_sub.show_all() + mic_parent.set_submenu(mic_sub) + self._menu.append(mic_parent) + + self._add_separator() + + self._add_section("Options") + self._floating_thumb = SwitchMenuItem("Show Floating Thumbnail", active=True) + self._remember_sel = SwitchMenuItem("Remember Last Selection", active=True) + self._show_mouse = SwitchMenuItem("Show Mouse Pointer", active=True) + self._menu.append(self._floating_thumb) + self._menu.append(self._remember_sel) + self._menu.append(self._show_mouse) + + self._menu.show_all() + + def _make_submenu_item(self, label: str, hint_lbl: Gtk.Label) -> Gtk.MenuItem: + """ + Create a menu item that has a label on the left and a dim hint on the right. + The hint shows the currently selected sub-option. + """ + item = Gtk.MenuItem() + item.set_name("sc-menu-item") + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + row.set_hexpand(True) + + title = Gtk.Label(label=label) + title.set_halign(Gtk.Align.START) + title.set_hexpand(True) + + hint_lbl.set_halign(Gtk.Align.END) + hint_lbl.show() + + row.pack_start(title, True, True, 0) + row.pack_end(hint_lbl, False, False, 0) + row.show_all() + item.add(row) + return item + + def _add_section(self, text: str): + item = Gtk.MenuItem() + item.set_name("sc-menu-item") + item.set_sensitive(False) + lbl = Gtk.Label(label=text.upper()) + lbl.set_name("sc-menu-section") + lbl.set_halign(Gtk.Align.START) + lbl.show() + item.add(lbl) + self._menu.append(item) + + def _add_separator(self): + sep = Gtk.SeparatorMenuItem() + sep.set_name("sc-menu-sep") + self._menu.append(sep) + + def _on_timer_select(self, item, idx: int): + if not item.get_active(): + return + self._timer_idx = idx + self._timer_hint_lbl.set_label(self._TIMER_LABELS[idx]) + for i, t in enumerate(self._timer_items): + if i != idx: + t.set_active(False) + + def _on_mic_select(self, item, idx: int): + if not item.get_active(): + return + self._mic_idx = idx + self._mic_hint_lbl.set_label(self._MIC_LABELS[idx]) + for i, m in enumerate(self._mic_items): + if i != idx: + m.set_active(False) + + def _choose_save_dir(self, *_): + dialog = Gtk.FileChooserDialog( + title="Choose Save Location", + action=Gtk.FileChooserAction.SELECT_FOLDER, + ) + dialog.add_buttons( + "_Cancel", + Gtk.ResponseType.CANCEL, + "_Select", + Gtk.ResponseType.ACCEPT, + ) + resp = dialog.run() + if resp == Gtk.ResponseType.ACCEPT: + self._save_path = dialog.get_filename() + self._save_lbl.set_label(self._save_path) + dialog.destroy() + + def popup_below(self, widget: Gtk.Widget): + """Pop the menu up, positioned below the given widget.""" + self._menu.popup_at_widget( + widget, + Gdk.Gravity.SOUTH_WEST, + Gdk.Gravity.NORTH_WEST, + None, + ) + + def get_timer_delay(self) -> int: + return [0, 5, 10][self._timer_idx] + + def get_mic_enabled(self) -> bool: + """True when a microphone source (not None) is selected.""" + return self._mic_idx != 0 + + def get_save_dir(self) -> str | None: + """ + Returns the custom save directory if the user changed it, + or None to use the service default. + """ + default = "~/Pictures/Screenshots" + return self._save_path if self._save_path != default else None + + def get_show_cursor(self) -> bool: + return self._show_mouse.get_active() + + def get_show_thumbnail(self) -> bool: + return self._floating_thumb.get_active() + + def get_remember_selection(self) -> bool: + return self._remember_sel.get_active() diff --git a/src/window/screencapture/tool_button.py b/src/window/screencapture/tool_button.py new file mode 100644 index 00000000..380c0e10 --- /dev/null +++ b/src/window/screencapture/tool_button.py @@ -0,0 +1,75 @@ +from fabric.widgets.button import Button + +from utils.gtk_utils import svg_file + + +class ToolButton(Button): + """A toggle button representing one capture/record mode.""" + + def __init__(self, icon: str, tooltip: str, tool_id: str, group, **kwargs): + self.tool_id = tool_id + self._group = group + + img = svg_file(f"screencapture/{icon}", size=20) + + super().__init__( + name="sc-tool-btn", + tooltip_text=tooltip, + child=img, + **kwargs, + ) + self.connect("clicked", self._on_clicked) + + def _on_clicked(self, *_): + self._group.select(self) + + def set_active(self, active: bool): + ctx = self.get_style_context() + if active: + ctx.add_class("active") + else: + ctx.remove_class("active") + + +class ToolGroup: + """Manages exclusive selection among ToolButtons.""" + + def __init__(self): + self._buttons: list[ToolButton] = [] + self._selected: ToolButton | None = None + self._callbacks: list = [] + + def add(self, btn: ToolButton): + self._buttons.append(btn) + + def select(self, btn: ToolButton): + if self._selected is btn: + return + if self._selected: + self._selected.set_active(False) + self._selected = btn + btn.set_active(True) + for cb in self._callbacks: + cb(btn.tool_id) + + def select_index(self, idx: int): + if 0 <= idx < len(self._buttons): + self.select(self._buttons[idx]) + + def select_id(self, tool_id: str): + for btn in self._buttons: + if btn.tool_id == tool_id: + self.select(btn) + return + + def current_index(self) -> int: + if self._selected and self._selected in self._buttons: + return self._buttons.index(self._selected) + return 0 + + def on_change(self, cb): + self._callbacks.append(cb) + + @property + def selected_id(self) -> str | None: + return self._selected.tool_id if self._selected else None diff --git a/src/window/settings/__init__.py b/src/window/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/settings/main.py b/src/window/settings/main.py new file mode 100644 index 00000000..59433aa0 --- /dev/null +++ b/src/window/settings/main.py @@ -0,0 +1,527 @@ +from fabric.utils import Gdk, GLib, Gtk, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.stack import Stack + +from services.config import config, on_config_change +from shared.widgets.smooth_switch import SmoothSwitch +from utils.gtk_utils import setup_cursor_hover, svg_file + + +class SettingsRow(CenterBox): + def __init__( + self, label: str, child=None, description: str | None = None, **kwargs + ): + label_widget = Label(label=label, name="settings-row-label", h_align="start") + + start_children = [label_widget] + if description: + description_widget = Label( + label=description, name="settings-row-description", h_align="start" + ) + start_children = [ + Box( + orientation="v", + spacing=2, + children=[label_widget, description_widget], + ) + ] + + super().__init__( + name="settings-row", + start_children=start_children, + end_children=[child] if child else [], + **kwargs, + ) + self.label_widget = label_widget + if description: + self.description_widget = description_widget + + +class SettingsSwitch(SmoothSwitch): + def __init__(self, config_key: str, **kwargs): + self.config_key = config_key + initial_active = config().get(config_key, False) + + super().__init__( + name="settings-switch", + active=initial_active, + on_user_toggle=self.toggle, + **kwargs, + ) + setup_cursor_hover(self, "pointer") + + # Sync with external config changes + on_config_change(self._on_config_change) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + self.set_active(new_config.get(self.config_key, False)) + + def toggle(self, state: bool): + config().set(self.config_key, state) + config().save() + + +class SettingsEntry(Entry): + def __init__(self, config_key: str, **kwargs): + self.config_key = config_key + initial_value = str(config().get(config_key, "")) + + super().__init__( + name="settings-entry", + text=initial_value, + on_activate=self.on_change, + **kwargs, + ) + + # Sync with external config changes + on_config_change(self._on_config_change) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + new_val = str(new_config.get(self.config_key, "")) + if self.get_text() != new_val: + self.set_text(new_val) + + def on_change(self, entry, *args): + value = entry.get_text() + # Try to convert to int if possible + try: + if value.isdigit(): + value = int(value) + except Exception as e: + logger.error(f"An error occurred: {e}") + + config().set(self.config_key, value) + config().save() + + +class SettingsComboBox(Gtk.ComboBoxText): + def __init__(self, config_key: str, options: list, **kwargs): + super().__init__(**kwargs) + self.set_name("settings-combo") + self.set_halign(Gtk.Align.END) + self.config_key = config_key + self.options = options + + for opt in options: + self.append_text(opt) + + self._update_from_config() + self.connect("changed", self.on_change) + + # Sync with external config changes + on_config_change(self._on_config_change) + + def _update_from_config(self): + current_val = str(config().get(self.config_key, "")) + if current_val in self.options: + self.set_active(self.options.index(current_val)) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + self._update_from_config() + + def on_change(self, combo): + value = combo.get_active_text() + if value: + config().set(self.config_key, value) + config().save() + + +class SettingsList(Box): + """A list of tags that can be added or removed""" + + def __init__(self, config_key: str, **kwargs): + super().__init__(name="settings-list", orientation="v", spacing=5, **kwargs) + self.config_key = config_key + + self.list_box = Box(spacing=5, name="settings-list-tags") + self.entry = Entry( + name="settings-list-entry", + placeholder="Add new item...", + on_activate=self.on_add, + ) + + self.add(self.list_box) + self.add(self.entry) + + self._update_list() + on_config_change(self._on_config_change) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + self._update_list() + + def _update_list(self): + items = config().get(self.config_key, []) + if not isinstance(items, list): + items = [] + + # Clear existing tags + self.list_box.children = [] + + for item in items: + tag = Button( + name="settings-list-tag", + child=Box( + spacing=5, + children=[ + Label(label=item), + Label(label="✕", name="settings-list-tag-close"), + ], + ), + on_clicked=lambda *_, i=item: self.on_remove(i), + ) + setup_cursor_hover(tag, "pointer") + self.list_box.pack_start(tag, False, False, 0) + + self.list_box.show_all() + + def on_add(self, entry): + val = entry.get_text().strip() + if not val: + return + + items = config().get(self.config_key, []) + if val not in items: + items.append(val) + config().set(self.config_key, items) + config().save() + + entry.set_text("") + self._update_list() + + def on_remove(self, item): + items = config().get(self.config_key, []) + if item in items: + items.remove(item) + config().set(self.config_key, items) + config().save() + self._update_list() + + +class SettingsPage(ScrolledWindow): + def __init__(self, title: str, rows: list, **kwargs): + container = Box( + name="settings-page-container", + orientation="v", + spacing=10, + h_expand=True, + v_expand=True, + children=[ + Label(label=title, name="settings-page-title", h_align="start"), + Box(orientation="v", spacing=1, children=rows, h_expand=True), + ], + ) + super().__init__( + name="settings-page", + h_expand=True, + v_expand=True, + propagate_width=True, + propagate_height=False, + min_content_width=600, + **kwargs, + ) + self.add(container) + self.show_all() + self._container = container + + +class SettingsWindow(Gtk.Window): + def __init__(self, **kwargs): + GLib.set_prgname("modus-settings") + super().__init__(title="Modus Settings", **kwargs) + self.set_name("settings-window") + self.set_title("modus-settings") + self.set_wmclass("modus-settings", "Modus") + self.set_default_size(850, 600) + self.set_resizable(False) + self.set_type_hint(Gtk.Window.get_type_hint(self) | Gdk.WindowTypeHint.DIALOG) + self.set_position(Gtk.WindowPosition.CENTER) + self.set_visible(False) + + # Reset global singleton on destroy + self.connect("destroy", self._on_window_destroy) + + self.stack = Stack( + name="settings-stack", + transition_type="none", + h_expand=True, + v_expand=True, + ) + + self.pages = { + "general": self._create_general_page(), + "dock": self._create_dock_page(), + "panel": self._create_panel_page(), + "notifications": self._create_notifications_page(), + } + + for name, page in self.pages.items(): + self.stack.add_named(page, name) + + self.sidebar_buttons = {} + self.sidebar_buttons["general"] = self._create_sidebar_button( + "General", "general", "misc/logo.svg" + ) + self.sidebar_buttons["dock"] = self._create_sidebar_button( + "Dock", "dock", "misc/control.svg" + ) + self.sidebar_buttons["panel"] = self._create_sidebar_button( + "Panel", "panel", "misc/control-center.svg" + ) + self.sidebar_buttons["notifications"] = self._create_sidebar_button( + "Notifications", "notifications", "notifications/notification-active.svg" + ) + + self.set_page("general") + + self.sidebar = Box( + name="settings-sidebar", + orientation="v", + spacing=5, + children=list(self.sidebar_buttons.values()), + ) + + self.main_box = Box( + name="settings-main-box", + orientation="h", + h_expand=True, + v_expand=True, + children=[ + self.sidebar, + Box( + name="settings-content-wrapper", + children=[self.stack], + h_expand=True, + v_expand=True, + ), + ], + ) + + self.add(self.main_box) + + def _create_sidebar_button(self, label, page_name, icon_name): + btn = Button( + name="settings-sidebar-button", + child=Box( + orientation="h", + spacing=10, + children=[svg_file(icon_name, size=16), Label(label=label)], + ), + on_clicked=lambda *_: self.set_page(page_name), + ) + setup_cursor_hover(btn, "pointer") + return btn + + def set_page(self, page_name): + if page_name not in self.pages: + return + self.stack.set_visible_child(self.pages[page_name]) + for name, btn in self.sidebar_buttons.items(): + if name == page_name: + btn.add_style_class("active") + else: + btn.remove_style_class("active") + + def _create_general_page(self): + return SettingsPage( + "General Settings", + [ + SettingsRow( + "Weather Location", + SettingsEntry("weather_location"), + "City name for weather updates", + ), + SettingsRow( + "Keyboard Layouts", + SettingsList("keyboard_layouts"), + "Manage active keyboard input languages", + ), + SettingsRow( + "Hide Special Workspace", + SettingsSwitch("hide_special_workspace"), + "Don't show special workspace in indicators", + ), + SettingsRow( + "Night Light Temperature", + SettingsEntry("night_light_temperature"), + "Color temperature in Kelvin (e.g. 4500)", + ), + SettingsRow( + "Live Preview", + SettingsSwitch("switcher_live_preview"), + "Show live window previews in switcher", + ), + SettingsRow( + "Preview Delay (ms)", + SettingsEntry("switcher_live_preview_delay_ms"), + "Delay before live preview appears", + ), + ], + ) + + def _create_dock_page(self): + return SettingsPage( + "Dock Settings", + [ + SettingsRow( + "Enabled", + SettingsSwitch("dock_enabled"), + "Show the application dock", + ), + SettingsRow( + "Auto Hide", + SettingsSwitch("dock_auto_hide"), + "Hide dock when not in use", + ), + SettingsRow( + "Always Occluded", + SettingsSwitch("dock_always_occluded"), + "Keep dock behind other windows", + ), + SettingsRow( + "Icon Size", + SettingsEntry("dock_icon_size"), + "Size of dock icons in pixels", + ), + SettingsRow( + "Hide Special Apps", + SettingsSwitch("dock_hide_special_workspace_apps"), + "Hide apps from special workspace in dock", + ), + ], + ) + + def _create_panel_page(self): + rows = [ + SettingsRow( + "Global Menu", + SettingsSwitch("global_menu"), + "Show application menu in the panel", + ), + SettingsRow( + "iMac Button", + SettingsSwitch("imac_button"), + ), + SettingsRow( + "Systray", + SettingsSwitch("systray"), + ), + SettingsRow( + "Control Center", + SettingsSwitch("control_center"), + ), + SettingsRow( + "Search", + SettingsSwitch("search"), + ), + SettingsRow( + "Network", + SettingsSwitch("network"), + ), + SettingsRow( + "Battery", + SettingsSwitch("battery"), + ), + SettingsRow( + "Bluetooth", + SettingsSwitch("bluetooth"), + ), + SettingsRow( + "Date & Time", + SettingsSwitch("date_time"), + ), + SettingsRow( + "Workspace Indicator", + SettingsSwitch("workspace_indicator"), + ), + SettingsRow( + "Notification Center", + SettingsSwitch("notification_center"), + ), + SettingsRow( + "Custom Mods", + SettingsSwitch("custom_mods"), + "Enable custom Hyprland mods in panel", + ), + SettingsRow( + "Window Switcher", + SettingsSwitch("window_switcher"), + "Enable Alt+Tab window switcher", + ), + SettingsRow( + "OSD", + SettingsSwitch("osd"), + "Enable on-screen display for volume/brightness", + ), + SettingsRow( + "Wallpapers Directory", + SettingsEntry("wallpapers_dir"), + "Path to wallpapers folder", + ), + SettingsRow( + "Systray Ignore", + SettingsList("systray_ignore"), + "Icons to hide from the system tray", + ), + ] + + return SettingsPage("Panel Settings", rows) + + def _create_notifications_page(self): + return SettingsPage( + "Notification Settings", + [ + SettingsRow( + "Timeout", + SettingsEntry("notification_timeout"), + "How long notifications stay on screen (e.g. 5s)", + ), + SettingsRow( + "Ignored Apps", + SettingsList("notification_ignored_apps"), + "Apps that won't show notifications", + ), + SettingsRow( + "Limited History", + SettingsList("notification_limited_apps_history"), + "Apps with only the latest notification shown", + ), + ], + ) + + def _on_window_destroy(self, *args): + global _settings_window + _settings_window = None + + def toggle(self): + if not self.get_visible(): + self.show_all() + self.present() + + # Re-sync current page + current_page = "general" + for name, btn in self.sidebar_buttons.items(): + if "active" in btn.get_style_context().list_classes(): + current_page = name + break + self.set_page(current_page) + else: + self.hide() + + +_settings_window = None + + +def get_settings_window(): + global _settings_window + if _settings_window is None: + _settings_window = SettingsWindow() + return _settings_window diff --git a/src/window/spotlight/__init__.py b/src/window/spotlight/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/spotlight/api/__init__.py b/src/window/spotlight/api/__init__.py new file mode 100644 index 00000000..5ed3e292 --- /dev/null +++ b/src/window/spotlight/api/__init__.py @@ -0,0 +1,5 @@ +from window.spotlight.api.context import PluginContext +from window.spotlight.api.plugin import SpotlightPlugin +from window.spotlight.api.result import SearchResult + +__all__ = ["PluginContext", "SearchResult", "SpotlightPlugin"] diff --git a/src/window/spotlight/api/context.py b/src/window/spotlight/api/context.py new file mode 100644 index 00000000..63fca747 --- /dev/null +++ b/src/window/spotlight/api/context.py @@ -0,0 +1,20 @@ +from typing import Any + + +class PluginContext: + """Dependency injection container passed to every plugin. + + Plugins access services through this instead of importing globals. + """ + + def __init__(self, services: dict[str, Any] | None = None): + self._services: dict[str, Any] = services or {} + + def get_service(self, name: str) -> Any | None: + return self._services.get(name) + + def register_service(self, name: str, service: Any) -> None: + self._services[name] = service + + def has_service(self, name: str) -> bool: + return name in self._services diff --git a/src/window/spotlight/api/plugin.py b/src/window/spotlight/api/plugin.py new file mode 100644 index 00000000..c571733c --- /dev/null +++ b/src/window/spotlight/api/plugin.py @@ -0,0 +1,94 @@ +from abc import ABC, abstractmethod +from typing import Any + +from window.spotlight.api.context import PluginContext +from window.spotlight.api.result import SearchResult + + +class SpotlightPlugin(ABC): + """Base class for all spotlight plugins. + + Plugins provide data via search(). Never create GTK widgets. + + Attributes: + id: Unique identifier (e.g., "calculator"). + name: Human-readable name (e.g., "Calculator"). + icon: Default GTK icon name. + keywords: Trigger words that route queries exclusively. + searchable: Whether plugin participates in global search. + priority: Lower = searched first. + refresh_interval: Auto-refresh interval in ms (0 = disabled). + """ + + id: str = "" + name: str = "" + icon: str = "" + keywords: list[str] | None = None + keyword_icons: dict[str, str] | None = None + searchable: bool = False + priority: int = 100 + refresh_interval: int = 0 + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if cls.keywords is None: + cls.keywords = [] + if cls.keyword_icons is None: + cls.keyword_icons = {} + + def __init__(self, context: PluginContext): + self.context = context + + def initialize(self) -> None: + """Called once after instantiation. Override for setup.""" + + def cleanup(self) -> None: + """Called on unload/disable. Override to release resources.""" + + def reload(self) -> None: + """Called when plugin needs to refresh its state. + Default: cleanup + re-initialize.""" + self.cleanup() + self.initialize() + + @abstractmethod + def search(self, query: str, token: Any) -> list[SearchResult]: + """Return results matching query. + + token: Cancellation token. Check token.is_cancelled before expensive work. + Must return list of SearchResult. Never create GTK widgets. + """ + ... + + def on_activate(self, update_fn=None) -> None: + """Called when keyword routes to this plugin. + + update_fn: callable(result_id, subtitle_text) for in-place label updates. + """ + + def on_deactivate(self) -> None: + """Called when keyword routes away from this plugin.""" + + def on_submit(self, query: str) -> None: + """Called when user presses Enter while this plugin is active.""" + + def detect(self, text: str) -> bool: + """Return True if this plugin should exclusively handle the input.""" + return False + + def handle_external(self, command: str, args: str) -> None: + """Handle external commands sent to the plugin.""" + + def release_memory(self) -> None: + """Release heavy cached data. Called when spotlight hides. + + Plugins should clear large caches here. They will be + reloaded lazily on next search. + """ + + def on_timer_tick(self) -> list[SearchResult] | None: + """Called every second when the plugin is active. + + Return updated results to re-render, or None to skip. + Used for live data like countdown timers. + """ diff --git a/src/window/spotlight/api/result.py b/src/window/spotlight/api/result.py new file mode 100644 index 00000000..6e2bd8e8 --- /dev/null +++ b/src/window/spotlight/api/result.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from typing import Any, Callable + + +@dataclass +class SearchResult: + """Data carrier for a single search result. + + Plugins return these. Never create GTK widgets. + The spotlight owns all rendering. + """ + + id: str + title: str + subtitle: str = "" + icon_name: str = "" + icon_data: bytes | None = None + score: float = 0.0 + action: Callable[[], Any] | None = None + render_type: str = "default" + plugin_name: str = "" + plugin_id: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + self.score = max(0.0, min(100.0, self.score)) + + def activate(self): + if self.action: + return self.action() + return None diff --git a/src/window/spotlight/app.py b/src/window/spotlight/app.py new file mode 100644 index 00000000..bcf1b4db --- /dev/null +++ b/src/window/spotlight/app.py @@ -0,0 +1,147 @@ +from fabric import Application +from fabric.core.service import Service +from fabric.utils import GLib, get_relative_path, logger +from gi.repository import Gio + +from shared.data import load_config +from utils.functions import set_process_name +from window.spotlight.main import SpotlightWindow + +BUS_NAME = "dev.modus.Spotlight" +OBJECT_PATH = "/dev/modus/Spotlight" +INTERFACE = "dev.modus.Spotlight" + +_INTROSPECTION = f""" + + + + + + + +""" + + +class SpotlightService(Service): + """DBus-exposed controller for the running spotlight instance. + + Owns no state of its own; it forwards IPC calls to the live window. + """ + + def __init__(self, window: SpotlightWindow, **kwargs): + super().__init__(**kwargs) + self._window = window + self._connection = Gio.bus_get_sync(Gio.BusType.SESSION) + self._reg_id = self._connection.register_object( + OBJECT_PATH, + Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION).interfaces[0], + self._on_method_call, + ) + + def _on_method_call( + self, + _conn, + _sender, + _path, + _iface, + method, + params, + invocation, + ): + if method == "Toggle": + command, text, external = params.unpack() + GLib.idle_add(lambda: self._window.toggle(command, text, external=external)) + invocation.return_value(None) + + def release(self): + if self._reg_id: + self._connection.unregister_object(self._reg_id) + self._reg_id = 0 + + +def _request_name() -> int: + """Try to own the spotlight bus name. Returns the RequestName reply code + (1 == primary owner).""" + conn = Gio.bus_get_sync(Gio.BusType.SESSION) + reply = conn.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "RequestName", + GLib.Variant("(su)", (BUS_NAME, 0)), + GLib.VariantType.new("(u)"), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + return reply.get_child_value(0).get_uint32() + + +def _forward_to_running(command: str, text: str, external: bool) -> bool: + """Hand the command to an already-running instance. Return True on success.""" + try: + conn = Gio.bus_get_sync(Gio.BusType.SESSION) + conn.call_sync( + BUS_NAME, + OBJECT_PATH, + INTERFACE, + "Toggle", + GLib.Variant("(ssb)", (command, text, external)), + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + return True + except GLib.Error as e: + logger.warning( + f"[Spotlight] failed to forward to running instance: {e.message}" + ) + return False + + +def main(command: str = "", text: str = "", external: bool = False): + # If an instance already owns the bus name, let it handle the toggle and + # exit. This is the same on-demand forwarding the old socket did. + if _request_name() != 1: + _forward_to_running(command, text, external) + return + + set_process_name("modus-spotlight") + load_config() + + spotlight = SpotlightWindow() + + global app + app = Application("modus-spotlight", spotlight) + + def set_css(): + app.set_stylesheet_from_file(get_relative_path("../../styles/main.css")) + + app.set_css = set_css + app.set_css() + + # Kill the process on every dismissal so nothing lingers in memory. + # close_spotlight is the single close chokepoint (Escape, result launch, + # and toggle-when-visible all route through it). + _orig_close = spotlight.close_spotlight + + def _close_and_quit(): + _orig_close() + app.quit() + + spotlight.close_spotlight = _close_and_quit + + service = SpotlightService(spotlight) + + spotlight.toggle(command, text, external=external) + + # External one-shot commands never show the window; give any async action a + # moment to fire, then exit so we do not stay resident. + if external and not spotlight.get_visible(): + GLib.timeout_add(2000, app.quit) + + try: + app.run() + finally: + service.release() diff --git a/src/window/spotlight/core/__init__.py b/src/window/spotlight/core/__init__.py new file mode 100644 index 00000000..b15d0d1e --- /dev/null +++ b/src/window/spotlight/core/__init__.py @@ -0,0 +1,14 @@ +from window.spotlight.core.loader import DiscoveredPlugin, PluginLoader +from window.spotlight.core.manager import PluginManager +from window.spotlight.core.registry import PluginEntry, PluginRegistry +from window.spotlight.core.search import SearchPipeline, SearchToken + +__all__ = [ + "DiscoveredPlugin", + "PluginEntry", + "PluginLoader", + "PluginManager", + "PluginRegistry", + "SearchPipeline", + "SearchToken", +] diff --git a/src/window/spotlight/core/loader.py b/src/window/spotlight/core/loader.py new file mode 100644 index 00000000..48b3f606 --- /dev/null +++ b/src/window/spotlight/core/loader.py @@ -0,0 +1,382 @@ +import importlib +import importlib.util +import inspect +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Type + +from fabric.utils import logger + +from window.spotlight.api.plugin import SpotlightPlugin + +VENV_DIR = ".venvs" + + +@dataclass +class DiscoveredPlugin: + """A plugin class discovered on disk, not yet instantiated.""" + + plugin_class: Type[SpotlightPlugin] + source: str + path: str + module_name: str = "" + + +class PluginLoader: + """Discovers and imports plugins from filesystem paths. + + Handles: + - Single-file plugins (weather.py) + - Package plugins (weather/plugin.py or weather/__init__.py) + - Built-in plugins from the plugins/ directory + - Third-party plugins from config/data/system paths + - Per-plugin virtualenvs for isolated dependencies + """ + + USER_PATHS = [Path(__file__).resolve().parents[4] / "config" / "plugins"] + + def __init__(self, builtin_dir: str | Path): + self._builtin_dir = Path(builtin_dir) + self._loaded_modules: set[str] = set() + + def discover(self) -> list[DiscoveredPlugin]: + """Scan all paths and return discovered plugin classes.""" + found: list[DiscoveredPlugin] = [] + seen_ids: set[str] = set() + + # Built-in plugins first + for entry in self._discover_directory(self._builtin_dir, "builtin"): + if entry.plugin_class.id not in seen_ids: + seen_ids.add(entry.plugin_class.id) + found.append(entry) + + # Third-party paths + source_labels = ["user", "system"] + for path, source in zip(self.USER_PATHS, source_labels, strict=False): + if path.is_dir(): + for entry in self._discover_directory(path, source): + pid = entry.plugin_class.id + if pid and pid not in seen_ids: + seen_ids.add(pid) + found.append(entry) + elif pid in seen_ids: + logger.warning( + f"[PluginLoader] Duplicate plugin id '{pid}' " + f"from {entry.path}, skipping" + ) + + return found + + def _discover_directory( + self, directory: Path, source: str + ) -> list[DiscoveredPlugin]: + """Scan a directory for plugins.""" + results: list[DiscoveredPlugin] = [] + if not directory.is_dir(): + return results + + for item in sorted(directory.iterdir()): + try: + discovered = self._discover_item(item, source) + if discovered: + results.append(discovered) + except Exception as e: + logger.error(f"[PluginLoader] Error discovering {item.name}: {e}") + + return results + + def _discover_item(self, item: Path, source: str) -> DiscoveredPlugin | None: + """Discover a single plugin from a file or directory.""" + if item.is_file() and item.suffix == ".py": + if item.name.startswith("_"): + return None + return self._load_file_plugin(item, source) + + if item.is_dir() and not item.name.startswith("_"): + return self._load_package_plugin(item, source) + + return None + + def _load_file_plugin(self, filepath: Path, source: str) -> DiscoveredPlugin | None: + """Import a single .py file and extract PLUGIN.""" + # Check for requirements.txt next to the .py file + req_file = filepath.parent / "requirements.txt" + if req_file.exists(): + self._ensure_venv(filepath.parent, source) + + # Also check for a companion directory with requirements + # e.g. weather.py + weather/requirements.txt + companion_dir = filepath.parent / filepath.stem + if companion_dir.is_dir(): + companion_req = companion_dir / "requirements.txt" + if companion_req.exists(): + self._ensure_venv(companion_dir, source) + + module_name = f"modus_plugin_{source}_{filepath.stem}" + plugin_class = self._import_and_extract(filepath, module_name) + if plugin_class: + return DiscoveredPlugin( + plugin_class=plugin_class, + source=source, + path=str(filepath), + ) + return None + + def _load_package_plugin( + self, pkg_dir: Path, source: str + ) -> DiscoveredPlugin | None: + """Import a package plugin (plugin.py or __init__.py). + + Sets up the package hierarchy in sys.modules so that relative + imports (from .module import ...) work correctly. + """ + # Check for requirements.txt in package directory + req_file = pkg_dir / "requirements.txt" + if req_file.exists(): + self._ensure_venv(pkg_dir, source) + + parent_pkg = pkg_dir.parent.name + pkg_name = pkg_dir.name + full_pkg_name = f"{parent_pkg}.{pkg_name}" + mod_name = full_pkg_name + + # Ensure parent directory is on sys.path for submodule resolution + parent_dir = str(pkg_dir.parent) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + # Set up package hierarchy so relative imports work + self._ensure_package_modules(pkg_dir.parent, parent_pkg, pkg_name) + + candidates = [ + (pkg_dir / "plugin.py", None), + (pkg_dir / "__init__.py", [str(pkg_dir)]), + ] + + for candidate, search_locs in candidates: + if candidate.exists(): + plugin_class = self._import_and_extract( + candidate, full_pkg_name, search_locs + ) + if plugin_class: + return DiscoveredPlugin( + plugin_class=plugin_class, + source=source, + path=str(candidate), + module_name=mod_name, + ) + + return None + + def _ensure_package_modules( + self, parent_dir: Path, parent_pkg: str, pkg_name: str + ) -> None: + """Create stub package modules in sys.modules for relative imports.""" + import types + + parent_mod_name = parent_pkg + if parent_mod_name not in sys.modules: + parent_mod = types.ModuleType(parent_mod_name) + parent_mod.__path__ = [str(parent_dir)] + parent_mod.__package__ = parent_mod_name + sys.modules[parent_mod_name] = parent_mod + + pkg_mod_name = f"{parent_pkg}.{pkg_name}" + if pkg_mod_name not in sys.modules: + pkg_dir = parent_dir / pkg_name + pkg_mod = types.ModuleType(pkg_mod_name) + pkg_mod.__path__ = [str(pkg_dir)] + pkg_mod.__package__ = pkg_mod_name + sys.modules[pkg_mod_name] = pkg_mod + + # ------------------------------------------------------------------ + # Plugin virtualenv management + # ------------------------------------------------------------------ + + def _ensure_venv(self, plugin_dir: Path, source: str) -> None: + """Create or update a venv for a plugin if requirements.txt exists. + + The venv lives at /.venvs// + Dependencies are installed into it, and its site-packages + are added to sys.path before the plugin is imported. + """ + req_file = plugin_dir / "requirements.txt" + if not req_file.exists(): + return + + venv_base = plugin_dir.parent / VENV_DIR + venv_base.mkdir(parents=True, exist_ok=True) + venv_dir = venv_base / plugin_dir.name + + if not venv_dir.is_dir(): + logger.info(f"[PluginLoader] Creating venv for {plugin_dir.name}") + if not self._create_venv(venv_dir): + return + + # Check if we need to install/update deps + marker_file = venv_dir / ".deps_installed" + needs_install = not marker_file.exists() or self._reqs_changed( + req_file, marker_file + ) + if needs_install: + logger.info(f"[PluginLoader] Installing deps for {plugin_dir.name}") + if self._install_deps(venv_dir, req_file): + marker_file.write_text(req_file.read_text()) + else: + logger.error( + f"[PluginLoader] Dep install failed for {plugin_dir.name}, " + "will retry next time" + ) + + # Add venv site-packages to sys.path + site_packages = self._find_site_packages(venv_dir) + if site_packages and str(site_packages) not in sys.path: + sys.path.insert(0, str(site_packages)) + + def _create_venv(self, venv_dir: Path) -> bool: + """Create a virtualenv using uv.""" + from utils.functions import run_command + + result = run_command(["uv", "venv", str(venv_dir)], timeout=30) + if result.returncode != 0: + logger.error(f"[PluginLoader] uv venv failed: {result.stderr[:300]}") + return result.returncode == 0 + + def _install_deps(self, venv_dir: Path, req_file: Path) -> bool: + """Install dependencies from requirements.txt using uv.""" + from utils.functions import run_command + + venv_python = venv_dir / "bin" / "python" + result = run_command( + [ + "uv", + "pip", + "install", + "-r", + str(req_file), + "--python", + str(venv_python), + ], + timeout=120, + ) + if result.returncode != 0: + logger.error(f"[PluginLoader] uv pip install failed: {result.stderr[:500]}") + return False + return True + + def _find_site_packages(self, venv_dir: Path) -> Path | None: + """Find site-packages directory in a venv.""" + # Look in lib/pythonX.Y/site-packages + lib_dir = venv_dir / "lib" + if lib_dir.is_dir(): + for item in lib_dir.iterdir(): + if item.name.startswith("python"): + site_pkgs = item / "site-packages" + if site_pkgs.is_dir(): + return site_pkgs + return None + + def _reqs_changed(self, req_file: Path, marker_file: Path) -> bool: + """Check if requirements.txt changed since last install.""" + try: + return req_file.read_text() != marker_file.read_text() + except Exception as e: + logger.warning( + f"[loader] return req_file.read_text() != marker_file.read_text() failed: {e}" + ) + return True + + # ------------------------------------------------------------------ + # Module import + # ------------------------------------------------------------------ + + def _import_and_extract( + self, + filepath: Path, + module_name: str, + submodule_search_locations: list[str] | None = None, + ) -> Type[SpotlightPlugin] | None: + """Import a module and extract the PLUGIN attribute. + + For package __init__.py files, pass submodule_search_locations + so that relative imports within the package work. + """ + try: + spec = importlib.util.spec_from_file_location( + module_name, + filepath, + submodule_search_locations=submodule_search_locations, + ) + if not spec or not spec.loader: + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + self._loaded_modules.add(module_name) + spec.loader.exec_module(module) + + plugin = self._extract_plugin_class(module, module_name) + if not plugin: + self._unload_module(module_name) + return plugin + + except Exception as e: + logger.error(f"[PluginLoader] Failed to import {filepath.name}: {e}") + return None + + def _extract_plugin_class( + self, module, module_name: str + ) -> Type[SpotlightPlugin] | None: + """Extract plugin class from a module. + + Priority: + 1. MODULE.PLUGIN attribute (explicit, preferred) + 2. First SpotlightPlugin subclass found in module (fallback) + """ + # Preferred: explicit PLUGIN variable + plugin_attr = getattr(module, "PLUGIN", None) + if plugin_attr is not None: + if ( + inspect.isclass(plugin_attr) + and issubclass(plugin_attr, SpotlightPlugin) + and plugin_attr is not SpotlightPlugin + ): + return plugin_attr + logger.warning( + f"[PluginLoader] {module_name}.PLUGIN is not a " + f"SpotlightPlugin subclass, ignoring" + ) + return None + + # Fallback: scan for subclass + for attr_name in dir(module): + attr_value = getattr(module, attr_name) + if ( + inspect.isclass(attr_value) + and issubclass(attr_value, SpotlightPlugin) + and attr_value is not SpotlightPlugin + and inspect.getmodule(attr_value) is module + ): + logger.info( + f"[PluginLoader] Found {attr_name} via subclass scan " + f"in {module_name} (consider adding PLUGIN = {attr_name})" + ) + return attr_value + + logger.warning(f"[PluginLoader] No SpotlightPlugin found in {module_name}") + return None + + def unload_module(self, module_name: str) -> None: + """Remove a plugin module from sys.modules.""" + if module_name in self._loaded_modules: + self._loaded_modules.discard(module_name) + for key in list(sys.modules.keys()): + if key == module_name or key.startswith(module_name + "."): + sys.modules.pop(key, None) + + def unload_all(self) -> None: + """Remove all loaded plugin modules from sys.modules.""" + for name in list(self._loaded_modules): + self.unload_module(name) + self._loaded_modules.clear() diff --git a/src/window/spotlight/core/manager.py b/src/window/spotlight/core/manager.py new file mode 100644 index 00000000..c043b988 --- /dev/null +++ b/src/window/spotlight/core/manager.py @@ -0,0 +1,213 @@ +import json +from pathlib import Path + +from fabric.utils import logger + +from window.spotlight.api.context import PluginContext +from window.spotlight.api.plugin import SpotlightPlugin +from window.spotlight.core.loader import PluginLoader +from window.spotlight.core.registry import PluginEntry, PluginRegistry + + +class PluginManager: + """Manages plugin lifecycle: load, enable, disable, reload. + + Responsibilities: + - Orchestrate discovery (loader) and storage (registry) + - Instantiate and initialize enabled plugins + - Persist disabled state via TOML config + - Provide the active plugin set to the search pipeline + """ + + DISABLED_KEY = "disabled_plugins" + + def __init__( + self, + loader: PluginLoader, + registry: PluginRegistry, + context: PluginContext, + ): + self._loader = loader + self._registry = registry + self._context = context + self._disabled_ids: set[str] = set() + + @property + def registry(self) -> PluginRegistry: + return self._registry + + def load_all(self) -> None: + """Discover, register, and instantiate all plugins.""" + self._load_disabled_list() + + discovered = self._loader.discover() + for dp in discovered: + self._registry.register(dp) + + for entry in self._registry.get_all(): + if entry.id in self._disabled_ids: + entry.enabled = False + continue + self._instantiate(entry) + + logger.info( + f"[PluginManager] Loaded {len(self._registry.get_enabled())} plugins" + ) + + def enable(self, plugin_id: str) -> bool: + entry = self._registry.get(plugin_id) + if not entry or entry.enabled: + return False + + entry.enabled = True + self._disabled_ids.discard(plugin_id) + + if not entry.instance: + self._instantiate(entry) + + self._save_disabled_list() + return entry.instance is not None + + def disable(self, plugin_id: str) -> bool: + entry = self._registry.get(plugin_id) + if not entry or not entry.enabled: + return False + + self._call_cleanup(entry) + entry.enabled = False + self._disabled_ids.add(plugin_id) + self._save_disabled_list() + return True + + def reload(self, plugin_id: str) -> bool: + entry = self._registry.get(plugin_id) + if not entry: + return False + + if entry.instance: + try: + # Quick reload: just re-initialize same class instance + entry.instance.reload() + return True + except Exception as e: + logger.error(f"[PluginManager] Failed to reload {plugin_id}: {e}") + return False + + # Not instantiated yet, just re-instantiate + if entry.enabled: + return self._instantiate(entry) + return False + + def deep_reload(self, plugin_id: str) -> bool: + """Full re-import from disk. Picks up code changes. + + Destroys the old instance, removes the module from sys.modules, + re-discovers the plugin class, re-instantiates, and re-initializes. + Use during plugin development to avoid restarting Modus. + """ + entry = self._registry.get(plugin_id) + if not entry: + return False + + self._call_cleanup(entry) + if entry.module_name: + self._loader.unload_module(entry.module_name) + + fp = Path(entry.path) if entry.path else None + if fp and fp.exists(): + source = entry.source + if fp.is_file() and fp.suffix == ".py": + dp = self._loader._load_file_plugin(fp, source) + elif fp.is_dir(): + dp = self._loader._load_package_plugin(fp, source) + else: + dp = None + if dp and dp.plugin_class.id == plugin_id: + entry.plugin_class = dp.plugin_class + return self._instantiate(entry) + return False + + def reload_all(self) -> None: + for entry in self._registry.get_enabled(): + if entry.instance: + try: + entry.instance.reload() + except Exception as e: + logger.error(f"[PluginManager] Failed to reload {entry.id}: {e}") + + def reload_all_full(self) -> None: + """Reload all enabled plugins from disk. Accepts new classes, methods, etc.""" + for entry in self._registry.get_all(): + self.deep_reload(entry.id) + + def stop_all(self) -> None: + for entry in self._registry.get_all(): + self._call_cleanup(entry) + + def release_memory_all(self) -> None: + for entry in self._registry.get_enabled(): + if entry.instance: + try: + entry.instance.release_memory() + except Exception as e: + logger.error( + f"[PluginManager] Error releasing memory for {entry.id}: {e}" + ) + + def get_active_plugins(self) -> list[SpotlightPlugin]: + return [ + entry.instance for entry in self._registry.get_enabled() if entry.instance + ] + + def get_plugin(self, plugin_id: str) -> SpotlightPlugin | None: + entry = self._registry.get(plugin_id) + if entry and entry.enabled and entry.instance: + return entry.instance + return None + + def _instantiate(self, entry: PluginEntry) -> bool: + try: + instance = entry.plugin_class(self._context) + instance.initialize() + entry.instance = instance + logger.info(f"[PluginManager] Instantiated {entry.id} successfully") + return True + except Exception as e: + logger.error(f"[PluginManager] Failed to instantiate {entry.id}: {e}") + return False + + def _call_cleanup(self, entry: PluginEntry) -> None: + if not entry.instance: + return + try: + entry.instance.cleanup() + except Exception as e: + logger.error(f"[PluginManager] Error cleaning up {entry.id}: {e}") + entry.instance = None + + def _config_path(self) -> Path: + from utils.gtk_utils import toml_file + + return Path(toml_file("config.toml")).parent / "plugins.json" + + def _load_disabled_list(self) -> None: + path = self._config_path() + if not path.exists(): + self._disabled_ids = set() + return + from utils.functions import read_json_file + + data = read_json_file(str(path)) + if data is not None: + self._disabled_ids = set(data.get("disabled", [])) + else: + self._disabled_ids = set() + + def _save_disabled_list(self) -> None: + path = self._config_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump({"disabled": sorted(self._disabled_ids)}, f, indent=2) + except Exception as e: + logger.error(f"[PluginManager] Failed to save disabled list: {e}") diff --git a/src/window/spotlight/core/registry.py b/src/window/spotlight/core/registry.py new file mode 100644 index 00000000..0a757a42 --- /dev/null +++ b/src/window/spotlight/core/registry.py @@ -0,0 +1,112 @@ +from dataclasses import dataclass, field +from typing import Type + +from fabric.utils import logger + +from window.spotlight.api.plugin import SpotlightPlugin +from window.spotlight.core.loader import DiscoveredPlugin + + +@dataclass +class PluginEntry: + """Internal bookkeeping for a registered plugin.""" + + id: str + plugin_class: Type[SpotlightPlugin] + source: str + path: str + instance: SpotlightPlugin | None = None + enabled: bool = True + keywords: list[str] = field(default_factory=list) + module_name: str = "" + + +class PluginRegistry: + """Stores loaded plugin metadata and instances. + + Responsibilities: + - Register/unregister plugins + - Lookup by id, keyword + - Prevent duplicates + - Track enabled/disabled state + """ + + def __init__(self): + self._entries: dict[str, PluginEntry] = {} + self._keyword_map: dict[str, str] = {} # keyword -> plugin_id + + def register(self, discovered: DiscoveredPlugin) -> bool: + """Register a discovered plugin. Returns False if duplicate id.""" + cls = discovered.plugin_class + pid = cls.id + if not pid: + logger.warning( + f"[PluginRegistry] Plugin {cls.__name__} has no id, skipping" + ) + return False + + if pid in self._entries: + logger.warning( + f"[PluginRegistry] Duplicate plugin id '{pid}' from " + f"{discovered.path}, ignoring" + ) + return False + + entry = PluginEntry( + id=pid, + plugin_class=cls, + source=discovered.source, + path=discovered.path, + keywords=list(cls.keywords) if cls.keywords else [], + module_name=discovered.module_name, + ) + self._entries[pid] = entry + + for kw in entry.keywords: + if kw in self._keyword_map: + logger.warning( + f"[PluginRegistry] Keyword '{kw}' already mapped to " + f"'{self._keyword_map[kw]}', overriding with '{pid}'" + ) + self._keyword_map[kw] = pid + + return True + + def unregister(self, plugin_id: str) -> bool: + entry = self._entries.pop(plugin_id, None) + if not entry: + return False + for kw in entry.keywords: + if self._keyword_map.get(kw) == plugin_id: + del self._keyword_map[kw] + return True + + def get(self, plugin_id: str) -> PluginEntry | None: + return self._entries.get(plugin_id) + + def get_all(self) -> list[PluginEntry]: + return list(self._entries.values()) + + def get_enabled(self) -> list[PluginEntry]: + return [e for e in self._entries.values() if e.enabled] + + def get_searchable(self) -> list[PluginEntry]: + return [ + e for e in self._entries.values() if e.enabled and e.plugin_class.searchable + ] + + def get_by_keyword(self, keyword: str) -> PluginEntry | None: + pid = self._keyword_map.get(keyword) + if pid: + return self._entries.get(pid) + return None + + def is_registered(self, plugin_id: str) -> bool: + return plugin_id in self._entries + + def set_enabled(self, plugin_id: str, enabled: bool) -> bool: + entry = self._entries.get(plugin_id) + if not entry: + return False + entry.enabled = enabled + return True diff --git a/src/window/spotlight/core/search.py b/src/window/spotlight/core/search.py new file mode 100644 index 00000000..fb135d06 --- /dev/null +++ b/src/window/spotlight/core/search.py @@ -0,0 +1,256 @@ +import threading +from dataclasses import dataclass, field +from typing import Callable + +from fabric.utils import GLib, logger + +from window.spotlight.api.plugin import SpotlightPlugin +from window.spotlight.api.result import SearchResult +from window.spotlight.core.manager import PluginManager +from window.spotlight.core.registry import PluginEntry + + +@dataclass +class SearchToken: + """Cancellation token for search operations.""" + + query: str + _cancelled: threading.Event = field(default_factory=threading.Event, repr=False) + + def cancel(self) -> None: + self._cancelled.set() + + @property + def is_cancelled(self) -> bool: + return self._cancelled.is_set() + + +class SearchPipeline: + """Async search pipeline that routes queries and merges results.""" + + def __init__(self, manager: PluginManager): + self._manager = manager + self._current_token: SearchToken | None = None + self._pending_threads: list[threading.Thread] = [] + self._pending_idle_ids: list[int] = [] + self._lock = threading.Lock() + + def search( + self, + query: str, + callback: Callable[[list[SearchResult]], None], + ) -> None: + self._cancel_current() + + if not query.strip(): + callback([]) + return + + token = SearchToken(query=query) + self._current_token = token + + keyword_plugin, _kw, _remaining = self._route(query) + + if keyword_plugin: + self._search_single(keyword_plugin, query, token, callback) + else: + self._global_search(query, token, callback) + + def search_single( + self, + entry: PluginEntry, + query: str, + callback: Callable[[list[SearchResult]], None], + ) -> None: + self._cancel_current() + token = SearchToken(query=query) + self._current_token = token + self._search_single(entry, query, token, callback) + + def cancel(self) -> None: + self._cancel_current() + + def _cancel_current(self) -> None: + if self._current_token: + self._current_token.cancel() + with self._lock: + for t in self._pending_threads: + t.join(timeout=0.1) + self._pending_threads.clear() + for source_id in self._pending_idle_ids: + GLib.source_remove(source_id) + self._pending_idle_ids.clear() + + def _route(self, query: str) -> tuple[PluginEntry | None, str, str]: + stripped = query.strip() + lower = stripped.lower() + registry = self._manager.registry + + for entry in registry.get_enabled(): + for keyword in entry.keywords: + kw = keyword.lower() + if lower.startswith(kw + " ") or lower == kw: + remaining = stripped[len(keyword) :].lstrip() + return entry, keyword, remaining + + return None, "", query + + def _search_single( + self, + entry: PluginEntry, + query: str, + token: SearchToken, + callback: Callable[[list[SearchResult]], None], + ) -> None: + instance = entry.instance + if not instance: + callback([]) + return + + def _run(): + try: + results = self._safe_search(instance, query, token) + self._stamp_results(results, entry) + if not token.is_cancelled: + + def _fire(): + with self._lock: + try: + self._pending_idle_ids.remove(source_id) + except ValueError as e: + logger.warning( + f"[search] self._pending_idle_ids.remove(source_id) failed: {e}" + ) + callback(results) + return False + + source_id = GLib.idle_add(_fire) + with self._lock: + self._pending_idle_ids.append(source_id) + except Exception as e: + logger.error(f"[SearchPipeline] Error searching {entry.id}: {e}") + if not token.is_cancelled: + + def _fire_err(): + with self._lock: + try: + self._pending_idle_ids.remove(source_id) + except ValueError as e: + logger.warning( + f"[search] self._pending_idle_ids.remove(source_id) failed: {e}" + ) + callback([]) + return False + + source_id = GLib.idle_add(_fire_err) + with self._lock: + self._pending_idle_ids.append(source_id) + + thread = threading.Thread(target=_run, daemon=True) + with self._lock: + self._pending_threads.append(thread) + thread.start() + + def _global_search( + self, + query: str, + token: SearchToken, + callback: Callable[[list[SearchResult]], None], + ) -> None: + searchable = self._manager.registry.get_searchable() + if not searchable: + callback([]) + return + + results_lock = threading.Lock() + collected: dict[str, list[SearchResult]] = {} + remaining = len(searchable) + + def _on_all_done(): + if token.is_cancelled: + return + merged = self._merge_results(collected) + callback(merged) + + def _plugin_done(plugin_id: str): + nonlocal remaining + with results_lock: + remaining -= 1 + if remaining <= 0: + + def _fire_all_done(): + with self._lock: + try: + self._pending_idle_ids.remove(source_id) + except ValueError as e: + logger.warning( + f"[search] self._pending_idle_ids.remove(source_id) failed: {e}" + ) + _on_all_done() + return False + + source_id = GLib.idle_add(_fire_all_done) + with self._lock: + self._pending_idle_ids.append(source_id) + + def _run(entry: PluginEntry): + instance = entry.instance + if not instance or token.is_cancelled: + _plugin_done(entry.id) + return + + try: + results = self._safe_search(instance, query, token) + self._stamp_results(results, entry) + with results_lock: + collected[entry.id] = results + except Exception as e: + logger.error(f"[SearchPipeline] Plugin {entry.id} crashed: {e}") + finally: + _plugin_done(entry.id) + + for entry in searchable: + thread = threading.Thread(target=_run, args=(entry,), daemon=True) + with self._lock: + self._pending_threads.append(thread) + thread.start() + + def _safe_search( + self, + plugin: SpotlightPlugin, + query: str, + token: SearchToken, + ) -> list[SearchResult]: + if token.is_cancelled: + return [] + results = plugin.search(query, token) + if token.is_cancelled: + return [] + return results or [] + + def _stamp_results(self, results: list[SearchResult], entry: PluginEntry) -> None: + for r in results: + if not r.plugin_name: + r.plugin_name = entry.plugin_class.name + if not r.plugin_id: + r.plugin_id = entry.id + + def _merge_results( + self, grouped: dict[str, list[SearchResult]] + ) -> list[SearchResult]: + enabled = {e.id: e for e in self._manager.registry.get_enabled()} + + ordered_ids = sorted( + grouped.keys(), + key=lambda pid: ( + enabled[pid].plugin_class.priority if pid in enabled else 999 + ), + ) + + merged: list[SearchResult] = [] + for pid in ordered_ids: + plugin_results = grouped[pid] + plugin_results.sort(key=lambda r: r.score, reverse=True) + merged.extend(plugin_results) + + return merged diff --git a/src/window/spotlight/main.py b/src/window/spotlight/main.py new file mode 100644 index 00000000..b3007e2f --- /dev/null +++ b/src/window/spotlight/main.py @@ -0,0 +1,748 @@ +import os + +from fabric.core import Signal +from fabric.utils import Gdk, GLib, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +from shared.widgets.clipping_box import ClippingBox +from shared.window.animated_scrollwindow import AnimatedScrollable +from utils.debounce import debounce +from utils.functions import get_children_height_limit +from window.spotlight.api.context import PluginContext +from window.spotlight.api.result import SearchResult +from window.spotlight.core.loader import PluginLoader +from window.spotlight.core.manager import PluginManager +from window.spotlight.core.registry import PluginRegistry +from window.spotlight.core.search import SearchPipeline + + +class PluginService: + """Owns plugin lifecycle and search pipeline. Plain class, no GObject.""" + + def __init__(self): + builtin_dir = os.path.join(os.path.dirname(__file__), "plugins") + context = PluginContext() + loader = PluginLoader(builtin_dir) + registry = PluginRegistry() + self.manager = PluginManager(loader, registry, context) + self.pipeline = SearchPipeline(self.manager) + self.manager.load_all() + + def stop(self): + self.pipeline.cancel() + self.manager.stop_all() + + def get_plugin(self, plugin_id: str): + return self.manager.get_plugin(plugin_id) + + def get_all_plugins(self): + return {e.id: e for e in self.manager.registry.get_enabled()} + + +class Spotlight(Box): + @Signal + def launched(self): ... + + def __init__(self, **kwargs): + super().__init__(name="spotlight", spacing=4, orientation="v", **kwargs) + + self._selected_index: int = -1 + self._current_results: list[SearchResult] = [] + self._active_plugin_entry = None + self._refresh_timer_id: int = 0 + self._result_widgets: dict[str, Button] = {} + + self.viewport = Box(spacing=4, orientation="v") + self.max_children = 4 + + self.plugin_service = PluginService() + + self.header_icon = Image(icon_name="system-search-symbolic") + self.header_entry = Entry( + placeholder="Search...", + style_classes="app-search-entry", + h_expand=True, + on_activate=self.on_entry_accept, + on_changed=self.on_entry_changed, + ).build(lambda entry, _: entry.connect("key-press-event", self.on_key_press)) + + self.header = Box( + style_classes="app-search-header", + spacing=8, + orientation="h", + children=[self.header_icon, self.header_entry], + ) + + self.scrolled_window = AnimatedScrollable( + max_content_size=(280, 320), + child=self.viewport, + h_expand=True, + v_expand=True, + visible=False, + animate=False, + ) + + self.scrolled_clip = ClippingBox( + style_classes="spotlight-scroll-clip", + children=self.scrolled_window, + visible=False, + ) + self.scrolled_window.connect("unmap", lambda: self.scrolled_clip.hide()) + + self.children = self.header, self.scrolled_clip + + # Search + @debounce(300) + def on_entry_changed(self, entry: Entry, *_): + if getattr(entry, "_ignore_change", False): + entry._ignore_change = False + return + + text = entry.get_text() + self._selected_index = -1 + + keyword_entry, _kw, _remaining = self.plugin_service.pipeline._route(text) + if keyword_entry: + self._activate_keyword_plugin(keyword_entry, _kw, _remaining) + self.plugin_service.pipeline.search_single( + keyword_entry, text, self._on_search_results + ) + return + + self._deactivate_keyword_plugin() + + if not text.strip(): + self._clear_viewport() + self.header_icon.set_from_icon_name("system-search-symbolic") + return + + for pe in self.plugin_service.manager.registry.get_searchable(): + inst = pe.instance + if inst and inst.detect(text): + self.header_icon.set_from_icon_name( + getattr(inst, "icon", "") or "system-search-symbolic" + ) + # Scope to the detected plugin only — a global search would + # mix in unrelated plugins' results. + self.plugin_service.pipeline.search_single( + pe, text, self._on_search_results + ) + return + + self.header_icon.set_from_icon_name("system-search-symbolic") + self.plugin_service.pipeline.search(text, self._on_search_results) + + def _activate_keyword_plugin(self, entry, keyword: str, query: str): + inst = entry.instance + if not inst: + return + + if self._active_plugin_entry and self._active_plugin_entry is not entry: + old = self._active_plugin_entry.instance + if old: + old.on_deactivate() + + self._active_plugin_entry = entry + + keyword_icons = getattr(inst, "keyword_icons", {}) + icon_name = ( + keyword_icons.get(keyword, getattr(inst, "icon", "")) + or "system-search-symbolic" + ) + self.header_icon.set_from_icon_name(icon_name) + + if getattr(inst, "full_viewport_clear", False): + self._clear_viewport() + self.scrolled_window.animate_size(0) + self.scrolled_window.hide() + self.scrolled_clip.hide() + + inst.on_activate(self.update_result_subtitle) + self._start_refresh_timer(entry, query) + + def _deactivate_keyword_plugin(self): + self._stop_refresh_timer() + if self._active_plugin_entry: + inst = self._active_plugin_entry.instance + if inst: + inst.on_deactivate() + self._active_plugin_entry = None + + def _start_refresh_timer(self, entry, query: str): + self._stop_refresh_timer() + inst = entry.instance + interval = getattr(inst, "refresh_interval", 0) if inst else 0 + if interval <= 0: + return + + def _tick(): + if self._active_plugin_entry is not entry: + return False + inst = entry.instance + if not inst: + return False + inst.on_timer_tick() + return True + + self._refresh_timer_id = GLib.timeout_add(interval, _tick) + + def _stop_refresh_timer(self): + if self._refresh_timer_id: + GLib.source_remove(self._refresh_timer_id) + self._refresh_timer_id = 0 + + def _auto_start_refresh_timer(self, results: list[SearchResult]): + for result in results: + if not result.plugin_id: + continue + entry = self.plugin_service.manager.registry.get(result.plugin_id) + if not entry: + continue + inst = entry.instance + if not inst: + continue + interval = getattr(inst, "refresh_interval", 0) + if interval > 0: + if self._active_plugin_entry is entry and self._refresh_timer_id: + return + if self._active_plugin_entry and self._active_plugin_entry is not entry: + old = self._active_plugin_entry.instance + if old: + old.on_deactivate() + self._active_plugin_entry = entry + inst.on_activate(self.update_result_subtitle) + self._start_refresh_timer(entry, "") + return + self._auto_stop_refresh_timer() + + def _auto_stop_refresh_timer(self): + if self._active_plugin_entry: + inst = self._active_plugin_entry.instance + interval = getattr(inst, "refresh_interval", 0) if inst else 0 + if interval <= 0: + self._deactivate_keyword_plugin() + + def _on_search_results(self, results: list[SearchResult]): + new_ids = [r.id for r in results] + old_ids = list(self._result_widgets.keys()) + + if new_ids == old_ids: + self._current_results = results + return + + for rid in set(old_ids) - set(new_ids): + self._remove_result_widget(rid) + + for child in list(self.viewport.children): + self.viewport.remove(child) + + for result in results: + widget = self._result_widgets.get(result.id) + if not widget: + widget = self._render_result(result) + self._result_widgets[result.id] = widget + widget.connect("enter-notify-event", self.on_slot_enter) + self.viewport.add(widget) + + self._current_results = results + + if not results: + self._auto_stop_refresh_timer() + self._post_arrange() + return + + self._auto_start_refresh_timer(results) + self._post_arrange() + + def _remove_result_widget(self, result_id: str): + widget = self._result_widgets.pop(result_id, None) + if not widget: + return + try: + widget.disconnect_by_func(self.on_slot_enter) + except (TypeError, ValueError) as e: + logger.warning( + f"[main] widget.disconnect_by_func(self.on_slot_enter) failed: {e}" + ) + self.viewport.remove(widget) + widget.destroy() + + # Rendering + def _render_result(self, result: SearchResult) -> Button: + rt = result.render_type + if rt == "app": + return self._render_app(result) + if rt == "calc": + return self._render_calc(result) + if rt == "emoji": + return self._render_emoji(result) + if rt == "power": + return self._render_power(result) + if rt == "wallpaper": + return self._render_wallpaper(result) + if rt == "clipboard_text": + return self._render_clipboard_text(result) + if rt == "clipboard_image": + return self._render_clipboard_image(result) + return self._render_default(result) + + def _render_app(self, r: SearchResult) -> Button: + app = r.metadata.get("desktop_app") + children = [] + if app: + pixbuf = app.get_icon_pixbuf() + if pixbuf: + children.append(Image(pixbuf=pixbuf, h_align="start", size=32)) + title_box = Box( + orientation="v", + children=[ + Label( + label=r.title or "Unknown", + v_align="start", + h_align="start", + style_classes="app-title", + ) + ], + ) + if r.subtitle: + title_box.add( + Label( + label=r.subtitle, + max_chars_width=42, + ellipsization="middle", + justification="center", + style_classes="app-description", + v_align="start", + h_align="start", + ) + ) + children.append(title_box) + + return Button( + style_classes="app-slot", + child=Box(orientation="h", spacing=12, children=children), + tooltip_text=r.subtitle, + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_calc(self, r: SearchResult) -> Button: + return Button( + style_classes="app-slot calc-result", + child=Box( + orientation="h", + spacing=12, + children=[ + Image( + icon_name=r.icon_name or "accessories-calculator-symbolic", + h_align="start", + size=32, + ), + Box( + orientation="v", + children=[ + Label( + label=r.title, + style_classes="calc-expression", + h_align="start", + ), + Label( + label=r.subtitle, + style_classes="calc-result-text", + h_align="start", + ), + ], + ), + ], + ), + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_emoji(self, r: SearchResult) -> Button: + emoji_char = r.metadata.get("emoji", r.title) + keyword_str = ", ".join(r.metadata.get("keywords", [])[:3]) + return Button( + style_classes="app-slot emoji-slot", + child=Box( + orientation="h", + spacing=12, + children=[ + Label( + label=emoji_char, + style_classes="emoji-char", + h_align="start", + v_align="center", + ), + Box( + orientation="v", + children=[ + Label( + label=r.subtitle, + style_classes="emoji-name", + h_align="start", + v_align="start", + ), + Label( + label=keyword_str, + style_classes="emoji-keywords", + h_align="start", + v_align="start", + ), + ], + ), + ], + ), + tooltip_text=f"Click to copy {emoji_char}", + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_power(self, r: SearchResult) -> Button: + return Button( + style_classes="app-slot power-menu-slot", + child=Box( + orientation="h", + spacing=12, + children=[ + Image(icon_name=r.icon_name, h_align="start", size=32), + Box( + orientation="v", + children=[ + Label( + label=r.title, + style_classes="power-menu-name", + h_align="start", + v_align="start", + ), + Label( + label=r.subtitle, + style_classes="power-menu-description", + h_align="start", + v_align="start", + ), + ], + ), + ], + ), + tooltip_text=f"Click to {r.title.lower()} the system", + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_wallpaper(self, r: SearchResult) -> Button: + thumb = r.metadata.get("thumbnail_path", "") + child_widget = Box(h_expand=True, v_expand=True) + if thumb and os.path.exists(thumb): + child_widget = ( + Box(h_expand=True, v_expand=True) + .build() + .set_style(f"background-image: url('file://{thumb}');", compile=False) + .unwrap() + ) + return Button( + style_classes="app-slot wallpaper", + child=child_widget, + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_clipboard_text(self, r: SearchResult) -> Button: + display = r.title + return Button( + style_classes="app-slot clipboard-text", + child=Box( + orientation="h", + children=[ + Label( + label=display, + style_classes="clipboard-content", + h_align="center", + v_align="center", + max_chars_width=40, + ellipsization="end", + ) + ], + ), + tooltip_text=r.subtitle, + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_clipboard_image(self, r: SearchResult) -> Button: + img_path = r.metadata.get("image_path", "") + child_widget = Box( + orientation="h", + children=[ + Label( + label="Image", + style_classes="clipboard-content", + h_align="center", + v_align="center", + ) + ], + ) + if img_path and os.path.exists(img_path): + child_widget = ( + Box(h_expand=True, v_expand=True) + .build() + .set_style( + f"background-image: url('file://{img_path}');", + compile=False, + ) + .unwrap() + ) + return Button( + style_classes="app-slot wallpaper clipboard-image", + child=child_widget, + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _render_default(self, r: SearchResult) -> Button: + children = [] + if r.icon_name: + children.append(Image(icon_name=r.icon_name, h_align="start", size=32)) + title_box = Box( + orientation="v", + children=[ + Label( + label=r.title, + style_classes="app-title", + h_align="start", + v_align="start", + ) + ], + ) + if r.subtitle: + title_box.add( + Label( + label=r.subtitle, + style_classes="app-description", + h_align="start", + v_align="start", + ) + ) + children.append(title_box) + + return Button( + style_classes="app-slot", + child=Box(orientation="h", spacing=12, children=children), + on_clicked=lambda *_: self._activate_result_by_id(r.id), + ) + + def _activate_result(self, result: SearchResult): + try: + if result.action: + result.action() + except Exception as e: + logger.warning(f"[spotlight] result.action() failed: {e}") + self.launched() + + def _activate_result_by_id(self, result_id: str): + for r in self._current_results: + if r.id == result_id: + self._activate_result(r) + return + + # Viewport management + def _clear_viewport(self): + self._current_results = [] + for child in list(self.viewport.children): + try: + child.disconnect_by_func(self.on_slot_enter) + except (TypeError, ValueError) as e: + logger.warning( + f"[main] child.disconnect_by_func(self.on_slot_enter) failed: {e}" + ) + self.viewport.remove(child) + child.destroy() + self._result_widgets.clear() + + def update_result_subtitle(self, result_id: str, text: str) -> None: + """Update subtitle label of a rendered result in-place (no re-render).""" + widget = self._result_widgets.get(result_id) + if not widget: + return + child = widget.get_child() + if not child: + return + # Walk: Button → main Box → title_box (last Box) → subtitle Label (2nd) + boxes = [c for c in child.get_children() if isinstance(c, Box)] + if not boxes: + return + title_box = boxes[-1] + labels = [c for c in title_box.get_children() if isinstance(c, Label)] + if len(labels) >= 2: + labels[1].set_text(text) + + def _post_arrange(self): + new_height = get_children_height_limit(self.viewport, self.max_children) + if new_height < 1: + self.scrolled_window.animate_size(0) + self.scrolled_clip.hide() + self.scrolled_window.hide() + return + + self.scrolled_clip.show() + self.scrolled_window.show() + self.scrolled_window.animate_size(new_height) + + if self.viewport.children: + self.set_selected_index(0) + + # Selection & keyboard + def on_slot_enter(self, button, event): + children = self.viewport.children + if button in children: + self.set_selected_index(children.index(button)) + + def set_selected_index(self, index: int): + children = self.viewport.children + if not children: + self._selected_index = -1 + return + + if 0 <= self._selected_index < len(children): + children[self._selected_index].remove_style_class("selected") + + self._selected_index = index % len(children) + selected = children[self._selected_index] + selected.add_style_class("selected") + + adj = self.scrolled_window.get_vadjustment() + alloc = selected.get_allocation() + res = selected.translate_coordinates(self.viewport, 0, 0) + if res: + y = res[1] if len(res) == 2 else res[2] + if y < adj.get_value(): + adj.set_value(y) + elif y + alloc.height > adj.get_value() + adj.get_page_size(): + adj.set_value(y + alloc.height - adj.get_page_size()) + + def on_key_press(self, entry, event): + keyval = event.keyval + if keyval == Gdk.KEY_Up or keyval == Gdk.KEY_ISO_Left_Tab: + self.set_selected_index(self._selected_index - 1) + return True + if keyval == Gdk.KEY_Down or keyval == Gdk.KEY_Tab: + self.set_selected_index(self._selected_index + 1) + return True + return False + + # Submit + def on_entry_accept(self, entry: Entry, *_): + if 0 <= self._selected_index < len(self.viewport.children): + widget = self.viewport.children[self._selected_index] + if isinstance(widget, Button): + widget.clicked() + return + + if self._current_results: + self._activate_result(self._current_results[0]) + return + + if self._active_plugin_entry: + text = entry.get_text() + entry.set_text("") + + def _on_immediate_results(results): + if results: + self._activate_result(results[0]) + + self.plugin_service.pipeline.search_single( + self._active_plugin_entry, text, _on_immediate_results + ) + return + + # External commands + def handle_external(self, command: str, text: str = "") -> bool: + if command == "deep_reload" and text: + return self.plugin_service.manager.deep_reload(text.strip()) + + plugin = self.plugin_service.get_plugin(command) + if plugin and hasattr(plugin, "handle_external"): + plugin.handle_external(command, text) + return True + + for pe in self.plugin_service.manager.registry.get_enabled(): + inst = pe.instance + if inst and command in getattr(inst, "keywords", []): + if hasattr(inst, "handle_external"): + inst.handle_external(command, text) + return True + + return False + + +class SpotlightWindow(Window): + def __init__(self, **kwargs): + self.spotlight_box = Spotlight( + on_launched=lambda: self.close_spotlight(), size=(460, -1) + ) + super().__init__( + name="spotlight-window", + title="modus-spotlight", + anchor="top", + margin="80px 0px 0px 0px", + keyboard_mode="exclusive", + child=self.spotlight_box, + visible=False, + all_visible=False, + **kwargs, + ) + + self.build() + self.add_keybinding("Escape", lambda *_: self.close_spotlight()) + + def close_spotlight(self): + self.spotlight_box.plugin_service.pipeline.cancel() + + self.spotlight_box._stop_refresh_timer() + + self.spotlight_box.header_entry.set_text("") + + debounce_id = getattr( + self.spotlight_box, "_debounce_timer_on_entry_changed", None + ) + if debounce_id: + GLib.source_remove(debounce_id) + self.spotlight_box._debounce_timer_on_entry_changed = 0 + + self.spotlight_box._clear_viewport() + self.spotlight_box.scrolled_window.animate_size(0) + self.spotlight_box.scrolled_window.hide() + self.spotlight_box.scrolled_clip.hide() + self.spotlight_box.header_icon.set_from_icon_name("system-search-symbolic") + self.spotlight_box._deactivate_keyword_plugin() + self.spotlight_box.plugin_service.manager.release_memory_all() + self.hide() + + def toggle( + self, command: str | None = None, text: str = "", external: bool = False + ): + if external: + full = f"{command or ''} {text or ''}".strip() + if full: + cmd, rest = ([*full.split(" ", 1), ""])[:2] + self.spotlight_box.handle_external(cmd.strip(), rest.strip()) + return + + if self.get_visible(): + self.close_spotlight() + return + + cmd = (command or "").strip() + txt = (text or "").strip() + + parts = [p for p in [cmd, txt] if p] + prefill = " ".join(parts) + + if cmd and not txt: + prefill += " " + + self.spotlight_box.header_entry.set_text(prefill) + + self.show_all() + self.spotlight_box.scrolled_clip.hide() + self.spotlight_box.scrolled_window.hide() + self.spotlight_box.header_entry.grab_focus() + self.spotlight_box.header_entry.set_position(-1) diff --git a/src/window/spotlight/plugins/__init__.py b/src/window/spotlight/plugins/__init__.py new file mode 100644 index 00000000..b3c9d6a9 --- /dev/null +++ b/src/window/spotlight/plugins/__init__.py @@ -0,0 +1,28 @@ +""" +Modus Launcher plugins package. + +Built-in plugins are auto-discovered via pkgutil. +Each plugin module exposes a PLUGIN variable pointing to its class. +""" + +import importlib +import pkgutil + +from fabric.utils import logger + +__all__ = [] + +for module_info in pkgutil.iter_modules(__path__): + module_name = module_info.name + if module_name.startswith("_"): + continue + try: + module = importlib.import_module(f"{__name__}.{module_name}") + plugin_class = getattr(module, "PLUGIN", None) + if plugin_class is not None: + globals()[module_name] = plugin_class + __all__.append(module_name) + except Exception as e: + logger.warning( + f"[__init__] module = importlib.import_module(f'(__name__).(module_nam... failed: {e}" + ) diff --git a/src/window/spotlight/plugins/application.py b/src/window/spotlight/plugins/application.py new file mode 100644 index 00000000..e73cf93f --- /dev/null +++ b/src/window/spotlight/plugins/application.py @@ -0,0 +1,83 @@ +import time +from typing import Any + +from fabric.utils import DesktopApp, get_desktop_applications, logger + +from utils.functions import fuzzy_score +from window.spotlight.api import SearchResult, SpotlightPlugin + + +class ApplicationPlugin(SpotlightPlugin): + id = "applications" + name = "Applications" + icon = "system-search-symbolic" + keywords: list[str] = [] + searchable = True + priority = 10 + + def __init__(self, context): + super().__init__(context) + self._desktop_apps: list[DesktopApp] = [] + self._apps_loaded_at: float = 0.0 + self._apps_ttl: float = 1.0 + + def initialize(self) -> None: + self._desktop_apps = get_desktop_applications() + + def cleanup(self) -> None: + self._desktop_apps = [] + + def search(self, query: str, token: Any) -> list[SearchResult]: + if not query: + return [] + + # Re-scan (throttled) so newly installed .desktop files appear + # without a restart, while avoiding a disk scan on every keystroke. + now = time.monotonic() + if now - self._apps_loaded_at > self._apps_ttl: + try: + self._desktop_apps = get_desktop_applications() + self._apps_loaded_at = now + except Exception as e: + logger.warning( + f"[application] self._desktop_apps = get_desktop_applications() failed: {e}" + ) + + results: list[SearchResult] = [] + for app in self._desktop_apps: + display_name = getattr(app, "display_name", "") or "" + if not display_name: + continue + + raw_score = fuzzy_score(query, display_name) + if raw_score <= 0: + continue + + score = raw_score / 100.0 + + results.append( + SearchResult( + id=getattr(app, "desktop_id", "") or display_name, + title=display_name, + subtitle=getattr(app, "description", "") or "", + icon_name="", + score=score, + action=lambda a=app: self._launch_app(a), + render_type="app", + plugin_name=self.name, + plugin_id=self.id, + metadata={"desktop_app": app}, + ) + ) + + results.sort(key=lambda r: r.score, reverse=True) + return results[:10] + + @staticmethod + def _launch_app(app: DesktopApp) -> None: + from window.globalmenu.launch import launch_desktop_app + + launch_desktop_app(app) + + +PLUGIN = ApplicationPlugin diff --git a/src/window/spotlight/plugins/caffeine.py b/src/window/spotlight/plugins/caffeine.py new file mode 100644 index 00000000..4e78cfc9 --- /dev/null +++ b/src/window/spotlight/plugins/caffeine.py @@ -0,0 +1,269 @@ +import os +import signal +import subprocess + +from fabric.utils import logger + +from shared.capture import CaptureNotifier +from utils.functions import format_duration, parse_timeout_string +from window.spotlight.api import SearchResult, SpotlightPlugin + +_PID_FILE = os.path.join( + os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "modus-caffeine.pid" +) +_INHIBIT_WHAT = "idle:sleep" +_INHIBIT_WHY = "Modus Caffeine" +_INDEFINITE_SECS = 100_000_000 + + +_notifier = CaptureNotifier(app_name="Modus Caffeine") + + +class CaffeinePlugin(SpotlightPlugin): + id = "caffeine" + name = "Caffeine" + icon = "user-available-symbolic" + keywords = ["caffeine", "caff"] + searchable = True + priority = 90 + + def initialize(self) -> None: + pass + + def cleanup(self) -> None: + pass + + # --- state helpers ------------------------------------------------- + + def _read_pid(self) -> int | None: + try: + with open(_PID_FILE) as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None + + def _is_our_pid(self, pid: int) -> bool: + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + data = f.read().decode(errors="ignore") + return "systemd-inhibit" in data and _INHIBIT_WHY in data + except OSError: + return False + + def _write_pid(self, pid: int) -> None: + try: + with open(_PID_FILE, "w") as f: + f.write(str(pid)) + except OSError as e: + logger.warning(f"[Caffeine] cannot record pid: {e}") + + def _kill_existing(self) -> None: + pid = self._read_pid() + if pid and self._is_our_pid(pid): + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + try: + os.unlink(_PID_FILE) + except OSError: + pass + + @staticmethod + def is_active() -> bool: + """Check if caffeine inhibition is currently active.""" + try: + with open(_PID_FILE) as f: + pid = int(f.read().strip()) + except (OSError, ValueError): + return False + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + data = f.read().decode(errors="ignore") + return "systemd-inhibit" in data and _INHIBIT_WHY in data + except OSError: + return False + + # --- duration parsing ---------------------------------------------- + + @staticmethod + def _parse_duration(text: str) -> int | None: + seconds = parse_timeout_string(text, in_seconds=True) + return seconds if seconds != 5 else None # 5 is the default on parse failure + + @staticmethod + def _humanize(secs: int) -> str: + result = format_duration(secs) + if result == "N/A": + return "0s" + if "h" in result and "m" in result: + hours = result.split("h")[0] + return f"{hours}h" + return result + + # --- inhibition ---------------------------------------------------- + + def _spawn(self, seconds: int) -> None: + self._kill_existing() + cmd = [ + "systemd-inhibit", + f"--what={_INHIBIT_WHAT}", + f"--why={_INHIBIT_WHY}", + "--mode=block", + "sleep", + str(seconds), + ] + try: + proc = subprocess.Popen(cmd) + except FileNotFoundError: + logger.error("[Caffeine] systemd-inhibit not found on this system") + return + except Exception as e: + logger.error(f"[Caffeine] failed to start inhibitor: {e}") + return + self._write_pid(proc.pid) + + def _apply(self, arg: str) -> None: + arg = (arg or "").strip().lower() + was_active = self.is_active() + if arg in ("", "on"): + self._spawn(_INDEFINITE_SECS) + if not was_active: + _notifier.notify( + "Caffeine Enabled", + "Stay awake until turned off", + icon="user-available-symbolic", + ) + return + if arg == "off": + self._kill_existing() + if was_active: + _notifier.notify( + "Caffeine Disabled", + "Idle and sleep allowed again", + icon="user-away-symbolic", + ) + return + secs = self._parse_duration(arg) + if secs is not None: + self._spawn(secs) + if not was_active: + _notifier.notify( + "Caffeine Enabled", + f"Stay awake for {self._humanize(secs)}", + icon="user-available-symbolic", + ) + return + logger.warning(f"[Caffeine] unknown argument: {arg!r}") + + # --- search surface ------------------------------------------------ + + def _result( + self, + rid: str, + title: str, + subtitle: str, + action, + active: bool, + ) -> SearchResult: + suffix = " — active" if active and rid != "caffeine_off" else "" + return SearchResult( + id=rid, + title=title + suffix, + subtitle=subtitle, + icon_name="user-available-symbolic" if active else "user-away-symbolic", + score=100.0, + action=action, + render_type="default", + plugin_name=self.name, + plugin_id=self.id, + ) + + def _mk(self, label: str, title: str, subtitle: str, active: bool) -> SearchResult: + return self._result( + f"caffeine_{label}", + title, + subtitle, + (lambda a=label: self._apply(a)), + active, + ) + + def _duration_subtitle(self, secs: int, active: bool) -> str: + if active: + return "Caffeine active — re-select to restart timer" + return f"Inhibits idle/sleep for {self._humanize(secs)}" + + def _menu(self, active: bool) -> list[SearchResult]: + presets = [("5m", 300), ("15m", 900), ("30m", 1800), ("1h", 3600)] + results = [ + self._mk("on", "Enable Caffeine", "Stay awake until turned off", active), + self._mk("off", "Disable Caffeine", "Allow idle and sleep again", active), + ] + results += [ + self._mk( + label, + f"Caffeine for {label}", + self._duration_subtitle(secs, active), + active, + ) + for label, secs in presets + ] + return results + + def search(self, query: str, token) -> list[SearchResult]: + q = query.strip().lower() + for kw in ("caffeine ", "caff "): + if q.startswith(kw): + q = q[len(kw) :].strip() + break + + active = self.is_active() + + if not q or q in ("caffeine", "caff"): + return self._menu(active) + + if q == "off": + return [ + self._mk( + "off", "Disable Caffeine", "Allow idle and sleep again", active + ) + ] + if q == "on": + return [ + self._mk("on", "Enable Caffeine", "Stay awake until turned off", active) + ] + + secs = self._parse_duration(q) + if secs is not None: + return [ + self._mk( + q, + f"Caffeine for {q}", + self._duration_subtitle(secs, active), + active, + ) + ] + + return [ + self._result( + "caffeine_help", + "Caffeine", + "Usage: caffeine on | off |