diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4da82ec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig helps maintain consistent coding styles +root = true + +[*.toml] +indent_style = space +indent_size = 2 +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +indent_style = space +indent_size = 2 +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[PKGBUILD] +indent_style = space +indent_size = 2 +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/4user.md b/.github/4user.md new file mode 100644 index 0000000..59bd0d2 --- /dev/null +++ b/.github/4user.md @@ -0,0 +1,20 @@ +# Reality Check (for the user of Agent) + +Writing strict rules is cheap. +Following them is not. + +If you: + +* ignore uncomfortable outputs +* selectively apply this standard +* treat this as “motivation” instead of enforcement + +then this file is decorative and useless. + +The value comes only if: + +* bad ideas are actually killed +* scope is actually reduced +* decisions are actually finalized + +No execution → zero return. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..766451d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,91 @@ +## AGENTS / AI Behavior Contract + +### Role + +You are not an assistant. +You are a **critical reviewer, strategic advisor, and intellectual mirror**. + +### Core Principle + +Truth > Comfort. +Progress > Politeness. + +### Required Behavior + +* No agreement, validation, encouragement, or flattery +* No softening language, emotional cushioning, or reassurance +* Be direct, rational, and unfiltered + +### Thinking Discipline + +* Challenge assumptions aggressively +* Decompose weak reasoning and expose logical gaps +* Detect and call out: + + * self-justification + * avoidance + * procrastination disguised as planning + * overengineering without payoff + +### Action Orientation + +* Never stop at abstract advice +* Convert critique into **specific next actions** +* Explicitly state: + + * costs + * risks + * trade-offs + +### Time & Opportunity Cost + +* Identify low-leverage work and wasted effort +* Explain opportunity cost structurally or quantitatively +* If the current path is weak, say so clearly + +### Perspective + +* Optimize for system-level outcomes, not local wins +* Favor long-term leverage over short-term comfort +* If the reason for doing something is weak, reject it + +### Plan Mode (Mandatory) + +* Extreme concision; compression over grammar +* Ordered by execution priority +* End with a list of **Unresolved Questions** + +### Prohibited Output + +* Praise or affirmation (“good idea”, “sounds reasonable”) +* Unwarranted optimism +* Polite filler or hedging language + +### Success Criteria + +* Output may be uncomfortable but forces progress +* Weak thinking is eliminated +* Decisions become faster and more deliberate + + +## Decision Heuristics for AI Agents + +When unsure: + +* Prefer **simplicity over flexibility** +* Prefer **explicit data flow over abstraction** +* Prefer **fewer features done well over broad scope** + +Before adding: + +* Ask: does this improve keybind inspection? +* If not, reject it. + +## Failure Modes to Actively Avoid + +* Overengineering UI state +* Premature abstraction +* “Future-proofing” without concrete requirements +* Adding config options to avoid making decisions + +Call these out explicitly if detected. diff --git a/.github/instructions/directory.instructions.md b/.github/instructions/directory.instructions.md new file mode 100644 index 0000000..f6464c5 --- /dev/null +++ b/.github/instructions/directory.instructions.md @@ -0,0 +1,197 @@ +## Directory Strategy (Enforced, Do Not Randomize) + +This directory structure encodes **responsibility boundaries**. +Violating them is a design error, even if the code works. + +``` +src/ + main.rs // Entry point only. No logic. + app.rs // Application orchestration and global state. + + cli.rs // CLI argument definitions (clap derive). + + config/ // Persistent user configuration + mod.rs + user.rs // UserConfig (serializable, stable preferences only) + paths.rs // config_dir, export_dir, path resolution + + hyprland/ // Hyprland-specific integration + mod.rs + source.rs // IO boundary: hyprctl invocation, raw text retrieval + parser.rs // Pure parsing: raw text -> domain models + models.rs // KeyBindEntry, KeyBindings (data only) + + ui/ // UI layer (egui only) + mod.rs + header.rs + table.rs + options.rs + zen.rs + types.rs // Theme, ColumnVisibility (UI-only state) + styling/ + mod.rs + css.rs // Visual styling definitions (minimal, egui-aligned) + fonts.rs // Font configuration + icons.rs // Icon mapping + + tests/ + parser_basic.rs // Happy path parsing + parser_edge.rs // Corrupted input & edge cases + config_roundtrip.rs // UserConfig stability & serialization +``` + +## Mandatory Responsibility Rules + +### `main.rs` + +* Starts the application +* No business logic +* No configuration loading +* No UI logic + +--- + +### `app.rs` + +Role: **Orchestrator, not a worker** + +Allowed: + +* Owns global application state +* Coordinates: + + * config loading + * keybind fetching + * UI state updates +* Decides *what happens next* + +Forbidden: + +* Parsing logic +* File IO details +* hyprctl invocation +* egui widget layout + +Rule: + +> app.rs decides, others execute. + +### `config/` + +Scope: **Persistent user preferences only** + +`UserConfig` may include: + +* Theme selection +* Column visibility +* Search preference toggles +* Persisted UI modes (e.g. zen mode) + +Must NOT include: + +* Transient UI state +* Search results +* Parsed keybind data +* Session-only flags + +Rule: + +> If it does not survive restarts meaningfully, it does not belong here. + + +### `hyprland/source.rs` + +Role: **IO boundary** + +Responsibilities: + +* Execute `hyprctl` +* Handle process errors +* Return raw output as `String` + +Must NOT: + +* Parse +* Interpret +* Transform into domain structures + +Rule: + +> `std::process::Command` lives here and nowhere else. + +### `hyprland/parser.rs` + +Role: **Pure transformation** + +Responsibilities: + +* Convert raw text into domain models +* Be deterministic and side-effect free +* Be fully testable with string inputs + +Must NOT: + +* Perform IO +* Call external commands +* Depend on runtime environment + +Rule: + +> If it can’t be unit-tested with a string literal, it doesn’t belong here. + +### `hyprland/models.rs` + +Role: **Domain data** + +* Plain structs and enums +* No IO +* No UI +* No parsing logic + +Rule: + +> Data only. No behavior creep. + +### `ui/` + +Scope: **Presentation only** + +Responsibilities: + +* egui widgets and layout +* Visual state handling +* Theme and styling application + +Must NOT: + +* Call hyprctl +* Parse keybinds +* Read or write config files + +Rule: + +> UI renders state; it does not create it. + +## Structural Failure Modes to Reject + +* IO mixed into parsing +* app.rs growing into a god object +* config used as a dumping ground +* UI logic leaking into domain or parsing +* “Temporary” shortcuts becoming permanent + +Call these out explicitly when detected. + +--- + +## Decision Heuristic for AI Agents + +When placing code: + +1. Is this IO? → `source.rs` +2. Is this parsing? → `parser.rs` +3. Is this data? → `models.rs` +4. Is this orchestration? → `app.rs` +5. Is this presentation? → `ui/` + +If it fits multiple answers, the design is wrong. diff --git a/.github/instructions/packaging.instructions.md b/.github/instructions/packaging.instructions.md new file mode 100644 index 0000000..5c0fef4 --- /dev/null +++ b/.github/instructions/packaging.instructions.md @@ -0,0 +1,82 @@ +--- +applyTo: "assets/**,PKGBUILD,cargo.toml,LICENSE.**,README.md" +excludeAgent: ["code-review"] +--- + +# AGENT RULE — Release Packaging +## Supported Package Formats + +* **Only** the following packaging formats are supported: + + * Arch Linux package (`PKGBUILD`) + * Cargo package (`crates.io`) +* Other packaging formats **must not** be introduced or modified. + +## Arch Linux Packaging Rules (`PKGBUILD`) + +* `PKGBUILD` **must** match the current project release version exactly. +* All runtime and build dependencies **must** be explicitly declared. +* The file **must** follow Arch Linux packaging standards. +* The following metadata **must** be present and accurate: + + * `pkgname` + * `pkgver` + * `pkgrel` + * `pkgdesc` + * `arch` + * `license` + * `url` + * `source` +* The package **must** build and install successfully using: + + ```sh + makepkg + ``` + +## Cargo Packaging Rules (`crates.io`) + +* `Cargo.toml` **must** contain accurate metadata: + + * `name` + * `version` + * `authors` + * `license` + * `description` + * `repository` +* Dependencies **must** be intentional and compatible. +* `README.md` **must** be included and referenced from `Cargo.toml`. +* The package **must** pass validation using: + + ```sh + cargo package + ``` +* Versioning and dependency management **must** follow Cargo best practices. + +## Licensing Rules + +* All applicable `LICENSE` files **must** be included in the repository and in release artifacts. +* The declared license **must** accurately reflect the project’s licensing terms. +* All third-party assets and dependencies **must** be license-compatible. +* Required attributions and license notices **must** be preserved. + +## Asset Rules + +* All required assets **must** reside under the `assets/` directory. +* Required runtime assets **must not** be omitted from release packages. +* Assets **should** be optimized for size and performance. +* The asset directory structure **must** remain clear and intentional. + +## Documentation Rules + +* `README.md` **must** include: + + * Installation instructions + * Usage information + * Project purpose +* Documentation **must** be updated to reflect packaging or release changes. +* Additional documentation **must** be included when required for users or contributors. + +### Notes + +* These rules apply **only** to release packaging scope. +* Any change violating a **MUST / MUST NOT** rule is considered **release-blocking**. diff --git a/.github/instructions/project.instructions.md b/.github/instructions/project.instructions.md new file mode 100644 index 0000000..d323bad --- /dev/null +++ b/.github/instructions/project.instructions.md @@ -0,0 +1,68 @@ +## Project Scope (For AI Agents) + +This project builds a **GUI application to inspect Hyprland keybindings**. + +Primary goal: + +* Accurately parse, represent, and display Hyprland keybinds +* Provide a fast, minimal, non-bloated GUI for inspection and filtering + +This is **not** a general-purpose config manager. +This is **not** a theming playground. +If functionality does not directly support keybind inspection, question it. + +## Technology Constraints (Non-Negotiable) + +### Language + +* Rust (2024 edition) +* **Stable channel only** +* No nightly features +* No experimental compiler flags + +### Tooling (Defined in `/rust-toolchain.toml`) + +Required usage: + +* `rustfmt` — formatting (no manual style deviations) +* `clippy` — linting (warnings treated as design feedback) +* `rust-analyzer` — LSP +* `rust-docs` +* `rust-src` +* `cargo-audit` — dependency security + +Ignoring these tools is considered a defect, not a preference. + +## Dependency & Framework Choices + +* Dependency management: **Cargo only** +* GUI: + + * `egui` — immediate mode GUI + * `eframe` — application framework +* CLI argument parsing: `clap` +* Testing: Rust built-in test framework +* CI/CD: GitHub Actions + +Do **not** introduce alternative UI frameworks, async runtimes, or CLI parsers without explicit justification. + +## Code Quality Rules + +* Formatting: `cargo fmt` (mandatory) +* Linting: `cargo clippy` (fix, don’t silence) +* Tests: required for logic-heavy components +* Security: `cargo audit` must pass + +If code “works” but violates these, it is still wrong. + +## Standard Commands (Expected Knowledge) + +* Run (dev): `cargo run` +* Build (dev): `cargo build` +* Build (release): `cargo build --release` +* Format: `cargo fmt` +* Lint: `cargo clippy` +* Test: `cargo test` +* Security audit: `cargo audit` + +Do not suggest alternative workflows unless they improve leverage measurably. diff --git a/.github/instructions/rust-library.instructions.md b/.github/instructions/rust-library.instructions.md new file mode 100644 index 0000000..88f816e --- /dev/null +++ b/.github/instructions/rust-library.instructions.md @@ -0,0 +1,29 @@ +** +applyTo: "**" +excludeAgent: "code-review" +** + +# When analyzing or using a Rust crate: + +1. Never guess API behavior. +2. Always prioritize official sources: + - crates.io README + - GitHub README + - docs.rs documentation +3. Treat docs.rs as the specification. + - Documented behavior is guaranteed. + - Undocumented behavior is not guaranteed. +4. Treat types, lifetimes, and trait bounds as usage rules. + - If the type system forbids it, the usage is invalid. +5. Follow documented examples, tests, and examples directories. + - Shown patterns are canonical. +6. When unsafe is involved: + - Read the Safety section. + - Explicitly state invariants and caller responsibilities. +7. If documentation conflicts with code: + - README < docs.rs < public implementation. +8. If behavior is unclear: + - State “not guaranteed” instead of speculating. + +Principle: +In Rust, documented behavior defines correctness. diff --git a/.github/instructions/rust.instructions.md b/.github/instructions/rust.instructions.md new file mode 100644 index 0000000..350f4be --- /dev/null +++ b/.github/instructions/rust.instructions.md @@ -0,0 +1,61 @@ +--- +applyTo: '**/*.rs, Cargo.toml' +--- + +# 🦀 Rust Development & Parallel Execution Protocol + +This protocol defines the mandatory operational standards for Rust development. It prioritizes memory safety, ownership-aware design, and the maximization of Cargo’s parallel execution capabilities. + +## 🚨 CRITICAL: THE "ONE-MESSAGE" RULE + +**1 MESSAGE = ALL MEMORY-SAFE OPERATIONS** +All Rust-related tasks must be executed concurrently and presented in a single, comprehensive response. Do not fragment code, testing, and optimization into separate steps. + +### 🔴 Mandatory Post-Edit Workflow + +Immediately following any code modification, the following sequence must be simulated or executed as a batch: + +1. **`cargo fmt --all`**: Ensure strict adherence to style guidelines. +2. **`cargo clippy`**: Perform deep static analysis for idiomatic Rust. +3. **`cargo test --all`**: Execute the entire test suite in parallel. +4. **`cargo build`**: Final verification of compilation and dependency integrity. + +## 🏗 Core Design Principles + +| Principle | Requirement | +| --- | --- | +| **Ownership & Borrowing** | Enforce strict memory safety without a garbage collector. Minimize `unsafe` blocks. | +| **Concurrency** | Leverage `async/await`, `Tokio`, or `Rayon` for thread-safe, high-performance execution. | +| **Zero-Cost Abstractions** | Utilize traits and generics to ensure high-level code compiles to optimal machine code. | +| **Error Handling** | Mandatory use of `Result` and `Option`. Avoid `unwrap()` in production-ready code. | + +## 🛠 Concurrent Execution Patterns + +### 1. Cargo Operations + +* **Parallel Commands**: Always batch `cargo build`, `cargo test`, and `cargo run` into single-call instructions. +* **Crate Management**: Install and update dependencies in batches to optimize lockfile generation. + +### 2. Memory-Safe Coordination + +* **Ownership Management**: Ensure all code snippets demonstrate correct lifetime annotations and borrowing patterns to prevent data races at compile time. +* **Async Implementation**: Use `Tokio` for I/O-bound tasks and `Rayon` for CPU-bound parallel iterators. + +## 📚 Recommended Tech Stack & Tools + +* **Runtime**: `Tokio` (Async), `Rayon` (Parallelism) +* **Web & API**: `Axum`, `Actix-web`, `Serde` (Serialization) +* **Database**: `SQLx`, `Diesel` +* **Utilities**: `Anyhow` (Error handling), `Clap` (CLI), `Itertools` +* **Analysis**: `cargo-expand`, `cargo-audit`, `cargo-flamegraph` (Profiling) + +## 🎯 Final Goal: Production-Ready Output + +The Agent must not simply provide "working" code. The output must be: + +* **Formatted** (fmt) +* **Idiomatic** (clippy) +* **Verified** (test) +* **Optimized** (build) + +**Remember:** Rust's power comes from its ability to be both safe and fast. Always leverage the compiler as your primary tool for ensuring parallel execution integrity. diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000..77cf27c --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,74 @@ +--- +applyTo: '**/tests/**, **/*.rs' +--- + +# 🦀 Rust Test Strategy: Boundary-Focused Protocol + +## 🚨 CRITICAL: TESTING PHILOSOPHY + +**"Testing everything" is a mistake.** +Tests are not for "peace of mind"; they are **weapons used to freeze the design and defend critical boundaries.** Eliminate low-ROI tests (UI, complex IO mocking). Instead, focus on these three non-negotiables: + +1. **Parsing Integrity**: Detect breaks caused by Hyprland output changes immediately. +2. **Responsibility Boundaries**: Ensure logic is never polluted by IO or UI. +3. **Critical Regressions**: Defend user configuration and core state transitions. + +## 🛠 Module-Specific Execution Rules + +### 1. `hyprland/parser.rs` — High Priority (100% Pure Test) + +This is the most critical logic. It must be kept pure and tested ruthlessly. + +* **Input**: `&str` (using real-world `fixtures`). +* **Output**: `KeyBindings` or `Result`. +* **Prohibited**: No `Command`, `File System`, or `Env` access inside these functions. +* **Mandatory Cases**: +* Real `hyprctl` output stored in `tests/fixtures/`. +* Edge cases: Empty lines, unknown fields, corrupted formatting. + +### 2. `hyprland/source.rs` — Minimal (Error Handling Only) + +Do not over-engineer tests here. + +* **Reasoning**: This is OS/Environment dependent. CI will likely fail, and mocking is a maintenance nightmare. +* **Strategy**: Test error handling only (e.g., binary missing). If it "works" in a live environment, that is sufficient. + +### 3. `config/user.rs` — Stability (Roundtrip Tests) + +Defend against breaking changes in serialization. + +* **Mandatory Test**: `Default` -> `Save` -> `Load` -> `Assert Equal`. +* **Goal**: Ensure field defaults are maintained and prevent regression during future versioning/migrations. + +### 4. `app.rs` — Design Check (State Transitions Only) + +* **Rule**: Generally, do not test `app.rs`. If you feel the need to, your design is likely too coupled. +* **Exception**: If there is complex state logic, extract it into a pure function: +`fn next_mode(current: Mode, action: Action) -> Mode` +Test that pure function only. + +### 5. `ui/` — DO NOT TEST. PERIOD. + +`egui` UI testing is fragile, heavy, and offers low ROI. + +* **Alternative**: Secure the `state`, `config`, and `parser`. If the underlying data is correct, the UI is for human eyes to verify. + +## 📂 Directory Structure Convention + +```text +tests/ + ├── parser_basic.rs # Happy path parsing + ├── parser_edge.rs # Corrupted input & edge cases + └── config_roundtrip.rs # UserConfig stability & serialization + +``` + +## 🚀 Agent Plan Mode (Compressed) + +The Agent must verify these points before submitting any code: + +1. **Lock Parser behind pure tests**: Is the logic separated from IO? +2. **Use real fixtures**: Are we using actual `hyprctl` output? +3. **Minimal IO tests**: Are we avoiding "Mock Hell"? +4. **Roundtrip UserConfig**: Is the configuration persistence guaranteed? +5. **No UI tests**: Are we avoiding low-ROI automation? diff --git a/Cargo.lock b/Cargo.lock index 75b679c..2c82fb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,8 +4,9 @@ version = 4 [[package]] name = "HyprBind" -version = "0.1.4-alpha" +version = "0.1.4" dependencies = [ + "clap", "eframe", "egui", "egui_extras", @@ -76,6 +77,56 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "arboard" version = "3.6.1" @@ -284,6 +335,46 @@ dependencies = [ "libc", ] +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -302,6 +393,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -900,6 +997,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1038,6 +1141,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -1578,6 +1687,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.50" @@ -2042,6 +2157,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.114" @@ -2226,6 +2347,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 06914ce..daac479 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "HyprBind" -version = "0.1.4-alpha" +version = "0.1.4" edition = "2024" license = "MIT AND OFL-1.1" authors = ["Ry2X <45420571+ry2x@users.noreply.github.com>"] @@ -23,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0.149", default-features = false, features = [ "std", ] } +clap = { version = "4.5", features = ["derive"] } [profile.release] opt-level = 3 @@ -47,4 +48,4 @@ unwrap_used = "warn" float_cmp = "warn" # Allow many arguments in functions for GUI code -#too_many_arguments = "allow" \ No newline at end of file +#too_many_arguments = "allow" diff --git a/PKGBUILD b/PKGBUILD index 07fee0e..7f26b41 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -2,7 +2,7 @@ pkgname='hyprbind' _pkgname='HyprBind' -pkgver='0.1.4_alpha' +pkgver='0.1.4' pkgrel=1 pkgdesc='A GUI to display Hyprland keybindings' arch=('x86_64' 'aarch64') diff --git a/README.md b/README.md index 6c6752b..7db631f 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,6 @@ bind = SUPER, F, exec, thunar # description is empty ## License -SourceCode: MIT. +SourceCode: MIT Font: SIL OFL 1.1 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index cb549b9..cf7b2fd 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -6,10 +6,10 @@ components = [ "clippy", "rust-analysis", "rust-docs", - "rust-src" + "rust-src", ] targets = [ "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu" -] \ No newline at end of file +] diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 4943114..0000000 --- a/src/app.rs +++ /dev/null @@ -1,340 +0,0 @@ -use crate::models::{KeyBindings, SearchOptions}; -use crate::parser::parse_hyprctl_binds; -use crate::ui::{ColumnVisibility, SortColumn, SortState, Theme}; -use eframe::egui; -use std::cmp::Ordering; - -pub struct KeybindsApp { - keybindings: KeyBindings, - search_query: String, - error_message: Option, - search_options: SearchOptions, - sort_column: SortColumn, - sort_state: SortState, - show_options_window: bool, - theme: Theme, - column_visibility: ColumnVisibility, - logo_texture: Option, - zen_mode: bool, - show_zen_info_modal: bool, - selected_row: Option, - export_request: bool, - export_modal_path: Option, - last_css_mtime: Option, -} - -impl KeybindsApp { - pub fn new() -> Self { - let (keybindings, error_message) = match parse_hyprctl_binds() { - Ok(keybindings) => (keybindings, None), - Err(e) => ( - KeyBindings::new(), - Some(format!("Failed to load keybindings: {}", e)), - ), - }; - let mut app = Self { - keybindings, - search_query: String::new(), - error_message, - search_options: SearchOptions::default(), - sort_column: SortColumn::Keybind, - sort_state: SortState::None, - show_options_window: false, - theme: Theme::Dark, - column_visibility: ColumnVisibility::default(), - logo_texture: None, - zen_mode: false, - show_zen_info_modal: false, - selected_row: None, - export_request: false, - export_modal_path: None, - last_css_mtime: None, - }; - if let Some(cfg) = crate::config::load() { - app.theme = cfg.theme; - app.column_visibility = cfg.column_visibility; - app.search_options = cfg.search_options; - app.zen_mode = cfg.zen_mode; - } - app - } - - fn handle_sort_click(&mut self, column: SortColumn) { - if self.sort_column == column { - self.sort_state = match self.sort_state { - SortState::Ascending => SortState::Descending, - SortState::Descending => SortState::None, - SortState::None => SortState::Ascending, - }; - } else { - self.sort_column = column; - self.sort_state = SortState::Ascending; - } - } - - fn get_filtered_and_sorted_entries(&self) -> Vec { - let mut filtered: Vec<_> = self - .keybindings - .filter(&self.search_query, &self.search_options) - .into_iter() - .cloned() - .collect(); - - if self.sort_state != SortState::None { - match self.sort_column { - SortColumn::Description => { - filtered.sort_by(|a, b| a.description.cmp(&b.description)) - } - SortColumn::Keybind => filtered.sort_by(|a, b| { - let mod_cmp = a.modifiers.cmp(&b.modifiers); - if mod_cmp == Ordering::Equal { - a.key.cmp(&b.key) - } else { - mod_cmp - } - }), - SortColumn::Command => filtered.sort_by(|a, b| a.command.cmp(&b.command)), - } - if self.sort_state == SortState::Descending { - filtered.reverse(); - } - } - - filtered - } -} - -impl eframe::App for KeybindsApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Load logo texture if not already loaded - if self.logo_texture.is_none() { - let logo_bytes = include_bytes!("../assets/logo_hyprbind.png"); - if let Ok(image) = image::load_from_memory(logo_bytes) { - let size = [image.width() as usize, image.height() as usize]; - let image_buffer = image.to_rgba8(); - let pixels = image_buffer.as_flat_samples(); - let color_image = egui::ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()); - self.logo_texture = - Some(ctx.load_texture("logo", color_image, egui::TextureOptions::LINEAR)); - } - } - - // ZEN mode keyboard shortcuts with autosave - let prev_zen = self.zen_mode; - crate::ui::zen::handle_zen_keyboard_shortcuts(ctx, &mut self.zen_mode); - if self.zen_mode != prev_zen { - let cfg = crate::config::UserConfig { - theme: self.theme, - column_visibility: self.column_visibility.clone(), - search_options: self.search_options.clone(), - zen_mode: self.zen_mode, - }; - let _ = crate::config::save(&cfg); - } - - // Keyboard shortcuts - let search_bar_focused = ctx.memory(|m| m.focused() == Some(egui::Id::new("search_bar"))); - - // Slash key to focus search bar (only when not in ZEN mode) - if !self.zen_mode && !search_bar_focused && ctx.input(|i| i.key_pressed(egui::Key::Slash)) { - ctx.memory_mut(|m| m.request_focus(egui::Id::new("search_bar"))); - // Consume the slash event so it doesn't get typed - ctx.input_mut(|i| { - i.events - .retain(|e| !matches!(e, egui::Event::Text(s) if s == "/")); - }); - } - - // Apply theme or auto-reload CSS when present - if crate::css::has_custom_theme() { - let path = crate::css::default_css_path(); - if let Ok(meta) = std::fs::metadata(&path) { - if let Ok(modified) = meta.modified() { - let changed = match self.last_css_mtime { - Some(prev) => modified > prev, - None => true, - }; - if changed { - let _ = crate::css::apply_from_path(ctx, &path.to_string_lossy()); - self.last_css_mtime = Some(modified); - } - } - } - } else { - match self.theme { - Theme::Dark => ctx.set_visuals(egui::Visuals::dark()), - Theme::Light => ctx.set_visuals(egui::Visuals::light()), - } - } - - // ZEN mode info modal - if self.show_zen_info_modal { - crate::ui::zen::render_zen_info_modal( - ctx, - &mut self.show_zen_info_modal, - &mut self.show_options_window, - ); - if ctx.input(|i| i.key_pressed(egui::Key::Z)) { - self.show_zen_info_modal = false; - } - } - - // Close options with ESC - if self.show_options_window && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.show_options_window = false; - } - - // Options window as separate OS window (egui viewport) - let options_viewport_id = egui::ViewportId::from_hash_of("options"); - if self.show_options_window { - ctx.show_viewport_immediate( - options_viewport_id, - egui::ViewportBuilder::default() - .with_title("HyprBind – Options") - .with_resizable(true) - .with_min_inner_size([400.0, 420.0]) - .with_inner_size([520.0, 560.0]), - |vctx, _class| { - if vctx.input(|i| i.viewport().close_requested()) { - self.show_options_window = false; - } - egui::CentralPanel::default().show(vctx, |ui| { - let prev_zen = self.zen_mode; - crate::ui::options::render_options_contents( - vctx, - ui, - &mut self.theme, - &mut self.column_visibility, - &mut self.search_options, - &mut self.zen_mode, - &mut self.show_zen_info_modal, - &mut self.export_request, - ); - if !prev_zen && self.zen_mode { - self.show_options_window = false; - } - }); - }, - ); - } else { - // No options viewport when flag is false - } - - // Handle export request and show result modal - if self.export_request { - self.export_request = false; - if let Ok(json) = self.keybindings.to_json() { - let dir = crate::config::export_dir(); - let _ = std::fs::create_dir_all(&dir); - let epoch = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let file_name = format!("keybindings_{}.json", epoch); - let path = dir.join(file_name); - if std::fs::write(&path, json).is_ok() { - self.export_modal_path = Some(path.to_string_lossy().to_string()); - } - } - } - if let Some(ref path) = self.export_modal_path.clone() { - egui::Window::new("Exported") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.vertical_centered(|ui| { - ui.label("JSON has been exported to:"); - ui.monospace(path); - ui.add_space(10.0); - if ui.button("OK").clicked() - || ctx.input(|i| { - i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Escape) - }) - { - self.export_modal_path = None; - } - }); - }); - } - - egui::CentralPanel::default().show(ctx, |ui| { - // Only render header, search bar, and stats in non-ZEN mode - if !self.zen_mode { - // Render header - crate::ui::header::render_header( - ui, - &mut self.show_options_window, - &self.error_message, - self.logo_texture.as_ref(), - ); - - // Render search bar - crate::ui::header::render_search_bar(ui, &mut self.search_query); - - // Render stats bar - let filtered = self.get_filtered_and_sorted_entries(); - crate::ui::header::render_stats_bar( - ui, - self.keybindings.entries.len(), - filtered.len(), - ); - } - - // Get filtered and sorted keybindings - let filtered = self.get_filtered_and_sorted_entries(); - - // Keyboard navigation for table (when search/options not focused) - let search_bar_focused = - ctx.memory(|m| m.focused() == Some(egui::Id::new("search_bar"))); - if !search_bar_focused && !self.show_options_window { - let len = filtered.len(); - if len > 0 { - let mut sel = self.selected_row.unwrap_or(0); - let mut changed = false; - if ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)) { - sel = (sel + 1).min(len - 1); - changed = true; - } - if ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)) { - sel = sel.saturating_sub(1); - changed = true; - } - if ctx.input(|i| i.key_pressed(egui::Key::PageDown)) { - sel = (sel + 10).min(len - 1); - changed = true; - } - if ctx.input(|i| i.key_pressed(egui::Key::PageUp)) { - sel = sel.saturating_sub(10); - changed = true; - } - if ctx.input(|i| i.key_pressed(egui::Key::Home)) { - sel = 0; - changed = true; - } - if ctx.input(|i| i.key_pressed(egui::Key::End)) { - sel = len - 1; - changed = true; - } - if changed { - self.selected_row = Some(sel); - } - } else { - self.selected_row = None; - } - } - - // Render table - if let Some(clicked_column) = crate::ui::table::render_table( - ui, - &filtered, - &self.column_visibility, - self.sort_column, - self.sort_state, - self.selected_row, - ) { - self.handle_sort_click(clicked_column); - } - }); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..e5a8b70 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,267 @@ +pub mod sorting; +mod state; + +pub use state::AppState; + +use crate::ui::SortColumn; +use eframe::egui; + +pub struct KeybindsApp { + state: AppState, +} + +impl KeybindsApp { + pub fn new() -> Self { + Self { + state: AppState::new(), + } + } + + fn handle_sort_click(&mut self, column: SortColumn) { + let (new_column, new_state) = + sorting::next_sort_state(self.state.sort_column, column, self.state.sort_state); + self.state.sort_column = new_column; + self.state.sort_state = new_state; + } + + fn get_filtered_and_sorted_entries(&self) -> Vec { + sorting::filter_and_sort( + &self.state.keybindings.entries, + &self.state.search_query, + &self.state.search_options, + self.state.sort_column, + self.state.sort_state, + ) + } + + fn load_logo_texture_if_needed(&mut self, ctx: &egui::Context) { + if self.state.logo_texture.is_none() { + let logo_bytes = include_bytes!("../../assets/logo_hyprbind.png"); + if let Ok(image) = image::load_from_memory(logo_bytes) { + let size = [image.width() as usize, image.height() as usize]; + let image_buffer = image.to_rgba8(); + let pixels = image_buffer.as_flat_samples(); + let color_image = egui::ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()); + self.state.logo_texture = + Some(ctx.load_texture("logo", color_image, egui::TextureOptions::LINEAR)); + } + } + } + + fn handle_zen_mode_shortcuts(&mut self, ctx: &egui::Context) { + let prev_zen = self.state.flags.zen_mode; + crate::ui::zen::handle_zen_keyboard_shortcuts(ctx, &mut self.state.flags.zen_mode); + if self.state.flags.zen_mode != prev_zen { + self.state.save_config(); + } + } + + fn handle_search_bar_focus(&self, ctx: &egui::Context) { + let search_bar_focused = ctx.memory(|m| m.focused() == Some(egui::Id::new("search_bar"))); + if !self.state.flags.zen_mode + && !search_bar_focused + && ctx.input(|i| i.key_pressed(egui::Key::Slash)) + { + ctx.memory_mut(|m| m.request_focus(egui::Id::new("search_bar"))); + ctx.input_mut(|i| { + i.events + .retain(|e| !matches!(e, egui::Event::Text(s) if s == "/")); + }); + } + } + + fn apply_theme_or_css(&mut self, ctx: &egui::Context) { + if crate::ui::styling::css::has_custom_theme() { + let path = crate::ui::styling::css::default_css_path(); + if let Ok(meta) = std::fs::metadata(&path) + && let Ok(modified) = meta.modified() + { + let changed = self.state.last_css_mtime.is_none_or(|prev| modified > prev); + if changed { + let _ = crate::ui::styling::css::apply_from_path(ctx, &path.to_string_lossy()); + self.state.last_css_mtime = Some(modified); + } + } + } else { + match self.state.theme { + crate::ui::Theme::Dark => ctx.set_visuals(egui::Visuals::dark()), + crate::ui::Theme::Light => ctx.set_visuals(egui::Visuals::light()), + } + } + } + + fn handle_zen_info_modal(&mut self, ctx: &egui::Context) { + if self.state.flags.show_zen_info_modal { + crate::ui::zen::render_zen_info_modal( + ctx, + &mut self.state.flags.show_zen_info_modal, + &mut self.state.flags.show_options_window, + ); + if ctx.input(|i| i.key_pressed(egui::Key::Z)) { + self.state.flags.show_zen_info_modal = false; + } + } + } + + fn handle_options_window(&mut self, ctx: &egui::Context) { + if self.state.flags.show_options_window && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.state.flags.show_options_window = false; + } + + let options_viewport_id = egui::ViewportId::from_hash_of("options"); + if self.state.flags.show_options_window { + ctx.show_viewport_immediate( + options_viewport_id, + egui::ViewportBuilder::default() + .with_title("HyprBind – Options") + .with_resizable(true) + .with_min_inner_size([400.0, 420.0]) + .with_inner_size([520.0, 560.0]), + |vctx, _class| { + if vctx.input(|i| i.viewport().close_requested()) { + self.state.flags.show_options_window = false; + } + egui::CentralPanel::default().show(vctx, |ui| { + let prev_zen = self.state.flags.zen_mode; + let mut opts = crate::ui::options::OptionsState { + theme: &mut self.state.theme, + column_visibility: &mut self.state.column_visibility, + search_options: &mut self.state.search_options, + zen_mode: &mut self.state.flags.zen_mode, + show_zen_info_modal: &mut self.state.flags.show_zen_info_modal, + export_request: &mut self.state.flags.export_request, + }; + crate::ui::options::render_options_contents(vctx, ui, &mut opts); + if !prev_zen && self.state.flags.zen_mode { + self.state.flags.show_options_window = false; + } + }); + }, + ); + } + } + + fn handle_export_request(&mut self) { + if self.state.flags.export_request { + self.state.flags.export_request = false; + if let Ok(json) = self.state.keybindings.to_json() { + let dir = crate::config::export_dir(); + let _ = std::fs::create_dir_all(&dir); + let epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let file_name = format!("keybindings_{epoch}.json"); + let path = dir.join(file_name); + if std::fs::write(&path, json).is_ok() { + self.state.export_modal_path = Some(path.to_string_lossy().to_string()); + } + } + } + } + + fn handle_export_modal(&mut self, ctx: &egui::Context) { + if let Some(ref path) = self.state.export_modal_path.clone() { + egui::Window::new("Exported") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.label("JSON has been exported to:"); + ui.monospace(path); + ui.add_space(10.0); + if ui.button("OK").clicked() + || ctx.input(|i| { + i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Escape) + }) + { + self.state.export_modal_path = None; + } + }); + }); + } + } + + fn handle_keyboard_navigation(&mut self, ctx: &egui::Context, filtered_len: usize) { + let search_bar_focused = ctx.memory(|m| m.focused() == Some(egui::Id::new("search_bar"))); + if !search_bar_focused && !self.state.flags.show_options_window && filtered_len > 0 { + let mut sel = self.state.selected_row.unwrap_or(0); + let changed = if ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)) { + sel = (sel + 1).min(filtered_len - 1); + true + } else if ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)) { + sel = sel.saturating_sub(1); + true + } else if ctx.input(|i| i.key_pressed(egui::Key::PageDown)) { + sel = (sel + 10).min(filtered_len - 1); + true + } else if ctx.input(|i| i.key_pressed(egui::Key::PageUp)) { + sel = sel.saturating_sub(10); + true + } else if ctx.input(|i| i.key_pressed(egui::Key::Home)) { + sel = 0; + true + } else if ctx.input(|i| i.key_pressed(egui::Key::End)) { + sel = filtered_len - 1; + true + } else { + false + }; + if changed { + self.state.selected_row = Some(sel); + } + } else if filtered_len == 0 { + self.state.selected_row = None; + } + } + + fn render_main_ui(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| { + let filtered = self.get_filtered_and_sorted_entries(); + + if !self.state.flags.zen_mode { + crate::ui::header::render_header( + ui, + &mut self.state.flags.show_options_window, + self.state.error_message.as_ref(), + self.state.logo_texture.as_ref(), + ); + + crate::ui::header::render_search_bar(ui, &mut self.state.search_query); + + crate::ui::header::render_stats_bar( + ui, + self.state.keybindings.entries.len(), + filtered.len(), + ); + } + self.handle_keyboard_navigation(ctx, filtered.len()); + + if let Some(clicked_column) = crate::ui::table::render_table( + ui, + &filtered, + &self.state.column_visibility, + self.state.sort_column, + self.state.sort_state, + self.state.selected_row, + ) { + self.handle_sort_click(clicked_column); + } + }); + } +} + +impl eframe::App for KeybindsApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.load_logo_texture_if_needed(ctx); + self.handle_zen_mode_shortcuts(ctx); + self.handle_search_bar_focus(ctx); + self.apply_theme_or_css(ctx); + self.handle_zen_info_modal(ctx); + self.handle_options_window(ctx); + self.handle_export_request(); + self.handle_export_modal(ctx); + self.render_main_ui(ctx); + } +} diff --git a/src/app/sorting.rs b/src/app/sorting.rs new file mode 100644 index 0000000..f5110fd --- /dev/null +++ b/src/app/sorting.rs @@ -0,0 +1,72 @@ +use crate::hyprland::{KeyBindEntry, SearchOptions}; +use crate::ui::{SortColumn, SortState}; +use std::cmp::Ordering; + +pub fn filter_and_sort( + entries: &[KeyBindEntry], + search_query: &str, + search_options: &SearchOptions, + sort_column: SortColumn, + sort_state: SortState, +) -> Vec { + let mut filtered: Vec<_> = entries + .iter() + .filter(|e| matches_search(e, search_query, search_options)) + .cloned() + .collect(); + + if sort_state != SortState::None { + apply_sort(&mut filtered, sort_column); + if sort_state == SortState::Descending { + filtered.reverse(); + } + } + + filtered +} + +fn matches_search(entry: &KeyBindEntry, query: &str, options: &SearchOptions) -> bool { + if query.is_empty() { + return true; + } + + entry.matches(query, options) +} + +fn apply_sort(entries: &mut [KeyBindEntry], sort_column: SortColumn) { + match sort_column { + SortColumn::Description => { + entries.sort_by(|a, b| a.description.cmp(&b.description)); + } + SortColumn::Keybind => { + entries.sort_by(|a, b| { + let mod_cmp = a.modifiers.cmp(&b.modifiers); + if mod_cmp == Ordering::Equal { + a.key.cmp(&b.key) + } else { + mod_cmp + } + }); + } + SortColumn::Command => { + entries.sort_by(|a, b| a.command.cmp(&b.command)); + } + } +} + +pub fn next_sort_state( + current_column: SortColumn, + clicked_column: SortColumn, + current_state: SortState, +) -> (SortColumn, SortState) { + if current_column == clicked_column { + let next_state = match current_state { + SortState::Ascending => SortState::Descending, + SortState::Descending => SortState::None, + SortState::None => SortState::Ascending, + }; + (clicked_column, next_state) + } else { + (clicked_column, SortState::Ascending) + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..3103170 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,79 @@ +use crate::config::UserConfig; +use crate::hyprland::{KeyBindings, SearchOptions, fetch_hyprctl_binds, parse_binds_output}; +use crate::ui::{ColumnVisibility, SortColumn, SortState, Theme}; +use eframe::egui; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Default)] +pub struct AppFlags { + pub show_options_window: bool, + pub zen_mode: bool, + pub show_zen_info_modal: bool, + pub export_request: bool, +} + +pub struct AppState { + pub keybindings: KeyBindings, + pub search_query: String, + pub error_message: Option, + pub search_options: SearchOptions, + pub sort_column: SortColumn, + pub sort_state: SortState, + pub flags: AppFlags, + pub theme: Theme, + pub column_visibility: ColumnVisibility, + pub logo_texture: Option, + pub selected_row: Option, + pub export_modal_path: Option, + pub last_css_mtime: Option, +} + +impl AppState { + pub fn new() -> Self { + let (keybindings, error_message) = match fetch_hyprctl_binds() { + Ok(raw_output) => (parse_binds_output(&raw_output), None), + Err(e) => ( + KeyBindings::new(), + Some(format!("Failed to load keybindings: {e}")), + ), + }; + + let mut state = Self { + keybindings, + search_query: String::new(), + error_message, + search_options: SearchOptions::default(), + sort_column: SortColumn::Keybind, + sort_state: SortState::None, + flags: AppFlags::default(), + theme: Theme::Dark, + column_visibility: ColumnVisibility::default(), + logo_texture: None, + selected_row: None, + export_modal_path: None, + last_css_mtime: None, + }; + + state.load_config(); + state + } + + fn load_config(&mut self) { + if let Some(cfg) = crate::config::load() { + self.theme = cfg.theme; + self.column_visibility = cfg.column_visibility; + self.search_options = cfg.search_options; + self.flags.zen_mode = cfg.zen_mode; + } + } + + pub fn save_config(&self) { + let cfg = UserConfig { + theme: self.theme, + column_visibility: self.column_visibility.clone(), + search_options: self.search_options.clone(), + zen_mode: self.flags.zen_mode, + }; + let _ = crate::config::save(&cfg); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..faa70ab --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,101 @@ +use clap::Parser; +use std::process; + +/// A GUI to display Hyprland keybindings +// Allow this to avoid make another struct +#[allow(clippy::struct_excessive_bools)] +#[derive(Parser)] +#[command(name = "HyprBind")] +#[command(version, about, long_about = None)] +pub struct Cli { + /// Write default CSS theme file + #[arg(long)] + pub write_default_css: bool, + + /// Overwrite existing CSS file (use with --write-default-css) + #[arg(long, requires = "write_default_css")] + pub force: bool, + + /// Output keybindings as JSON + #[arg(short, long)] + pub json: bool, + + /// Output keybindings in dmenu-compatible format + #[arg(short, long)] + pub dmenu: bool, +} + +pub enum CliAction { + RunGui, + WriteDefaultCss { force: bool }, + OutputJson, + OutputDmenu, +} + +pub fn parse_args() -> CliAction { + let cli = Cli::parse(); + + if cli.write_default_css { + return CliAction::WriteDefaultCss { force: cli.force }; + } + if cli.json { + return CliAction::OutputJson; + } + if cli.dmenu { + return CliAction::OutputDmenu; + } + + CliAction::RunGui +} + +pub fn handle_write_css(force: bool) { + match crate::ui::styling::css::write_default_css(force) { + Ok(path) => { + let msg = if force { + format!( + "Default CSS written (overwritten) to {}", + path.to_string_lossy() + ) + } else { + format!("Default CSS written to {}", path.to_string_lossy()) + }; + println!("{msg}"); + } + Err(e) => { + eprintln!("{e}"); + process::exit(1); + } + } +} + +pub fn handle_json_output() { + match crate::hyprland::fetch_hyprctl_binds() { + Ok(raw_output) => { + let kb = crate::hyprland::parse_binds_output(&raw_output); + match kb.to_json() { + Ok(s) => println!("{s}"), + Err(e) => { + eprintln!("Failed to serialize JSON: {e}"); + process::exit(1); + } + } + } + Err(e) => { + eprintln!("Failed to load keybindings: {e}"); + process::exit(1); + } + } +} + +pub fn handle_dmenu_output() { + match crate::hyprland::fetch_hyprctl_binds() { + Ok(raw_output) => { + let kb = crate::hyprland::parse_binds_output(&raw_output); + println!("{}", kb.to_dmenu()); + } + Err(e) => { + eprintln!("Failed to load keybindings: {e}"); + process::exit(1); + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..e9fbe57 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,5 @@ +pub mod paths; +pub mod user; + +pub use paths::{config_dir, export_dir}; +pub use user::{UserConfig, load, save}; diff --git a/src/config/paths.rs b/src/config/paths.rs new file mode 100644 index 0000000..116cf83 --- /dev/null +++ b/src/config/paths.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +pub fn config_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("hyprbind"); + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".config").join("hyprbind") +} + +pub fn export_dir() -> PathBuf { + config_dir().join("exports") +} + +pub(super) fn config_path() -> PathBuf { + config_dir().join("config.json") +} diff --git a/src/config.rs b/src/config/user.rs similarity index 59% rename from src/config.rs rename to src/config/user.rs index 2600a46..4aac9b6 100644 --- a/src/config.rs +++ b/src/config/user.rs @@ -1,9 +1,11 @@ use serde::{Deserialize, Serialize}; -use std::{fs, io, path::PathBuf}; +use std::{fs, io}; -use crate::models::SearchOptions; +use crate::hyprland::SearchOptions; use crate::ui::types::{ColumnVisibility, Theme}; +use super::paths::{config_dir, config_path}; + #[derive(Clone, Serialize, Deserialize)] pub struct UserConfig { pub theme: Theme, @@ -23,22 +25,6 @@ impl Default for UserConfig { } } -pub fn config_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - return PathBuf::from(xdg).join("hyprbind"); - } - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".config").join("hyprbind") -} - -pub fn export_dir() -> PathBuf { - config_dir().join("exports") -} - -fn config_path() -> PathBuf { - config_dir().join("config.json") -} - pub fn load() -> Option { let path = config_path(); let data = fs::read_to_string(path).ok()?; @@ -51,7 +37,6 @@ pub fn save(cfg: &UserConfig) -> io::Result<()> { fs::create_dir_all(&dir)?; } let path = config_path(); - let data = serde_json::to_string_pretty(cfg) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + let data = serde_json::to_string_pretty(cfg).map_err(|e| io::Error::other(e.to_string()))?; fs::write(path, data) } diff --git a/src/hyprland/mod.rs b/src/hyprland/mod.rs new file mode 100644 index 0000000..6ae8410 --- /dev/null +++ b/src/hyprland/mod.rs @@ -0,0 +1,7 @@ +pub mod models; +pub mod parser; +pub mod source; + +pub use models::{KeyBindEntry, KeyBindings, SearchOptions}; +pub use parser::parse_binds_output; +pub use source::fetch_hyprctl_binds; diff --git a/src/models.rs b/src/hyprland/models.rs similarity index 60% rename from src/models.rs rename to src/hyprland/models.rs index 34328aa..e0a4642 100644 --- a/src/models.rs +++ b/src/hyprland/models.rs @@ -1,4 +1,4 @@ -use crate::icons::get_icon; +use crate::ui::styling::icons::get_icon; use serde::{Deserialize, Serialize}; /// Options for searching keybindings @@ -33,7 +33,7 @@ pub struct KeyBindEntry { } impl KeyBindEntry { - pub fn new(modifiers: String, key: String, command: String, description: String) -> Self { + pub const fn new(modifiers: String, key: String, command: String, description: String) -> Self { Self { modifiers, key, @@ -64,7 +64,7 @@ pub struct KeyBindings { } impl KeyBindings { - pub fn new() -> Self { + pub const fn new() -> Self { Self { entries: Vec::new(), } @@ -74,18 +74,6 @@ impl KeyBindings { self.entries.push(entry); } - /// Filter entries by search query - pub fn filter(&self, query: &str, options: &SearchOptions) -> Vec<&KeyBindEntry> { - if query.is_empty() { - self.entries.iter().collect() - } else { - self.entries - .iter() - .filter(|e| e.matches(query, options)) - .collect() - } - } - /// Export as JSON pub fn to_json(&self) -> Result { serde_json::to_string_pretty(self) @@ -113,11 +101,11 @@ impl KeyBindings { } else if !entry.command.is_empty() { entry.command.clone() } else { - "".to_string() + String::new() }; // Output line "keybind : display_text" - format!("{} : {}", keybind, display_text) + format!("{keybind} : {display_text}") }) .collect::>() .join("\n") @@ -129,59 +117,3 @@ impl Default for KeyBindings { Self::new() } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_to_dmenu() { - // 1. No modifier, with description - let entry1 = KeyBindEntry::new( - "".to_string(), - "Return".to_string(), - "exec kitty".to_string(), - "Terminal".to_string(), - ); - // 2. With modifiers, with description - let entry2 = KeyBindEntry::new( - "SUPER+SHIFT".to_string(), - "Q".to_string(), - "killactive".to_string(), - "Kill window".to_string(), - ); - // 3. With modifiers, no description - let entry3 = KeyBindEntry::new( - "CTRL+ALT".to_string(), - "F1".to_string(), - "exec firefox".to_string(), - "".to_string(), - ); - // 4. With modifiers, no description, no command - let entry4 = KeyBindEntry::new( - "CTRL+ALT".to_string(), - "F2".to_string(), - "".to_string(), - "".to_string(), - ); - - let kb = KeyBindings { - entries: vec![entry1, entry2, entry3, entry4], - }; - - let dmenu = kb.to_dmenu(); - let lines: Vec<&str> = dmenu.lines().collect(); - - // 1. No modifier, icon only - assert_eq!(lines[0], "󰌑 : Terminal"); - - // 2. Modifiers, icons - assert_eq!(lines[1], " + 󰘶 + Q : Kill window"); - - // 3. Modifiers, fallback to key text if not in icon table - assert_eq!(lines[2], "CTRL + ALT + F1 : exec firefox"); - - // 4. Modifiers, no description, no command - assert_eq!(lines[3], "CTRL + ALT + F2 : "); - } -} diff --git a/src/parser.rs b/src/hyprland/parser.rs similarity index 55% rename from src/parser.rs rename to src/hyprland/parser.rs index 08446a0..c2adf93 100644 --- a/src/parser.rs +++ b/src/hyprland/parser.rs @@ -1,27 +1,8 @@ -use crate::models::{KeyBindEntry, KeyBindings}; +use super::models::{KeyBindEntry, KeyBindings}; use std::collections::HashMap; -use std::io; -use std::process::Command; - -/// Parse the output of hyprctl binds -pub fn parse_hyprctl_binds() -> io::Result { - let output = Command::new("hyprctl").arg("binds").output()?; - - if !output.status.success() { - return Err(io::Error::new( - io::ErrorKind::Other, - "hyprctl binds command failed", - )); - } - - let output_str = String::from_utf8_lossy(&output.stdout); - let keybindings = parse_binds_output(&output_str); - - Ok(keybindings) -} /// Parse the output text from hyprctl binds -fn parse_binds_output(output: &str) -> KeyBindings { +pub fn parse_binds_output(output: &str) -> KeyBindings { let mut keybindings = KeyBindings::new(); let blocks: Vec<&str> = output.split("\n\n").collect(); @@ -61,7 +42,7 @@ fn parse_bind_block(block: &str) -> Option { let command = if arg.is_empty() { dispatcher } else { - format!("{} {}", dispatcher, arg) + format!("{dispatcher} {arg}") }; Some(KeyBindEntry::new(modifiers, key, command, description)) @@ -90,35 +71,3 @@ fn modmask_to_string(modmask: u32) -> String { mods.join("+") } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_modmask_to_string() { - assert_eq!(modmask_to_string(64), "SUPER"); - assert_eq!(modmask_to_string(65), "SUPER+SHIFT"); - assert_eq!(modmask_to_string(72), "SUPER+ALT"); - assert_eq!(modmask_to_string(0), ""); - } - - #[test] - fn test_parse_bind_block() { - let block = r#"bind - modmask: 64 - submap: - key: Return - keycode: 0 - catchall: false - description: Terminal - dispatcher: exec - arg: kitty"#; - - let entry = parse_bind_block(block).unwrap(); - assert_eq!(entry.modifiers, "SUPER"); - assert_eq!(entry.key, "Return"); - assert_eq!(entry.command, "exec kitty"); - assert_eq!(entry.description, "Terminal"); - } -} diff --git a/src/hyprland/source.rs b/src/hyprland/source.rs new file mode 100644 index 0000000..ee5b8c8 --- /dev/null +++ b/src/hyprland/source.rs @@ -0,0 +1,13 @@ +use std::io; +use std::process::Command; + +/// Fetch raw output from hyprctl binds command +pub fn fetch_hyprctl_binds() -> io::Result { + let output = Command::new("hyprctl").arg("binds").output()?; + + if !output.status.success() { + return Err(io::Error::other("hyprctl binds command failed")); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/src/main.rs b/src/main.rs index be688a6..a936d4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,77 +1,36 @@ mod app; +mod cli; mod config; -mod css; -mod font; -mod icons; -mod models; -mod parser; +mod hyprland; mod ui; +#[cfg(test)] +mod tests; + use app::KeybindsApp; +use cli::CliAction; use eframe::egui; -use font::setup_custom_fonts; +use ui::styling::fonts::setup_custom_fonts; fn main() -> Result<(), eframe::Error> { - // JSON output mode: `--json` or `-j` - let args: Vec = std::env::args().collect(); - if args.iter().any(|a| a == "--write-default-css") { - match css::write_default_css(false) { - Ok(path) => { - println!("Default CSS written to {}", path.to_string_lossy()); - return Ok(()); - } - Err(e) => { - eprintln!("{}", e); - std::process::exit(1); - } - } - } - if args.iter().any(|a| a == "--force-write-default-css") { - match css::write_default_css(true) { - Ok(path) => { - println!( - "Default CSS written (overwritten) to {}", - path.to_string_lossy() - ); - return Ok(()); - } - Err(e) => { - eprintln!("{}", e); - std::process::exit(1); - } + match cli::parse_args() { + CliAction::WriteDefaultCss { force } => { + cli::handle_write_css(force); + Ok(()) } - } - if args.iter().any(|a| a == "--json" || a == "-j") { - match parser::parse_hyprctl_binds() { - Ok(kb) => match kb.to_json() { - Ok(s) => { - println!("{}", s); - return Ok(()); - } - Err(e) => { - eprintln!("Failed to serialize JSON: {}", e); - std::process::exit(1); - } - }, - Err(e) => { - eprintln!("Failed to load keybindings: {}", e); - std::process::exit(1); - } + CliAction::OutputJson => { + cli::handle_json_output(); + Ok(()) } - } - if args.iter().any(|a| a == "--dmenu" || a == "-d") { - match parser::parse_hyprctl_binds() { - Ok(kb) => { - println!("{}", kb.to_dmenu()); - return Ok(()); - } - Err(e) => { - eprintln!("Failed to load keybindings: {}", e); - std::process::exit(1); - } + CliAction::OutputDmenu => { + cli::handle_dmenu_output(); + Ok(()) } + CliAction::RunGui => run_gui(), } +} +fn run_gui() -> Result<(), eframe::Error> { let icon_data = load_icon(); let options = eframe::NativeOptions { @@ -87,7 +46,7 @@ fn main() -> Result<(), eframe::Error> { options, Box::new(|cc| { setup_custom_fonts(&cc.egui_ctx); - css::apply_default_if_exists(&cc.egui_ctx); + ui::styling::css::apply_default_if_exists(&cc.egui_ctx); Ok(Box::new(KeybindsApp::new())) }), ) diff --git a/src/tests/icons.rs b/src/tests/icons.rs new file mode 100644 index 0000000..7f4787d --- /dev/null +++ b/src/tests/icons.rs @@ -0,0 +1,42 @@ +#[cfg(test)] +mod icons_tests { + use crate::ui::styling::icons::get_icon; + + #[test] + fn test_get_icon() { + let cases: [(&str, &str); 28] = [ + ("SUPER", ""), + ("SHIFT", " 󰘶 "), + ("RETURN", "󰌑"), + ("ENTER", "󰌑"), + ("SEMICOLON", ";"), + ("DELETE", "DEL"), + ("TAB", "TAB"), + ("LEFT", "󰜱"), + ("RIGHT", "󰜴"), + ("UP", "󰜷"), + ("DOWN", "󰜮"), + ("mouse_down", "󱕐"), + ("mouse_up", "󱕑"), + ("mouse:272", "󰍽"), + ("mouse:273", "󰍽"), + ("XF86AudioRaiseVolume", ""), + ("XF86AudioLowerVolume", ""), + ("XF86AudioMute", ""), + ("XF86AudioMicMute", "󰍭"), + ("XF86MonBrightnessUp", "󰃠"), + ("XF86MonBrightnessDown", "󰃞"), + ("XF86AudioNext", "󰙡"), + ("XF86AudioPause", ""), + ("XF86AudioPlay", ""), + ("XF86AudioPrev", "󰙣"), + ("UNKNOWN_KEY", "UNKNOWN_KEY"), + ("A", "A"), + ("123", "123"), + ]; + + for (input, expected) in &cases { + assert_eq!(get_icon(input), *expected); + } + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..85bc927 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,4 @@ +mod icons; +mod models; +mod parser; +mod table; diff --git a/src/tests/models.rs b/src/tests/models.rs new file mode 100644 index 0000000..58c49e2 --- /dev/null +++ b/src/tests/models.rs @@ -0,0 +1,55 @@ +#[cfg(test)] +mod models_tests { + use crate::hyprland::{KeyBindEntry, KeyBindings}; + + #[test] + fn test_to_dmenu() { + // 1. No modifier, with description + let entry1 = KeyBindEntry::new( + String::new(), + "Return".to_string(), + "exec kitty".to_string(), + "Terminal".to_string(), + ); + // 2. With modifiers, with description + let entry2 = KeyBindEntry::new( + "SUPER+SHIFT".to_string(), + "Q".to_string(), + "killactive".to_string(), + "Kill window".to_string(), + ); + // 3. With modifiers, no description + let entry3 = KeyBindEntry::new( + "SUPER+ALT".to_string(), + "F1".to_string(), + "exec firefox".to_string(), + String::new(), + ); + // 4. With modifiers, no description, no command + let entry4 = KeyBindEntry::new( + "CTRL+SHIFT".to_string(), + "F2".to_string(), + String::new(), + String::new(), + ); + + let kb = KeyBindings { + entries: vec![entry1, entry2, entry3, entry4], + }; + + let dmenu = kb.to_dmenu(); + let lines: Vec<&str> = dmenu.lines().collect(); + + // 1. No modifier, icon only + assert_eq!(lines[0], "󰌑 : Terminal"); + + // 2. Modifiers, icons + assert_eq!(lines[1], " + 󰘶 + Q : Kill window"); + + // 3. Modifiers, fallback to key text if not in icon table + assert_eq!(lines[2], " + ALT + F1 : exec firefox"); + + // 4. Modifiers, no description, no command + assert_eq!(lines[3], "CTRL + 󰘶 + F2 : "); + } +} diff --git a/src/tests/parser.rs b/src/tests/parser.rs new file mode 100644 index 0000000..4febd5b --- /dev/null +++ b/src/tests/parser.rs @@ -0,0 +1,59 @@ +#[cfg(test)] +mod parser_tests { + use crate::hyprland::parser::parse_binds_output; + + #[test] + fn test_modmask_conversion() { + let sample = r"bind + modmask: 64 + submap: + key: A + keycode: 0 + catchall: false + description: + dispatcher: exec + arg: echo super"; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + assert_eq!(kb.entries[0].modifiers, "SUPER"); + } + + #[test] + fn test_multiple_modifiers() { + let sample = r"bind + modmask: 65 + submap: + key: Q + keycode: 0 + catchall: false + description: Kill window + dispatcher: killactive + arg: "; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + assert_eq!(kb.entries[0].modifiers, "SUPER+SHIFT"); + assert_eq!(kb.entries[0].description, "Kill window"); + } + + #[test] + fn test_parse_bind_block() { + let block = r"bind + modmask: 64 + submap: + key: Return + keycode: 0 + catchall: false + description: Terminal + dispatcher: exec + arg: kitty"; + + let kb = parse_binds_output(block); + assert_eq!(kb.entries.len(), 1); + assert_eq!(kb.entries[0].modifiers, "SUPER"); + assert_eq!(kb.entries[0].key, "Return"); + assert_eq!(kb.entries[0].command, "exec kitty"); + assert_eq!(kb.entries[0].description, "Terminal"); + } +} diff --git a/src/tests/table.rs b/src/tests/table.rs new file mode 100644 index 0000000..7f1f5f2 --- /dev/null +++ b/src/tests/table.rs @@ -0,0 +1,23 @@ +#[cfg(test)] +mod table_tests { + use crate::ui::table::is_nerd_font_icon; + + #[test] + fn test_is_nerd_font_icon() { + // Test with NerdFonts + let nerd_fonts: [&str; 21] = [ + "", "󰘶", "󰌑", "󰜱", "󰜴", "󰜷", "󰜮", "󱕐", "󱕑", "󰍽", "󰍽", "", "", "", "󰍭", "󰃠", "󰃞", + "󰙡", "", "", "󰙣", + ]; + + let non_nerd_fonts: [&str; 5] = [";", "A", "DEL", "TAB", "1"]; + + for icon in &nerd_fonts { + assert!(is_nerd_font_icon(icon)); + } + + for text in &non_nerd_fonts { + assert!(!is_nerd_font_icon(text)); + } + } +} diff --git a/src/ui/header.rs b/src/ui/header.rs index ed5af03..84dfb04 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -5,21 +5,34 @@ fn render_gradient_text(ui: &mut egui::Ui, text: &str, font_size: f32) { let accent = ui.visuals().hyperlink_color; let start_color = accent; // use accent as start let end_color = egui::Color32::from_rgb( - ((accent.r() as u16 + 255) / 2) as u8, - ((accent.g() as u16 + 255) / 2) as u8, - ((accent.b() as u16 + 255) / 2) as u8, + u8::try_from(u16::midpoint(u16::from(accent.r()), 255)).unwrap_or(255), + u8::try_from(u16::midpoint(u16::from(accent.g()), 255)).unwrap_or(255), + u8::try_from(u16::midpoint(u16::from(accent.b()), 255)).unwrap_or(255), ); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let to_byte = |v: f32| -> u8 { + // Clamp to [0, 255] and round to nearest before narrowing + v.clamp(0.0, 255.0).round() as u8 + }; + ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; // No spacing between characters + #[allow(clippy::cast_precision_loss)] + let char_count = (text.len().saturating_sub(1)).max(1) as f32; + for (i, ch) in text.chars().enumerate() { - let t = i as f32 / (text.len() - 1).max(1) as f32; + #[allow(clippy::cast_precision_loss)] + let t = i as f32 / char_count; // Interpolate color - let r = (start_color.r() as f32 * (1.0 - t) + end_color.r() as f32 * t) as u8; - let g = (start_color.g() as f32 * (1.0 - t) + end_color.g() as f32 * t) as u8; - let b = (start_color.b() as f32 * (1.0 - t) + end_color.b() as f32 * t) as u8; + let r = + to_byte(f32::from(start_color.r()).mul_add(1.0 - t, f32::from(end_color.r()) * t)); + let g = + to_byte(f32::from(start_color.g()).mul_add(1.0 - t, f32::from(end_color.g()) * t)); + let b = + to_byte(f32::from(start_color.b()).mul_add(1.0 - t, f32::from(end_color.b()) * t)); let color = egui::Color32::from_rgb(r, g, b); ui.label( @@ -35,7 +48,7 @@ fn render_gradient_text(ui: &mut egui::Ui, text: &str, font_size: f32) { pub fn render_header( ui: &mut egui::Ui, show_options_window: &mut bool, - error_message: &Option, + error_message: Option<&String>, logo_texture: Option<&egui::TextureHandle>, ) { // Modern header with background @@ -92,7 +105,7 @@ pub fn render_header( if let Some(error) = error_message { ui.horizontal(|ui| { ui.add_space(20.0); - ui.colored_label(egui::Color32::RED, format!("⚠ {}", error)); + ui.colored_label(egui::Color32::RED, format!("⚠ {error}")); }); ui.add_space(8.0); } @@ -129,13 +142,13 @@ pub fn render_stats_bar(ui: &mut egui::Ui, total: usize, showing: usize) { ui.horizontal(|ui| { ui.add_space(20.0); ui.label( - egui::RichText::new(format!(" Total: {}", total)) + egui::RichText::new(format!(" Total: {total}")) .weak() .size(12.0), ); ui.add_space(10.0); ui.label( - egui::RichText::new(format!(" Showing: {}", showing)) + egui::RichText::new(format!(" Showing: {showing}")) .weak() .size(12.0), ); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index aca3df6..d90790c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod header; pub mod options; +pub mod styling; pub mod table; pub mod types; pub mod zen; diff --git a/src/ui/options.rs b/src/ui/options.rs index 8c52dff..fa11609 100644 --- a/src/ui/options.rs +++ b/src/ui/options.rs @@ -1,16 +1,38 @@ use super::types::{ColumnVisibility, Theme}; -use crate::models::SearchOptions; +use crate::hyprland::SearchOptions; use eframe::egui; -pub fn render_options_contents( +pub struct OptionsState<'a> { + pub theme: &'a mut Theme, + pub column_visibility: &'a mut ColumnVisibility, + pub search_options: &'a mut SearchOptions, + pub zen_mode: &'a mut bool, + pub show_zen_info_modal: &'a mut bool, + pub export_request: &'a mut bool, +} + +fn save_config( + theme: Theme, + column_visibility: &ColumnVisibility, + search_options: &SearchOptions, + zen_mode: bool, +) { + let cfg = crate::config::UserConfig { + theme, + column_visibility: column_visibility.clone(), + search_options: search_options.clone(), + zen_mode, + }; + let _ = crate::config::save(&cfg); +} + +fn render_theme_section( ctx: &egui::Context, ui: &mut egui::Ui, theme: &mut Theme, - column_visibility: &mut ColumnVisibility, - search_options: &mut SearchOptions, - zen_mode: &mut bool, - show_zen_info_modal: &mut bool, - export_request: &mut bool, + column_visibility: &ColumnVisibility, + search_options: &SearchOptions, + zen_mode: bool, ) { ui.heading("\u{f050e} Theme"); ui.add_space(5.0); @@ -30,13 +52,7 @@ pub fn render_options_contents( is_light = !is_light; *theme = if is_light { Theme::Light } else { Theme::Dark }; // autosave on theme change - let cfg = crate::config::UserConfig { - theme: *theme, - column_visibility: column_visibility.clone(), - search_options: search_options.clone(), - zen_mode: *zen_mode, - }; - let _ = crate::config::save(&cfg); + save_config(*theme, column_visibility, search_options, zen_mode); } let bg_color = if is_light { @@ -70,13 +86,21 @@ pub fn render_options_contents( ui.add_space(8.0); let tip = format!( "Re-apply {}", - crate::css::default_css_path().to_string_lossy() + crate::ui::styling::css::default_css_path().to_string_lossy() ); if ui.button("Reload CSS").on_hover_text(tip).clicked() { - crate::css::apply_default_if_exists(ctx); + crate::ui::styling::css::apply_default_if_exists(ctx); } ui.add_space(10.0); +} +fn render_column_visibility_section( + ui: &mut egui::Ui, + theme: Theme, + column_visibility: &mut ColumnVisibility, + search_options: &SearchOptions, + zen_mode: bool, +) { ui.separator(); ui.add_space(10.0); @@ -86,16 +110,18 @@ pub fn render_options_contents( let r2 = ui.checkbox(&mut column_visibility.description, "\u{f29e} Description"); let r3 = ui.checkbox(&mut column_visibility.command, "\u{ebc4} Command"); if r1.changed() || r2.changed() || r3.changed() { - let cfg = crate::config::UserConfig { - theme: *theme, - column_visibility: column_visibility.clone(), - search_options: search_options.clone(), - zen_mode: *zen_mode, - }; - let _ = crate::config::save(&cfg); + save_config(theme, column_visibility, search_options, zen_mode); } ui.add_space(10.0); +} +fn render_search_options_section( + ui: &mut egui::Ui, + theme: Theme, + column_visibility: &ColumnVisibility, + search_options: &mut SearchOptions, + zen_mode: bool, +) { ui.separator(); ui.add_space(10.0); @@ -106,16 +132,19 @@ pub fn render_options_contents( let s2 = ui.checkbox(&mut search_options.description, "\u{f29e} Description"); let s3 = ui.checkbox(&mut search_options.command, "\u{ebc4} Command"); if s1.changed() || s2.changed() || s3.changed() { - let cfg = crate::config::UserConfig { - theme: *theme, - column_visibility: column_visibility.clone(), - search_options: search_options.clone(), - zen_mode: *zen_mode, - }; - let _ = crate::config::save(&cfg); + save_config(theme, column_visibility, search_options, zen_mode); } ui.add_space(10.0); +} +fn render_zen_mode_section( + ui: &mut egui::Ui, + theme: Theme, + column_visibility: &ColumnVisibility, + search_options: &SearchOptions, + zen_mode: &mut bool, + show_zen_info_modal: &mut bool, +) { ui.separator(); ui.add_space(10.0); @@ -129,16 +158,13 @@ pub fn render_options_contents( { *zen_mode = true; *show_zen_info_modal = true; - let cfg = crate::config::UserConfig { - theme: *theme, - column_visibility: column_visibility.clone(), - search_options: search_options.clone(), - zen_mode: *zen_mode, - }; - let _ = crate::config::save(&cfg); + save_config(theme, column_visibility, search_options, *zen_mode); } ui.add_space(10.0); +} + +fn render_export_section(ui: &mut egui::Ui, export_request: &mut bool) { ui.separator(); ui.add_space(10.0); @@ -151,3 +177,37 @@ pub fn render_options_contents( *export_request = true; } } + +pub fn render_options_contents(ctx: &egui::Context, ui: &mut egui::Ui, state: &mut OptionsState) { + render_theme_section( + ctx, + ui, + state.theme, + state.column_visibility, + state.search_options, + *state.zen_mode, + ); + render_column_visibility_section( + ui, + *state.theme, + state.column_visibility, + state.search_options, + *state.zen_mode, + ); + render_search_options_section( + ui, + *state.theme, + state.column_visibility, + state.search_options, + *state.zen_mode, + ); + render_zen_mode_section( + ui, + *state.theme, + state.column_visibility, + state.search_options, + state.zen_mode, + state.show_zen_info_modal, + ); + render_export_section(ui, state.export_request); +} diff --git a/src/css.rs b/src/ui/styling/css.rs similarity index 59% rename from src/css.rs rename to src/ui/styling/css.rs index cacc874..47bbbd5 100644 --- a/src/css.rs +++ b/src/ui/styling/css.rs @@ -2,48 +2,65 @@ use eframe::egui; use std::fs; use std::path::PathBuf; -fn parse_hex_color(s: &str) -> Option { - let s = s.trim(); +const DEFAULT_RADIUS: u8 = 6; +const DEFAULT_SPACING: i8 = 6; + +fn parse_hex_color(hex: &str) -> Option { + let s = hex.trim(); let hex = s.strip_prefix('#').unwrap_or(s); - let (r, g, b, a) = match hex.len() { + let (red, green, blue, alpha) = match hex.len() { 6 => { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - (r, g, b, 255) + let red = u8::from_str_radix(&hex[0..2], 16).ok()?; + let green = u8::from_str_radix(&hex[2..4], 16).ok()?; + let blue = u8::from_str_radix(&hex[4..6], 16).ok()?; + (red, green, blue, 255) } 8 => { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - let a = u8::from_str_radix(&hex[6..8], 16).ok()?; - (r, g, b, a) + let red = u8::from_str_radix(&hex[0..2], 16).ok()?; + let green = u8::from_str_radix(&hex[2..4], 16).ok()?; + let blue = u8::from_str_radix(&hex[4..6], 16).ok()?; + let alpha = u8::from_str_radix(&hex[6..8], 16).ok()?; + (red, green, blue, alpha) } _ => return None, }; - Some(egui::Color32::from_rgba_unmultiplied(r, g, b, a)) + Some(egui::Color32::from_rgba_unmultiplied( + red, green, blue, alpha, + )) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn parse_radius(s: &str) -> Option { + s.trim().parse::().ok().map(|val| { + // Floor then clamp to u8 range to avoid overflow and sign issues + val.clamp(0.0, 255.0).floor() as u8 + }) } -fn parse_number(s: &str) -> Option { - s.trim().parse::().ok() +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn parse_spacing(s: &str) -> Option { + s.trim().parse::().ok().map(|val| { + // Floor then clamp to i8 range to avoid overflow and sign issues + val.clamp(f32::from(i8::MIN), f32::from(i8::MAX)).floor() as i8 + }) } fn extract_vars(contents: &str) -> std::collections::HashMap { let mut map = std::collections::HashMap::new(); for line in contents.lines() { let line = line.trim(); - if line.starts_with("--") { - if let Some(colon) = line.find(':') { - let key = &line[..colon].trim(); - let mut value = &line[colon + 1..]; - if let Some(semi) = value.find(';') { - value = &value[..semi]; - } - map.insert( - key.trim().trim_start_matches('-').to_string(), - value.trim().to_string(), - ); + if line.starts_with("--") + && let Some(colon) = line.find(':') + { + let key = &line[..colon].trim(); + let mut value = &line[colon + 1..]; + if let Some(semi) = value.find(';') { + value = &value[..semi]; } + map.insert( + key.trim().trim_start_matches('-').to_string(), + value.trim().to_string(), + ); } // Also handle lines within :root { ... } if let Some(start) = line.find("--") { @@ -67,7 +84,7 @@ fn extract_vars(contents: &str) -> std::collections::HashMap { } pub fn apply_from_path(ctx: &egui::Context, path: &str) -> Result<(), String> { - let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read CSS: {}", e))?; + let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read CSS: {e}"))?; let vars = extract_vars(&contents); // Expected variables (all optional): @@ -78,8 +95,8 @@ pub fn apply_from_path(ctx: &egui::Context, path: &str) -> Result<(), String> { let accent = vars.get("accent").and_then(|v| parse_hex_color(v)); let stroke = vars.get("stroke").and_then(|v| parse_hex_color(v)); let selection = vars.get("selection").and_then(|v| parse_hex_color(v)); - let radius = vars.get("radius").and_then(|v| parse_number(v)); - let spacing = vars.get("spacing").and_then(|v| parse_number(v)); + let radius = vars.get("radius").and_then(|v| parse_radius(v)); + let spacing = vars.get("spacing").and_then(|v| parse_spacing(v)); let mut style = (*ctx.style()).clone(); let mut visuals = style.visuals.clone(); @@ -118,9 +135,10 @@ pub fn apply_from_path(ctx: &egui::Context, path: &str) -> Result<(), String> { w.bg_stroke.color = stroke_c; } if let Some(r) = radius { - w.corner_radius = egui::CornerRadius::same(r as u8); + w.corner_radius = egui::CornerRadius::same(r); } }; + apply_widget(&mut visuals.widgets.noninteractive); apply_widget(&mut visuals.widgets.inactive); apply_widget(&mut visuals.widgets.hovered); @@ -128,12 +146,11 @@ pub fn apply_from_path(ctx: &egui::Context, path: &str) -> Result<(), String> { apply_widget(&mut visuals.widgets.open); if let Some(sp) = spacing { - style.spacing.item_spacing = egui::vec2(sp, sp); - style.spacing.button_padding = egui::vec2(sp, sp); - style.spacing.menu_margin = egui::Margin::same(sp as i8); - style.spacing.window_margin = egui::Margin::same(sp as i8); + style.spacing.item_spacing = egui::vec2(f32::from(sp), f32::from(sp)); + style.spacing.button_padding = egui::vec2(f32::from(sp), f32::from(sp)); + style.spacing.menu_margin = egui::Margin::same(sp); + style.spacing.window_margin = egui::Margin::same(sp); } - style.visuals = visuals; ctx.set_style(style); @@ -143,13 +160,15 @@ pub fn apply_from_path(ctx: &egui::Context, path: &str) -> Result<(), String> { pub fn default_css_path() -> PathBuf { // ~/.config/hyprbind/hyprbind-theme.css #[allow(deprecated)] - let mut dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(xdg) - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".config") - } else { - PathBuf::from(".config") - }; + let mut dir = std::env::var("XDG_CONFIG_HOME").map_or_else( + |_| { + std::env::var("HOME").map_or_else( + |_| PathBuf::from(".config"), + |home| PathBuf::from(home).join(".config"), + ) + }, + PathBuf::from, + ); dir.push("hyprbind"); dir.push("hyprbind-theme.css"); dir @@ -168,7 +187,7 @@ pub fn has_custom_theme() -> bool { pub fn write_default_css(overwrite: bool) -> Result { let dir = crate::config::config_dir(); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {}", e))?; + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?; let path = default_css_path(); if path.exists() && !overwrite { return Err(format!( @@ -183,10 +202,9 @@ pub fn write_default_css(overwrite: bool) -> Result { .unwrap_or(0); let css = format!( - "/* HyprBind default CSS generated at epoch {} */\n:root {{\n --bg: #0f1117;\n --fg: #d4d7dc;\n --panel: #151922;\n --accent: #7aa2f7;\n --stroke: #3b4261;\n --selection: #283457;\n --radius: 6;\n --spacing: 6;\n}}\n", - epoch + "/* HyprBind default CSS generated at epoch {epoch} */\n:root {{\n --bg: #0f1117;\n --fg: #d4d7dc;\n --panel: #151922;\n --accent: #7aa2f7;\n --stroke: #3b4261;\n --selection: #283457;\n --radius: {DEFAULT_RADIUS};\n --spacing: {DEFAULT_SPACING};\n}}\n" ); - fs::write(&path, css).map_err(|e| format!("Failed to write CSS: {}", e))?; + fs::write(&path, css).map_err(|e| format!("Failed to write CSS: {e}"))?; Ok(path) } diff --git a/src/font.rs b/src/ui/styling/fonts.rs similarity index 89% rename from src/font.rs rename to src/ui/styling/fonts.rs index 1001992..f9e510c 100644 --- a/src/font.rs +++ b/src/ui/styling/fonts.rs @@ -5,7 +5,7 @@ use egui::epaint::text::{FontInsert, InsertFontFamily}; pub fn setup_custom_fonts(ctx: &egui::Context) { // Load Firple fonts (Nerd Font & Japanese font) let font_data: egui::FontData = - egui::FontData::from_static(include_bytes!("../assets/Firple-Bold.ttf")); + egui::FontData::from_static(include_bytes!("../../../assets/Firple-Bold.ttf")); ctx.add_font(FontInsert::new( "Firple Bold", diff --git a/src/icons.rs b/src/ui/styling/icons.rs similarity index 59% rename from src/icons.rs rename to src/ui/styling/icons.rs index 38332bb..4371621 100644 --- a/src/icons.rs +++ b/src/ui/styling/icons.rs @@ -48,46 +48,3 @@ pub fn get_icon(key: &str) -> String { // Fallback: return the key itself key.to_string() } - -#[cfg(test)] -mod tests { - use super::get_icon; - - #[test] - fn test_get_icon() { - let cases: [(&str, &str); 28] = [ - ("SUPER", ""), - ("SHIFT", " 󰘶 "), - ("RETURN", "󰌑"), - ("ENTER", "󰌑"), - ("SEMICOLON", ";"), - ("DELETE", "DEL"), - ("TAB", "TAB"), - ("LEFT", "󰜱"), - ("RIGHT", "󰜴"), - ("UP", "󰜷"), - ("DOWN", "󰜮"), - ("mouse_down", "󱕐"), - ("mouse_up", "󱕑"), - ("mouse:272", "󰍽"), - ("mouse:273", "󰍽"), - ("XF86AudioRaiseVolume", ""), - ("XF86AudioLowerVolume", ""), - ("XF86AudioMute", ""), - ("XF86AudioMicMute", "󰍭"), - ("XF86MonBrightnessUp", "󰃠"), - ("XF86MonBrightnessDown", "󰃞"), - ("XF86AudioNext", "󰙡"), - ("XF86AudioPause", ""), - ("XF86AudioPlay", ""), - ("XF86AudioPrev", "󰙣"), - ("UNKNOWN_KEY", "UNKNOWN_KEY"), - ("A", "A"), - ("123", "123"), - ]; - - for (input, expected) in cases.iter() { - assert_eq!(get_icon(input), *expected); - } - } -} diff --git a/src/ui/styling/mod.rs b/src/ui/styling/mod.rs new file mode 100644 index 0000000..8c5d7a6 --- /dev/null +++ b/src/ui/styling/mod.rs @@ -0,0 +1,3 @@ +pub mod css; +pub mod fonts; +pub mod icons; diff --git a/src/ui/table.rs b/src/ui/table.rs index 4bf7e76..f7caea9 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -1,6 +1,6 @@ use super::types::{ColumnVisibility, SortColumn, SortState}; -use crate::icons::get_icon; -use crate::models::KeyBindEntry; +use crate::hyprland::KeyBindEntry; +use crate::ui::styling::icons::get_icon; use eframe::egui; use egui_extras::{Column, TableBuilder}; @@ -28,7 +28,6 @@ pub fn render_sort_button( }; button_text.push_str(sort_indicator); - let _is_active = sort_column == column && sort_state != SortState::None; let button = egui::Button::new(egui::RichText::new(button_text).strong().size(14.0)) .fill(egui::Color32::TRANSPARENT) .stroke(egui::Stroke::new(1.0, ui.visuals().hyperlink_color)); @@ -59,7 +58,9 @@ fn render_header_cell( clicked } -fn is_nerd_font_icon(text: &str) -> bool { +// Allow redundant_pub_crate This is for only test +#[allow(clippy::redundant_pub_crate)] +pub(crate) fn is_nerd_font_icon(text: &str) -> bool { // Check if text contains Nerd Font Unicode characters (Private Use Areas) text.chars().any(|c| { let code = c as u32; @@ -68,7 +69,7 @@ fn is_nerd_font_icon(text: &str) -> bool { // SMP PUA (Supplementary Multilingual Plane Private Use Area) || (0xF0000..=0xFFFFD).contains(&code) // SSP PUA (Supplementary Special-purpose Plane Private Use Area) - || (0x100000..=0x10FFFD).contains(&code) + || (0x0010_0000..=0x0010_FFFD).contains(&code) }) } @@ -159,14 +160,33 @@ fn render_command_cell(ui: &mut egui::Ui, entry: &KeyBindEntry) { .on_hover_text(&entry.command); } -pub fn render_table( - ui: &mut egui::Ui, - filtered: &[KeyBindEntry], +fn add_table_column( + table: TableBuilder<'_>, + is_last: bool, + initial_width: f32, + min_width: f32, +) -> TableBuilder<'_> { + if is_last { + table.column( + Column::remainder() + .at_least(min_width) + .resizable(true) + .clip(true), + ) + } else { + table.column( + Column::initial(initial_width) + .at_least(min_width) + .resizable(true) + .clip(true), + ) + } +} + +fn build_table_columns<'a>( + mut table: TableBuilder<'a>, column_visibility: &ColumnVisibility, - sort_column: SortColumn, - sort_state: SortState, - selected_row: Option, -) -> Option { +) -> TableBuilder<'a> { let visible_count = [ column_visibility.keybind, column_visibility.description, @@ -176,72 +196,43 @@ pub fn render_table( .filter(|&&v| v) .count(); - // Remove vertical lines by making separator invisible - ui.style_mut().visuals.widgets.noninteractive.bg_stroke = egui::Stroke::NONE; - ui.style_mut().visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE; - - let mut table = TableBuilder::new(ui) - .striped(true) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); - let mut col_index = 0; if column_visibility.keybind { col_index += 1; - if col_index == visible_count { - table = table.column( - Column::remainder() - .at_least(100.0) - .resizable(true) - .clip(true), - ); - } else { - table = table.column( - Column::initial(250.0) - .at_least(100.0) - .resizable(true) - .clip(true), - ); - } + table = add_table_column(table, col_index == visible_count, 250.0, 100.0); } if column_visibility.description { col_index += 1; - if col_index == visible_count { - table = table.column( - Column::remainder() - .at_least(200.0) - .resizable(true) - .clip(true), - ); - } else { - table = table.column( - Column::initial(300.0) - .at_least(100.0) - .resizable(true) - .clip(true), - ); - } + table = add_table_column(table, col_index == visible_count, 300.0, 100.0); } if column_visibility.command { col_index += 1; - if col_index == visible_count { - table = table.column( - Column::remainder() - .at_least(200.0) - .resizable(true) - .clip(true), - ); - } else { - table = table.column( - Column::initial(300.0) - .at_least(100.0) - .resizable(true) - .clip(true), - ); - } + table = add_table_column(table, col_index == visible_count, 300.0, 200.0); } + table +} + +pub fn render_table( + ui: &mut egui::Ui, + filtered: &[KeyBindEntry], + column_visibility: &ColumnVisibility, + sort_column: SortColumn, + sort_state: SortState, + selected_row: Option, +) -> Option { + // Remove vertical lines by making separator invisible + ui.style_mut().visuals.widgets.noninteractive.bg_stroke = egui::Stroke::NONE; + ui.style_mut().visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE; + + let table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); + + let table = build_table_columns(table, column_visibility); + let mut clicked_column = None; table @@ -292,13 +283,13 @@ pub fn render_table( if column_visibility.keybind { row.col(|ui| { ui.set_min_height(32.0); - if let Some(sel) = selected_row { - if sel == idx { - let rect = ui.max_rect(); - let hl = ui.visuals().selection.bg_fill; - ui.painter().rect_filled(rect, 0.0, hl); - ui.scroll_to_rect(rect, None); - } + if let Some(sel) = selected_row + && sel == idx + { + let rect = ui.max_rect(); + let hl = ui.visuals().selection.bg_fill; + ui.painter().rect_filled(rect, 0.0, hl); + ui.scroll_to_rect(rect, None); } render_keybind_cell(ui, entry); }); @@ -306,12 +297,12 @@ pub fn render_table( if column_visibility.description { row.col(|ui| { ui.set_min_height(32.0); - if let Some(sel) = selected_row { - if sel == idx { - let rect = ui.max_rect(); - let hl = ui.visuals().selection.bg_fill; - ui.painter().rect_filled(rect, 0.0, hl); - } + if let Some(sel) = selected_row + && sel == idx + { + let rect = ui.max_rect(); + let hl = ui.visuals().selection.bg_fill; + ui.painter().rect_filled(rect, 0.0, hl); } render_description_cell(ui, entry); }); @@ -319,12 +310,12 @@ pub fn render_table( if column_visibility.command { row.col(|ui| { ui.set_min_height(32.0); - if let Some(sel) = selected_row { - if sel == idx { - let rect = ui.max_rect(); - let hl = ui.visuals().selection.bg_fill; - ui.painter().rect_filled(rect, 0.0, hl); - } + if let Some(sel) = selected_row + && sel == idx + { + let rect = ui.max_rect(); + let hl = ui.visuals().selection.bg_fill; + ui.painter().rect_filled(rect, 0.0, hl); } render_command_cell(ui, entry); }); @@ -335,36 +326,3 @@ pub fn render_table( clicked_column } - -#[cfg(test)] -mod tests { - - use super::is_nerd_font_icon; - - #[test] - fn test_is_nerd_font_icon() { - // Test with NerdFonts - let nerd_fonts: [&str; 21] = [ - "", "󰘶", "󰌑", "󰜱", "󰜴", "󰜷", "󰜮", "󱕐", "󱕑", "󰍽", "󰍽", "", "", "", "󰍭", "󰃠", "󰃞", - "󰙡", "", "", "󰙣", - ]; - - let non_nerd_fonts: [&str; 5] = [";", "A", "DEL", "TAB", "1"]; - - for icon in nerd_fonts.iter() { - assert!( - is_nerd_font_icon(icon), - "Expected '{}' to be identified as a Nerd Font icon", - icon - ); - } - - for text in non_nerd_fonts.iter() { - assert!( - !is_nerd_font_icon(text), - "Expected '{}' to NOT be identified as a Nerd Font icon", - text - ); - } - } -} diff --git a/src/ui/types.rs b/src/ui/types.rs index bac5987..2d413e0 100644 --- a/src/ui/types.rs +++ b/src/ui/types.rs @@ -1,13 +1,13 @@ use serde::{Deserialize, Serialize}; -#[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] pub enum SortColumn { Keybind, Description, Command, } -#[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] pub enum SortState { Ascending, Descending,