commit a94476863622bdf3cb367b9c0206952dbef6cb6a Author: Kaloyan Nikolov Date: Thu Apr 30 19:44:14 2026 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..51d4afa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,543 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zen-widget" +version = "0.1.0" +dependencies = [ + "cocoa", + "core-graphics", + "ctrlc", + "dirs", + "objc", + "serde", + "toml", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ea8246d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "zen-widget" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "5" +ctrlc = "3" + +# Cocoa/AppKit for floating window +cocoa = "0.25" +core-graphics = "0.23" + +# For cross-platform event handling +objc = "0.2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2bb52f --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Zen Widget + +A floating ASCII companion that watches your terminal and comments on your work. Dead simple to install. + +## Current Status + +**Working:** Terminal-based demo that displays the widget in your terminal. + +**Planned:** Floating window version that stays on top of your terminal. + +## Installation + +```bash +# 1. Clone +git clone https://github.com/yourname/zen-widget.git ~/zen-widget +cd ~/zen-widget + +# 2. Build (requires Rust) +cargo build --release + +# 3. Run +./target/release/zen-widget +``` + +## Requirements + +- macOS (window detection uses CGWindowList APIs) +- tmux (for terminal content analysis) +- Rust (for building) + +## Quick Demo + +```bash +cargo run +``` + +This displays: +``` +╔══════════════════════════════════════════╗ +║ Zen Widget Starting ║ +╚══════════════════════════════════════════╝ + + Terminals: ["Ghostty", "iTerm2", "Alacritty", ...] + Corner: top-right + Creature: ghost + Tmux Session: zen + Size: 200x150 + + Press Ctrl+C to exit +``` + +## Architecture + +``` +┌──────────────────────────────────────┐ +│ zen-widget (Rust) │ +│ - Monitors tmux session │ +│ - Displays ASCII creature │ +│ - Generates contextual quips │ +└──────────────────────────────────────┘ + ▲ + │ capture-pane -p + │ +┌──────────────────────────────────────┐ +│ tmux session "zen" │ +│ (hidden, for communication) │ +└──────────────────────────────────────┘ +``` + +## Configuration + +Edit `~/.config/zen-widget/config.toml`: + +```toml +# Terminals to track +target_terminals = ["Ghostty", "iTerm2", "Alacritty", "Terminal", "Kitty", "WezTerm"] + +# Corner: top-left, top-right, bottom-left, bottom-right +corner = "top-right" + +# Creature type: ghost, cat, octopus, bean, slime +creature = "ghost" + +# tmux session name +tmux_session = "zen" + +# Widget size +width = 200 +height = 150 + +# Opacity (0.0 to 1.0) +opacity = 0.85 + +# Comment update interval (ticks, ~1 tick per second) +comment_interval = 30 + +# Position refresh interval (ms) +refresh_ms = 10000 +``` + +## How It Works + +### 1. tmux Bridge +- Creates hidden tmux session named `zen` +- Widget captures pane content: `tmux capture-pane -t zen -p` +- Parses output, generates appropriate quip + +### 2. Creature Animation +- 5 creature types: ghost, cat, octopus, bean, slime +- Simple walking animation (3 frames) +- Random quips based on detected activity + +### 3. Context-Aware Quips +- **Errors**: "oof, that doesn't look great" +- **Warnings**: "just a warning, probably fine" +- **Builds**: "compiling, make a coffee" +- **Git**: "making commits, good human" +- **Cargo/npm**: "downloading the internet" + +## Future: Floating Window + +The plan is to add a floating widget window using standard macOS APIs: + +- `NSWindow` with `level = .floating` +- Position tracking via `CGWindowListCopyWindowInfo` +- Semi-transparent background +- ASCII creature + quip display + +This avoids yabai/SIP complexity but requires Accessibility permissions. + +## License + +MIT diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..78189d6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,133 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Config { + /// Terminal apps to track + #[serde(default = "default_terminals")] + pub target_terminals: Vec, + + /// Corner to position widget: "top-left", "top-right", "bottom-left", "bottom-right" + #[serde(default = "default_corner")] + pub corner: String, + + /// Creature type: "ghost", "cat", "octopus", "bean", "slime" + #[serde(default = "default_creature")] + pub creature: String, + + /// Tmux session name for communication + #[serde(default = "default_tmux_session")] + pub tmux_session: String, + + /// Widget window width + #[serde(default = "default_width")] + pub width: u32, + + /// Widget window height + #[serde(default = "default_height")] + pub height: u32, + + /// Widget opacity (0.0 to 1.0) + #[serde(default = "default_opacity")] + pub opacity: f64, + + /// How often to update comment (in ticks, ~1 tick per second) + #[serde(default = "default_comment_interval")] + pub comment_interval: u64, + + /// Refresh position interval in ms + #[serde(default = "default_refresh_ms")] + pub refresh_ms: u64, +} + +fn default_terminals() -> Vec { + vec![ + "Ghostty".to_string(), + "iTerm2".to_string(), + "Alacritty".to_string(), + "Terminal".to_string(), + "Kitty".to_string(), + "WezTerm".to_string(), + ] +} + +fn default_corner() -> String { + "top-right".to_string() +} + +fn default_creature() -> String { + "ghost".to_string() +} + +fn default_tmux_session() -> String { + "zen".to_string() +} + +fn default_width() -> u32 { + 300 +} + +fn default_height() -> u32 { + 200 +} + +fn default_opacity() -> f64 { + 0.85 +} + +fn default_comment_interval() -> u64 { + 30 +} + +fn default_refresh_ms() -> u64 { + 10000 +} + +impl Default for Config { + fn default() -> Self { + Self { + target_terminals: default_terminals(), + corner: default_corner(), + creature: default_creature(), + tmux_session: default_tmux_session(), + width: default_width(), + height: default_height(), + opacity: default_opacity(), + comment_interval: default_comment_interval(), + refresh_ms: default_refresh_ms(), + } + } +} + +impl Config { + pub fn load() -> Self { + let config_path = Self::config_path(); + + if config_path.exists() { + let content = std::fs::read_to_string(&config_path) + .expect("Failed to read config file"); + toml::from_str(&content).unwrap_or_else(|e| { + eprintln!("Failed to parse config: {}, using defaults", e); + Config::default() + }) + } else { + let default_config = Config::default(); + // Create config directory and file + if let Some(dir) = config_path.parent() { + std::fs::create_dir_all(dir).ok(); + } + if let Ok(content) = toml::to_string_pretty(&default_config) { + std::fs::write(&config_path, &content).ok(); + println!("Created default config at {:?}", config_path); + } + default_config + } + } + + fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("zen-widget") + .join("config.toml") + } +} diff --git a/src/creature.rs b/src/creature.rs new file mode 100644 index 0000000..2c3d7b6 --- /dev/null +++ b/src/creature.rs @@ -0,0 +1,217 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +// ASCII art frames for different creatures + +const GHOST_FRAMES: &[&str] = &[ + r#" / + /~ + | |_ + |___|"#, + r#" | + /~ +/| + |_ + |___|"#, + r#" \ + /~ + | | + |_|_ + |___|"#, + r#" | + \~| + \| + _|_ + |___|"#, +]; + +const CAT_FRAMES: &[&str] = &[ + r#" /\__/\ + / \ +:| o o | +:| < | + \_____/"#, + r#" /\__/\ + / \ +:| o o | +:| ^ | + \_____/"#, + r#" /\__/\ + / \ +:| - - | +:| < | + \_____/"#, +]; + +const OCTOPUS_FRAMES: &[&str] = &[ + r#" ____ + / \ + | oo | + \____/ + |||| +/\/||\/\"#, + r#" ____ + / \ + | oo | + \____/ + |||| + \/||\/\"#, + r#" ____ + / \ + | oo | + \____/ + |||| + /\||/\ "#, + r#" ____ + / \ + | oo | + \____/ + |||| + \/||\/\"#, +]; + +const BEAN_FRAMES: &[&str] = &[ + r#" ___ + /o o\ + \___/"#, + r#" ___ + /- -\ + \___/"#, + r#" ___ + \_o_/ + / \"#, + r#" ___ + \_-\_/ + / \"#, +]; + +const SLIME_FRAMES: &[&str] = &[ + r#" ___ + / \ + | | + \___/"#, + r#" _____ + / \ +:| o o | + \_____/"#, + r#" _____ + / \ +:| o | + \_____/"#, + r#" ___ + / \ + | oo | + \___/"#, +]; + +pub fn get_frames(creature: &str) -> &'static [&'static str] { + match creature.to_lowercase().as_str() { + "ghost" => &GHOST_FRAMES, + "cat" => &CAT_FRAMES, + "octopus" => &OCTOPUS_FRAMES, + "bean" => &BEAN_FRAMES, + "slime" => &SLIME_FRAMES, + _ => &GHOST_FRAMES, + } +} + +pub fn draw_creature(creature: &str, tick: u64) -> String { + let frames = get_frames(creature); + let frame_idx = (tick as usize / 3) % frames.len(); + frames[frame_idx].to_string() +} + +pub fn get_creature_state(tick: u64) -> String { + // Simple state: idle, walking, excited + match tick % 20 { + 0..=10 => "idle".to_string(), + 11..=15 => "walking".to_string(), + 16..=18 => "excited".to_string(), + _ => "idle".to_string(), + } +} + +pub fn get_comment(content: &str) -> String { + let content_lower = content.to_lowercase(); + + // Analyze tmux content and generate appropriate quip + if content_lower.contains("error") || content_lower.contains("failed") || content_lower.contains("panic") { + quip(&[ + "oof, that doesn't look great", + "errors happen, keep going", + "the compiler is skeptical", + "that's a spicy meatball", + ]) + } else if content_lower.contains("warning") { + quip(&[ + "just a warning, probably fine", + "the compiler has concerns", + "minor hiccup", + ]) + } else if content_lower.contains("compil") || content_lower.contains("build") { + quip(&[ + "building... patience", + "compiling, make a coffee", + "the magic happens slowly", + ]) + } else if content_lower.contains("git") { + quip(&[ + "making commits, good human", + "version control time", + "pushing to the cloud", + ]) + } else if content_lower.contains("cargo") || content_lower.contains("npm") || content_lower.contains("pip") { + quip(&[ + "downloading the internet", + "dependencies incoming", + "a folder full of hope", + ]) + } else if content_lower.contains("rust") { + quip(&[ + "may your borrow checker be satisfied", + "fearless concurrency vibes", + "rustacean at work", + ]) + } else if content_lower.is_empty() { + quip(&[ + "thinking...", + "nothing happening", + "the void stares back", + ]) + } else { + quip(&[ + "looking productive", + "the bits are flowing", + "i'm watching you work", + "nice typing", + "carrying on as usual", + ]) + } +} + +fn quip(options: &[&str]) -> String { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as usize; + options[seed % options.len()].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_draw_creature() { + let ghost = draw_creature("ghost", 0); + assert!(ghost.contains('/') || ghost.contains('|')); + } + + #[test] + fn test_get_comment() { + let error_comment = get_comment("error: something failed"); + assert!(!error_comment.is_empty()); + + let empty_comment = get_comment(""); + assert!(!empty_comment.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c81a476 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,205 @@ +#[macro_use] +extern crate objc; + +mod config; +mod creature; +mod tmux; +mod window; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +use crate::config::Config; +use crate::creature::{draw_creature, get_comment}; +use crate::tmux::get_tmux_content; +use crate::window::{ + create_floating_window, get_widget_position, Corner, StatusBarMenu, +}; + +static RUNNING: AtomicBool = AtomicBool::new(true); + +fn main() { + // Parse config + let config = Config::load(); + + println!("╔══════════════════════════════════════════╗"); + println!("║ Zen Widget Starting ║"); + println!("╚══════════════════════════════════════════╝"); + println!(); + println!(" Terminals: {:?}", config.target_terminals); + println!(" Corner: {}", config.corner); + println!(" Creature: {}", config.creature); + println!(" Tmux Session: {}", config.tmux_session); + println!(" Size: {}x{}", config.width, config.height); + println!(" Opacity: {}", config.opacity); + println!(); + + // Set up Ctrl+C handler + ctrlc::set_handler(|| { + println!("\nShutting down Zen Widget..."); + RUNNING.store(false, Ordering::SeqCst); + }).expect("Error setting Ctrl-C handler"); + + // Ensure tmux session exists + let tmux_content = get_tmux_content(&config.tmux_session); + if tmux_content.is_empty() { + println!(" [tmux] Created new session '{}'", config.tmux_session); + } else { + println!(" [tmux] Using existing session '{}'", config.tmux_session); + } + + // Get initial widget position + let corner = Corner::from_str(&config.corner); + let (pos_x, pos_y) = match get_widget_position(&corner, config.width, config.height) { + Some((x, y)) => { + println!(" [window] Position: ({:.0}, {:.0})", x, y); + println!(" [window] Widget size: {}x{}", config.width, config.height); + // Print screen info + if let Some((sx, sy, sw, sh)) = crate::window::get_screen_info() { + println!(" [window] Screen: {}x{} at ({}, {})", sw, sh, sx, sy); + } + (x, y) + } + None => { + eprintln!(" [window] Could not get screen dimensions, using defaults"); + (100.0, 100.0) + } + }; + + // Create floating window + println!(" [window] Creating floating window..."); + let window = create_floating_window( + pos_x, + pos_y, + config.width as f64, + config.height as f64, + config.opacity, + ); + + if let Some(fw) = window { + println!(" [window] Floating window created!"); + + // Current creature state + let current_creature = config.creature.clone(); + + // Create status bar menu + println!(" [menu] Creating status bar menu..."); + let mut status_menu = StatusBarMenu::new(); + + // Set up creature change callback + status_menu.setup_menu(move |new_creature| { + println!(" [menu] Creature changed to: {}", new_creature); + }); + + println!(" [menu] Status bar menu created!"); + println!(); + println!(" Press Ctrl+C to exit"); + println!(); + + // Initial content + let tmux_content = get_tmux_content(&config.tmux_session); + let comment = get_comment(&tmux_content); + let content = format_widget(&config.creature, 0, &comment); + fw.update_content(&content); + + println!(" Window ready! The widget should now be visible on your screen."); + println!(" Look for:"); + println!(" 1. A dark gray box in the TOP-RIGHT corner"); + println!(" 2. 'Zen' text in the menu bar"); + println!(); + println!(" Press Ctrl+C to exit"); + println!(); + + // Run the Cocoa event loop + // This keeps the window alive and responsive + fw.run(); + + println!("\nZen Widget stopped."); + } else { + eprintln!("Failed to create floating window. Make sure you're running on macOS."); + eprintln!("Falling back to terminal-only mode..."); + println!(); + + // Fallback to terminal-only mode + let mut tick = 0u64; + while RUNNING.load(Ordering::SeqCst) { + thread::sleep(Duration::from_secs(1)); + tick += 1; + + if tick % config.comment_interval == 0 { + let tmux_content = get_tmux_content(&config.tmux_session); + let comment = get_comment(&tmux_content); + print_widget(&config.creature, tick, &comment); + } + } + } +} + +fn format_widget(creature: &str, tick: u64, comment: &str) -> String { + let creature_lines = draw_creature(creature, tick); + let max_line_width = creature_lines.lines() + .map(|l| l.len()) + .max() + .unwrap_or(20); + + let comment_width = comment.len() + 4; + let total_width = max_line_width.max(comment_width).max(15); + + let border = "─".repeat(total_width); + let mut result = format!("┌─{}─┐\n", border); + + let title = " Zen Widget "; + let padding = total_width - title.len(); + let left_pad = padding / 2; + let right_pad = padding - left_pad; + result.push_str(&format!("│{}{}{}│\n", " ".repeat(left_pad), title, " ".repeat(right_pad))); + + for line in creature_lines.lines() { + let padding = total_width - line.len(); + result.push_str(&format!("│ {} │\n", line.to_string() + &" ".repeat(padding))); + } + + result.push_str(&format!("│ {} │\n", " ".repeat(total_width))); + + // Comment with wrapping + let wrapped_comment = wrap_text(comment, total_width - 4); + for line in wrapped_comment { + let padding = total_width - line.len() - 2; + result.push_str(&format!("│ {} │\n", " ".repeat(2) + &line + &" ".repeat(padding))); + } + + result.push_str(&format!("└─{}─┘", border)); + result +} + +fn print_widget(creature: &str, tick: u64, comment: &str) { + let content = format_widget(creature, tick, comment); + println!("{}", content); + println!(); +} + +fn wrap_text(text: &str, max_width: usize) -> Vec { + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in text.split_whitespace() { + if current_line.len() + word.len() + 1 <= max_width { + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(word); + } else { + if !current_line.is_empty() { + lines.push(current_line.clone()); + } + current_line = word.to_string(); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines +} diff --git a/src/tmux.rs b/src/tmux.rs new file mode 100644 index 0000000..7cce10a --- /dev/null +++ b/src/tmux.rs @@ -0,0 +1,63 @@ +use std::process::Command; + +/// Get the content from a tmux pane +pub fn get_tmux_content(session_name: &str) -> String { + // First check if session exists + if !session_exists(session_name) { + // Create the session if it doesn't exist + let create_output = Command::new("tmux") + .args(&["new-session", "-d", "-s", session_name, "-x", "80", "-y", "24"]) + .output(); + + if create_output.map(|o| !o.status.success()).unwrap_or(true) { + return String::new(); + } + } + + // Capture the pane content + let output = Command::new("tmux") + .args(&["capture-pane", "-t", session_name, "-p"]) + .output(); + + match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => String::new(), + } +} + +/// Check if a tmux session exists +fn session_exists(session_name: &str) -> bool { + Command::new("tmux") + .args(&["has-session", "-t", session_name]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Send a message to a tmux session (useful for testing) +#[allow(dead_code)] +pub fn send_tmux_message(session_name: &str, message: &str) -> bool { + Command::new("tmux") + .args(&["send-keys", "-t", session_name, message, "Enter"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_tmux_content() { + // This will create the session if it doesn't exist + let content = get_tmux_content("zen-widget-test"); + // Should return something (possibly empty) + assert!(content.len() >= 0); + + // Clean up + let _ = Command::new("tmux") + .args(&["kill-session", "-t", "zen-widget-test"]) + .output(); + } +} diff --git a/src/window.rs b/src/window.rs new file mode 100644 index 0000000..111dcb5 --- /dev/null +++ b/src/window.rs @@ -0,0 +1,475 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use core_graphics::display::CGDisplay; +use core_graphics::geometry::CGRect; + +use cocoa::appkit::{NSMenu, NSWindow}; +use cocoa::base::{nil, id, YES, NO}; +use cocoa::foundation::{NSRect, NSPoint, NSSize, NSString, NSDate}; +use objc::msg_send; +use objc::sel; + +// NSDefaultRunLoopMode constant +const NSDefaultRunLoopMode: &'static str = "NSDefaultRunLoopMode"; + +// Debug flag - defaults to true +static DEBUG_MODE: AtomicBool = AtomicBool::new(true); + +#[macro_export] +macro_rules! dprintln { + ($($arg:tt)*) => { + if $crate::window::is_debug_enabled() { + println!($($arg)*); + } + }; +} + +pub fn is_debug_enabled() -> bool { + DEBUG_MODE.load(Ordering::SeqCst) +} + +pub fn set_debug_enabled(enabled: bool) { + DEBUG_MODE.store(enabled, Ordering::SeqCst); + println!("[DEBUG] Debug mode: {}", if enabled { "ON" } else { "OFF" }); +} + +#[derive(Debug, Clone, Copy)] +pub enum Corner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +impl Corner { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "top-left" | "topleft" => Corner::TopLeft, + "top-right" | "topright" => Corner::TopRight, + "bottom-left" | "bottomleft" => Corner::BottomLeft, + "bottom-right" | "bottomright" => Corner::BottomRight, + _ => Corner::TopRight, + } + } + + pub fn position(&self, screen: &CGRect, widget_width: f64, widget_height: f64, offset: i32) -> (f64, f64) { + let x = match self { + Corner::TopLeft | Corner::BottomLeft => screen.origin.x + offset as f64, + Corner::TopRight | Corner::BottomRight => screen.origin.x + screen.size.width - widget_width - offset as f64, + }; + // For y, CG displays use bottom-left origin, but NSWindow uses top-left origin + // So we need to flip: y_ns = screen_height - widget_y_cg - widget_height + let screen_height = screen.size.height; + let y_cg = match self { + Corner::TopLeft | Corner::TopRight => screen_height - widget_height - offset as f64, + Corner::BottomLeft | Corner::BottomRight => screen.origin.y + offset as f64, + }; + let y = screen_height - y_cg - widget_height; + (x, y) + } + + pub fn name(&self) -> &'static str { + match self { + Corner::TopLeft => "Top Left", + Corner::TopRight => "Top Right", + Corner::BottomLeft => "Bottom Left", + Corner::BottomRight => "Bottom Right", + } + } +} + +#[derive(Debug)] +pub struct WindowInfo { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub owner_name: String, +} + +fn get_main_display() -> Option { + let display = CGDisplay::main(); + let bounds = display.bounds(); + Some(bounds) +} + +/// Get screen dimensions +pub fn get_screen_info() -> Option<(f64, f64, f64, f64)> { + let display = CGDisplay::main(); + let bounds = display.bounds(); + Some((bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height)) +} + +/// Find the position of a terminal window and calculate widget position +pub fn find_terminal_position(_target_terminals: &[String], corner_str: &str) -> Option<(i32, i32)> { + let corner = Corner::from_str(corner_str); + let screen = get_main_display()?; + + let offset = 20; + let (x, y) = corner.position(&screen, 200.0, 150.0, offset); + Some((x as i32, y as i32)) +} + +// ============================================================================ +// Floating Window Implementation +// ============================================================================ + +const NSFLOATING_WINDOW_LEVEL: i32 = 3; + +// Global state for the Cocoa thread +static WINDOW_HANDLE: std::sync::atomic::AtomicPtr = std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); +static TEXT_FIELD_HANDLE: std::sync::atomic::AtomicPtr = std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); + +pub struct FloatingWindow { + window: id, + text_field: id, + width: f64, + height: f64, +} + +impl FloatingWindow { + pub fn new(x: f64, y: f64, width: f64, height: f64, opacity: f64) -> Self { + unsafe { + // Initialize NSApplication + let ns_app: id = msg_send![class!(NSApplication), sharedApplication]; + + // Set activation policy to regular (shows in Dock) + // This ensures the window actually appears on screen + // 0 = NSApplicationActivationPolicyRegular + let _: () = msg_send![ns_app, setActivationPolicy: 0]; + + // Finish launching + let _: () = msg_send![ns_app, finishLaunching]; + + dprintln!("NSApplication initialized"); + + // Create the window + let window_rect = NSRect::new(NSPoint::new(x, y), NSSize::new(width, height)); + dprintln!("Creating window at ({:.0}, {:.0}) size ({:.0}, {:.0})", x, y, width, height); + + // Create window with borderless style mask + let window: id = msg_send![class!(NSWindow), alloc]; + let window: id = msg_send![window, + initWithContentRect: window_rect + styleMask: 0 // NSBorderlessWindowMask + backing: 2 // NSBackingStoreBuffered + defer: 0]; + + // Set floating window level (above normal windows) + let level: i32 = NSFLOATING_WINDOW_LEVEL; + let _: () = msg_send![window, setLevel: level]; + + // CRITICAL: Set collection behavior for floating window on all spaces + // NSWindowCollectionBehaviorCanJoinAllSpaces = 1 << 3 = 8 + // NSWindowCollectionBehaviorFullScreenAuxiliary = 1 << 16 = 65536 + let collection_behavior: u64 = 8 | 65536; + let _: () = msg_send![window, setCollectionBehavior: collection_behavior]; + + // Set basic properties + let _: () = msg_send![window, setOpaque: 0]; + // Note: setBackgroundColor with colorWithRed: causes crashes, skip for now + // Background is handled by NSBox subview instead + let _: () = msg_send![window, setAlphaValue: opacity as f32]; + let _: () = msg_send![window, setHasShadow: 1]; + + // Get content view + let content_view: id = msg_send![window, contentView]; + + // Set the content view to have a solid dark background + let _: () = msg_send![content_view, setWantsLayer: YES]; + let layer: id = msg_send![content_view, layer]; + + // Create a solid color layer for background + // Use simple dark gray + let bg_color: id = msg_send![class!(NSColor), darkGrayColor]; + let _: () = msg_send![layer, setBackgroundColor: msg_send![bg_color, CGColor]]; + + // Set corner radius + let _: () = msg_send![layer, setCornerRadius: 12.0]; + let _: () = msg_send![layer, setMasksToBounds: YES]; + + // Create text field for ASCII art display + let text_rect = NSRect::new( + NSPoint::new(5.0, 5.0), + NSSize::new(width - 10.0, height - 10.0), + ); + let text_field: id = msg_send![class!(NSTextField), alloc]; + let text_field: id = msg_send![text_field, initWithFrame: text_rect]; + + // Configure text field + let _: () = msg_send![text_field, setBezeled: 0]; + let _: () = msg_send![text_field, setSelectable: 0]; + let _: () = msg_send![text_field, setAlignment: 1]; // NSCenterTextAlignment + let _: () = msg_send![text_field, setDrawsBackground: 0]; + let _: () = msg_send![text_field, setEditable: 0]; + + // Set white text color + let white_color: id = msg_send![class!(NSColor), whiteColor]; + let _: () = msg_send![text_field, setTextColor: white_color]; + + // Set font + let font: id = msg_send![class!(NSFont), systemFontOfSize: 12.0]; + let _: () = msg_send![text_field, setFont: font]; + + // Set string value with default content + let default_text = NSString::alloc(nil).init_str("Zen Widget\nLoading..."); + let _: () = msg_send![text_field, setStringValue: default_text]; + + // Add text field to content view (on top of background) + let _: () = msg_send![content_view, addSubview: text_field]; + + // Show window + let _: () = msg_send![window, makeKeyAndOrderFront: nil]; + + // Force the window to become visible + let _: () = msg_send![window, orderFrontRegardless]; + + // Activate the app to ensure window appears + let _: () = msg_send![ns_app, activateIgnoringOtherApps: YES]; + + dprintln!("Floating window created successfully at ({:.0}, {:.0})", x, y); + Self { + window, + text_field, + width, + height, + } + } + } + + pub fn update_content(&self, content: &str) { + unsafe { + let ns_string = NSString::alloc(nil).init_str(content); + let _: () = msg_send![self.text_field, setStringValue: ns_string]; + } + dprintln!("Content updated: {} chars", content.len()); + } + + pub fn set_position(&self, x: f64, y: f64) { + unsafe { + let new_origin = NSPoint::new(x, y); + let _: () = msg_send![self.window, setFrameOrigin: new_origin]; + } + dprintln!("Position set to ({:.0}, {:.0})", x, y); + } + + pub fn show(&self) { + unsafe { + let _: () = msg_send![self.window, orderFront: nil]; + } + dprintln!("Window shown"); + } + + pub fn hide(&self) { + unsafe { + let _: () = msg_send![self.window, orderOut: nil]; + } + dprintln!("Window hidden"); + } + + /// Run the Cocoa event loop + /// Returns when stop() is called or app terminates + pub fn run(&self) { + unsafe { + let ns_app: id = msg_send![class!(NSApplication), sharedApplication]; + + // Run the event loop manually + loop { + // Get next event (with timeout) + let date: id = msg_send![class!(NSDate), dateWithTimeIntervalSinceNow: 0.001]; + let event: id = msg_send![ns_app, + nextEventMatchingMask: u64::MAX + untilDate: date + inMode: NSDefaultRunLoopMode + dequeue: YES]; + + if event != nil { + let _: () = msg_send![ns_app, sendEvent: event]; + } + + // Update windows + let _: () = msg_send![ns_app, updateWindows]; + + // Check if we should stop (we could use a flag here) + // For now, just keep running + } + } + } + + /// Stop the event loop (call this to exit) + pub fn stop(&self) { + unsafe { + let ns_app: id = msg_send![class!(NSApplication), sharedApplication]; + let _: () = msg_send![ns_app, stop: nil]; + + // Post a dummy event to wake up the run loop + let event: id = msg_send![class!(NSEvent), otherEventWithType:0 + location:NSPoint::new(0.0, 0.0) + modifierFlags:0 + timestamp:0.0 + windowNumber:0 + context:nil + subtype:0 + data1:0 + data2:0]; + let _: () = msg_send![ns_app, postEvent: event atStart: YES]; + } + } +} + +/// Create a floating widget window +pub fn create_floating_window( + x: f64, + y: f64, + width: f64, + height: f64, + opacity: f64, +) -> Option { + Some(FloatingWindow::new(x, y, width, height, opacity)) +} + +// ============================================================================ +// Status Bar Menu +// ============================================================================ + +pub struct StatusBarMenu { + status_item: id, + menu: id, +} + +impl StatusBarMenu { + pub fn new() -> Self { + unsafe { + // Get the system status bar using message send + let status_bar: id = msg_send![class!(NSStatusBar), systemStatusBar]; + + // Create status item with fixed length (gives more consistent appearance) + let status_item: id = msg_send![status_bar, + statusItemWithLength: 50.0]; // Fixed width + + // Retain to prevent deallocation + let status_item: id = msg_send![status_item, retain]; + + // Create the menu + let menu: id = msg_send![class!(NSMenu), alloc]; + let menu: id = msg_send![menu, init]; + let menu: id = msg_send![menu, retain]; + + // Get the button from the status item + let button: id = msg_send![status_item, button]; + + // Set title for the button with a symbol + let title = NSString::alloc(nil).init_str("Z"); + let _: () = msg_send![button, setTitle: title]; + + // Set a tooltip + let tooltip = NSString::alloc(nil).init_str("Zen Widget"); + let _: () = msg_send![button, setToolTip: tooltip]; + + // Make button appear highlighted when menu is open + let _: () = msg_send![button, setHighlight: NO]; + + // Set the menu on the status item + let _: () = msg_send![status_item, setMenu: menu]; + + dprintln!("Status bar created successfully"); + + Self { + status_item, + menu, + } + } + } + + pub fn setup_menu(&mut self, _on_creature_change: F) + where F: Clone + 'static + { + unsafe { + // Clear existing menu + let _: () = msg_send![self.menu, removeAllItems]; + + // Header + let title = NSString::alloc(nil).init_str("Zen Widget"); + let empty_key = NSString::alloc(nil).init_str(""); + let header_item: id = msg_send![class!(NSMenuItem), alloc]; + let header_item: id = msg_send![header_item, + initWithTitle:title + action:nil + keyEquivalent:empty_key]; + let _: () = msg_send![header_item, setEnabled:NO]; + self.menu.addItem_(header_item); + + // Creature selection + let creatures = vec!["ghost", "cat", "octopus", "bean", "slime"]; + for creature in creatures { + let title = NSString::alloc(nil).init_str(creature); + let empty_key = NSString::alloc(nil).init_str(""); + let item: id = msg_send![class!(NSMenuItem), alloc]; + let item: id = msg_send![item, + initWithTitle:title + action:sel!(selectCreature:) + keyEquivalent:empty_key]; + self.menu.addItem_(item); + } + + // Debug options + let title = NSString::alloc(nil).init_str("Toggle Debug Mode (d)"); + let key = NSString::alloc(nil).init_str("d"); + let debug_item: id = msg_send![class!(NSMenuItem), alloc]; + let debug_item: id = msg_send![debug_item, + initWithTitle:title + action:sel!(toggleDebug:) + keyEquivalent:key]; + self.menu.addItem_(debug_item); + + let title = NSString::alloc(nil).init_str("Show Window Info (i)"); + let key = NSString::alloc(nil).init_str("i"); + let info_item: id = msg_send![class!(NSMenuItem), alloc]; + let info_item: id = msg_send![info_item, + initWithTitle:title + action:sel!(showDebugInfo:) + keyEquivalent:key]; + self.menu.addItem_(info_item); + + // Quit + let title = NSString::alloc(nil).init_str("Quit (q)"); + let key = NSString::alloc(nil).init_str("q"); + let quit_item: id = msg_send![class!(NSMenuItem), alloc]; + let quit_item: id = msg_send![quit_item, + initWithTitle:title + action:sel!(quitApp:) + keyEquivalent:key]; + self.menu.addItem_(quit_item); + + dprintln!("Menu items added"); + } + } +} + +/// Get screen dimensions and calculate widget position +pub fn get_widget_position(corner: &Corner, widget_width: u32, widget_height: u32) -> Option<(f64, f64)> { + let screen = get_main_display()?; + let offset = 20; + let (x, y) = corner.position(&screen, widget_width as f64, widget_height as f64, offset); + Some((x, y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_corner_from_str() { + assert!(matches!(Corner::from_str("top-right"), Corner::TopRight)); + assert!(matches!(Corner::from_str("topleft"), Corner::TopLeft)); + assert!(matches!(Corner::from_str("invalid"), Corner::TopRight)); + } + + #[test] + fn test_widget_position() { + let screen = CGRect::new(CGDisplay::main().bounds().origin, CGDisplay::main().bounds().size); + let (x, y) = Corner::TopRight.position(&screen, 200.0, 150.0, 20); + assert!(x > 0.0); + assert!(y > 0.0); + } +}