Initial commit

This commit is contained in:
devmobasa
2025-10-13 23:36:05 +02:00
commit 4389fb4d45
33 changed files with 7610 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Rust
/target
**/*.rs.bk
# Cargo
# Keep Cargo.lock for binaries for reproducible builds (uncomment to ignore for libs)
# Cargo.lock
# Editors/OS
.idea/
.vscode/
.DS_Store
*.swp
*.swo
# ZoomIt reference files (not part of our implementation)
/ZoomIt/
# Test scripts
test_run.sh
run.sh
!/packaging/hyprmarker.service
packaging/
Generated
+1951
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "hyprmarker"
version = "0.1.0"
edition = "2024"
[dependencies]
# Wayland
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
smithay-client-toolkit = { version = "0.20", default-features = false, features = ["calloop", "xkbcommon"] }
calloop = "0.14"
# Cairo for drawing
cairo-rs = { version = "0.21", features = ["png"] }
cairo-sys-rs = "0.21"
# Pango for advanced text rendering and font support
pango = "0.21"
pangocairo = "0.21"
# Utils
nix = "0.30"
memmap2 = "0.9"
thiserror = "1.0"
log = "0.4"
env_logger = "0.11"
clap = { version = "4.5", features = ["derive"] }
anyhow = "1.0"
toml = "0.9"
serde = { version = "1.0", features = ["derive"] }
dirs = "6.0"
# Daemon mode
signal-hook = "0.3"
ksni = "0.3"
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+451
View File
@@ -0,0 +1,451 @@
# hyprmarker
A ZoomIt-like screen annotation tool for Wayland compositors, written in Rust. Draw freehand, create shapes, add text, and annotate your screen during presentations or screen recordings.
## Demo
![Demo](demo.gif)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)
## Quick Install (Arch Linux)
**Install from AUR:**
```bash
# Using yay
yay -S hyprmarker
# Using paru
paru -S hyprmarker
```
**Setup daemon mode:**
```bash
# Enable systemd service (starts on login)
systemctl --user enable --now hyprmarker.service
# Add keybind to ~/.config/hypr/hyprland.conf
bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker
```
**Done!** Press `Super+D` to toggle overlay, draw with your mouse, press `Escape` to exit.
---
## Features
hyprmarker replicates all the drawing features of Microsoft's ZoomIt for Linux with native Wayland support:
- **Freehand Drawing**: Draw freely with your cursor (default mode)
- **Straight Lines**: Hold `Shift` while dragging
- **Rectangles**: Hold `Ctrl` while dragging
- **Ellipses/Circles**: Hold `Tab` while dragging
- **Arrows**: Hold `Ctrl+Shift` while dragging
- **Text Annotations**: Press `T` to enter text mode, with multi-line support (`Shift+Enter`) and custom fonts
- **Color Selection**: Press color keys (R, G, B, Y, O, P, W, K)
- **Adjustable Line Thickness**: Use `+`/`-` keys or scroll wheel
- **Undo**: `Ctrl+Z` to undo last shape
- **Clear All**: Press `E` to clear all annotations
- **Help Overlay**: Press `F10` to show/hide keybinding help
- **Status Bar**: Shows current tool, color, and thickness
- **Customizable**: TOML-based configuration file
- **Exit**: Press `Escape` to exit drawing mode
## Installation (Other Distros)
### Prerequisites
**System Dependencies:**
```bash
# Ubuntu/Debian
sudo apt-get install libcairo2-dev libwayland-dev libpango1.0-dev
# Fedora
sudo dnf install cairo-devel wayland-devel pango-devel
```
**Requirements:**
- Wayland compositor with wlr-layer-shell protocol support (Hyprland, Sway, etc.)
- Rust 1.70+ for building from source
### Build from Source
```bash
git clone https://github.com/devmobasa/hyprmarker.git
cd hyprmarker
cargo build --release
```
The binary will be at `target/release/hyprmarker`
### Install Script (Manual Build)
```bash
# Build and install locally
cargo build --release
./tools/install.sh
```
The installer will:
- Install binary to `~/.local/bin/hyprmarker`
- Create config directory at `~/.config/hyprmarker/`
- Optionally add Hyprland configuration
## Usage
### Daemon Mode (Recommended)
Run hyprmarker as a background daemon and toggle it with `Super+D`:
**Option 1: Systemd User Service (Recommended)**
Automatically starts on login and appears in system tray:
```bash
# Run installer and choose option 1
./tools/install.sh
# Or setup manually:
mkdir -p ~/.config/systemd/user
cp packaging/hyprmarker.service ~/.config/systemd/user/
systemctl --user enable --now hyprmarker.service
```
Then add keybind to Hyprland config:
```conf
bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker
```
**Option 2: Hyprland exec-once**
Run installer and choose option 2, or add manually to `~/.config/hypr/hyprland.conf`:
```conf
# Autostart hyprmarker daemon
exec-once = hyprmarker --daemon
# Toggle overlay with Super+D
bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker
```
Then reload: `hyprctl reload`
**Usage:**
- Daemon starts automatically on login
- System tray icon appears (may be in Waybar drawer/expander)
- Press `Super+D` to show overlay and start drawing
- Press `Ctrl+Q` or `Escape` to hide overlay (daemon keeps running)
- Right-click tray icon for menu (Toggle/Quit)
### One-Shot Mode
For quick one-time annotations:
```bash
hyprmarker --active
```
Or bind to a key:
```conf
bind = $mainMod, D, exec, hyprmarker --active
```
### Controls Reference
| Action | Key/Mouse |
|--------|-----------|
| **Drawing Tools** |
| Freehand pen | Default (just drag with left mouse) |
| Straight line | Hold `Shift` + drag |
| Rectangle | Hold `Ctrl` + drag |
| Ellipse/Circle | Hold `Tab` + drag |
| Arrow | Hold `Ctrl+Shift` + drag |
| Text mode | Press `T`, click to position, type, `Shift+Enter` for new line, `Enter` to finish |
| **Colors** |
| Red | `R` |
| Green | `G` |
| Blue | `B` |
| Yellow | `Y` |
| Orange | `O` |
| Pink | `P` |
| White | `W` |
| Black | `K` |
| **Line Thickness** |
| Increase | `+` or `=` or scroll down |
| Decrease | `-` or `_` or scroll up |
| **Editing** |
| Undo last shape | `Ctrl+Z` |
| Clear all | `E` |
| Cancel action | Right-click or `Escape` |
| **Help** |
| Toggle help overlay | `F10` |
| Exit overlay | `Escape` or `Ctrl+Q` |
## Configuration
hyprmarker supports customization through a TOML configuration file.
### Configuration Location
`~/.config/hyprmarker/config.toml`
### Creating Configuration
1. Copy the example config:
```bash
mkdir -p ~/.config/hyprmarker
cp config.example.toml ~/.config/hyprmarker/config.toml
```
2. Edit to your preferences:
```bash
nano ~/.config/hyprmarker/config.toml
```
### Configuration Options
```toml
[drawing]
# Default pen color: "red", "green", "blue", etc. or RGB [255, 0, 0]
default_color = "red"
default_thickness = 3.0 # Pixels (1.0-20.0)
default_font_size = 32.0 # Pixels (8.0-72.0)
# Custom font configuration (Pango-based)
font_family = "Sans" # Any installed font: "Monospace", "JetBrains Mono", etc.
font_weight = "bold" # "normal", "bold", "light", or numeric (100-900)
font_style = "normal" # "normal", "italic", "oblique"
text_background_enabled = false # Semi-transparent background behind text
[arrow]
length = 20.0 # Arrowhead length in pixels
angle_degrees = 30.0 # Arrowhead angle (15-60)
[performance]
buffer_count = 3 # 2=double, 3=triple (recommended), 4=quad buffering
enable_vsync = true # Prevent tearing
[ui]
show_status_bar = true
status_bar_position = "bottom-left" # top-left, top-right, bottom-left, bottom-right
```
See **[docs/CONFIG.md](docs/CONFIG.md)** for detailed configuration documentation.
## Architecture
hyprmarker is built with a native Wayland backend using modern Rust libraries:
```
hyprmarker/
├── src/
│ ├── main.rs # Entry point, CLI parsing
│ ├── daemon.rs # Daemon mode with signal handling
│ ├── ui.rs # Status bar and help overlay rendering
│ ├── util.rs # Utility functions
│ ├── backend/
│ │ ├── mod.rs # Backend module
│ │ └── wayland.rs # Wayland wlr-layer-shell implementation
│ ├── config/
│ │ ├── mod.rs # Configuration loader and validator
│ │ ├── types.rs # Config structure definitions
│ │ └── enums.rs # Color specs and enums
│ ├── draw/
│ │ ├── mod.rs # Drawing module
│ │ ├── color.rs # Color definitions and constants
│ │ ├── font.rs # Font descriptor for Pango
│ │ ├── frame.rs # Frame container for shapes
│ │ ├── shape.rs # Shape definitions (lines, text, etc.)
│ │ └── render.rs # Cairo/Pango rendering functions
│ └── input/
│ ├── mod.rs # Input handling module
│ ├── state.rs # Drawing state machine
│ ├── events.rs # Keyboard/mouse event types
│ ├── modifiers.rs # Modifier key tracking
│ └── tool.rs # Drawing tool enum
├── tools/ # Helper scripts (install, run, reload)
├── packaging/ # Distribution files (service, PKGBUILD)
├── docs/ # Documentation
└── config.example.toml # Example configuration
```
### Wayland Backend
The Wayland backend uses:
- **wlr-layer-shell**: Overlay surface creation
- **wl_shm**: Shared memory buffers with triple buffering
- **Cairo**: Vector graphics rendering
- **smithay-client-toolkit**: Wayland protocol handling
**Features:**
- ✅ Full-screen transparent overlay
- ✅ Native HiDPI/scaling support
- ✅ Triple-buffered rendering for smooth drawing
- ✅ Frame-synchronized updates (VSync)
- ✅ Exclusive input capture
- ✅ All drawing tools with live preview
## Platform Support
| Platform | Status | Notes |
|----------|--------|-------|
| Wayland (Hyprland, Sway, etc.) | ✅ **SUPPORTED** | Requires wlr-layer-shell protocol |
## Performance
hyprmarker is optimized for high-resolution displays with smooth 60 FPS drawing:
- **Triple buffering**: Prevents flickering during fast drawing
- **VSync synchronization**: Smooth frame pacing
- **Dirty region optimization**: Minimal data transfer
- **Efficient rendering**: Cairo vector graphics with buffer pooling
Performance characteristics:
- **4K displays**: Smooth 60 FPS
- **1080p/1440p**: Smooth 60 FPS
- **CPU usage**: Low, event-driven architecture
Configure buffer count and VSync in `config.toml` for your setup.
## Documentation
- **[docs/SETUP.md](docs/SETUP.md)** - Detailed installation and system setup
- **[docs/CONFIG.md](docs/CONFIG.md)** - Configuration reference
## Comparison with ZoomIt
| Feature | ZoomIt (Windows) | hyprmarker (Linux) |
|---------|-----------------|-------------------|
| Freehand drawing | ✅ | ✅ |
| Straight lines | ✅ | ✅ |
| Rectangles | ✅ | ✅ |
| Ellipses | ✅ | ✅ |
| Arrows | ✅ | ✅ |
| Text annotations | ✅ | ✅ |
| Multi-line text | ❌ | ✅ (Shift+Enter) |
| Custom fonts | ❌ | ✅ (Pango) |
| Color selection | ✅ | ✅ (8 colors) |
| Undo | ✅ | ✅ |
| Clear all | ✅ | ✅ |
| Help overlay | ❌ | ✅ |
| Status bar | ❌ | ✅ |
| Configuration file | ❌ | ✅ |
| Scroll wheel thickness | ❌ | ✅ |
| Zoom functionality | ✅ | ❌ (not planned) |
| Break timer | ✅ | ❌ (not planned) |
| Screen recording | ✅ | ❌ (not planned) |
## Troubleshooting
### Overlay not appearing
1. **Check Wayland environment:**
```bash
echo $WAYLAND_DISPLAY # Should show wayland-0 or similar
```
2. **Check compositor support:**
```bash
# Verify wlr-layer-shell protocol is available
# Works on: Hyprland, Sway, river, etc.
# Does NOT work on: GNOME Wayland, KDE Wayland (no wlr-layer-shell)
```
3. **Check logs:**
```bash
RUST_LOG=info ./target/release/hyprmarker --active
```
### Config not loading
```bash
# Verify config file exists
ls -la ~/.config/hyprmarker/config.toml
# Check for TOML syntax errors in logs
RUST_LOG=info ./target/release/hyprmarker --active
```
### Performance issues
Try adjusting performance settings in `config.toml`:
```toml
[performance]
buffer_count = 2 # Reduce if memory-constrained
enable_vsync = true # Keep enabled to prevent tearing
```
## Development
### Building for Development
```bash
cargo build
cargo run -- --active
```
### Enable Debug Logging
```bash
RUST_LOG=debug cargo run -- --active
```
### Running Tests
```bash
cargo test
cargo clippy # Linting
cargo fmt # Code formatting
```
## Contributing
Contributions are welcome! Areas where help is needed:
1. **Additional compositors**: Test on more Wayland compositors
2. **Multi-monitor support**: Per-monitor overlay surfaces
3. **Additional tools**: Filled shapes, highlighter, eraser
4. **Daemon mode**: Background process with hotkey toggle
5. **Save/load**: Export annotations to image files
## License
MIT License - see [LICENSE](LICENSE) file for details
## Acknowledgments
- Inspired by Microsoft's [ZoomIt](https://learn.microsoft.com/en-us/sysinternals/downloads/zoomit)
- Similar to [Gromit-MPX](https://github.com/bk138/gromit-mpx)
- Uses [Cairo](https://www.cairographics.org/) for rendering
- Built with [Rust](https://www.rust-lang.org/) and [smithay-client-toolkit](https://github.com/Smithay/client-toolkit)
## Development
This tool was developed with AI assistance:
- **Initial concept & planning**: ChatGPT
- **Architecture review & design**: GitHub Copilot
- **Implementation**: Claude Code (Anthropic)
Created as a native Wayland implementation of ZoomIt annotation features for Linux desktop environments.
## Roadmap
- [x] Native Wayland wlr-layer-shell implementation
- [x] Configuration file support
- [x] Status bar and help overlay
- [x] Scroll wheel thickness adjustment
- [x] Daemon mode with global hotkey toggle (Super+D)
- [x] System tray integration
- [x] Autostart with systemd user service
- [x] Multi-line text support (Shift+Enter)
- [x] Custom fonts with Pango rendering
- [ ] Multi-monitor support with per-monitor surfaces
- [ ] Additional shapes (filled shapes, highlighter)
- [ ] Save annotations to image file
- [ ] Eraser tool
- [ ] Color picker
---
**Note**: This tool focuses solely on screen annotation. For screen magnification (zoom), consider using built-in accessibility tools or dedicated magnifiers like `wlr-randr` zoom or compositor-specific zoom features.
+151
View File
@@ -0,0 +1,151 @@
# hyprmarker configuration file
# Location: ~/.config/hyprmarker/config.toml
#
# All settings are optional. If not specified, defaults will be used.
# Delete this file to reset to defaults.
# ═══════════════════════════════════════════════════════════════════════════════
# DRAWING SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
[drawing]
# Default pen color
# Options: "red", "green", "blue", "yellow", "orange", "pink", "white", "black"
# Or RGB array: [255, 0, 0]
default_color = "red"
# Default pen thickness in pixels (1.0 - 20.0)
default_thickness = 3.0
# Default font size for text mode (8.0 - 72.0)
default_font_size = 32.0
# ───────────────────────────────────────────────────────────────────────────────
# Font Configuration (Pango-based text rendering)
# ───────────────────────────────────────────────────────────────────────────────
# Font family name for text rendering
# Install fonts system-wide and reference by family name
# Examples to try:
# font_family = "Sans" # Default sans-serif
# font_family = "Monospace" # Fixed-width font
# font_family = "Serif" # Serif font
# font_family = "JetBrains Mono" # If installed
# font_family = "Fira Code" # If installed
# font_family = "Maple Mono NF" # If installed
# font_family = "DejaVu Sans" # Common on Linux
# font_family = "Liberation Sans" # Common on Linux
# font_family = "Noto Sans" # Google Noto fonts
# Falls back to "Sans" if the specified font is not available
font_family = "Sans"
# Font weight: "normal", "bold", "light", "ultralight", "heavy", "ultrabold"
# Or numeric: 100-900 (400=normal, 700=bold)
# Try different weights:
# font_weight = "normal" # Regular weight
# font_weight = "bold" # Bold (default, best visibility)
# font_weight = "light" # Light weight
# font_weight = "600" # Semi-bold (numeric)
font_weight = "bold"
# Font style: "normal", "italic", "oblique"
# Try different styles:
# font_style = "normal" # Standard upright text
# font_style = "italic" # Italic/cursive
# font_style = "oblique" # Slanted text
font_style = "normal"
# Enable semi-transparent background box behind text for better contrast
# Set to true if you find text hard to read against complex backgrounds
# Default: false (no background, cleaner look with just stroke outline)
text_background_enabled = false
# ═══════════════════════════════════════════════════════════════════════════════
# ARROW SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
[arrow]
# Arrowhead length in pixels
length = 20.0
# Arrowhead angle in degrees (15-60)
# 30 degrees gives a nice balanced arrow
angle_degrees = 30.0
# ═══════════════════════════════════════════════════════════════════════════════
# PERFORMANCE SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
[performance]
# Number of buffers for rendering (2, 3, or 4)
# 2 = double buffering (low memory)
# 3 = triple buffering (recommended, smooth)
# 4 = quad buffering (ultra-smooth on high refresh displays)
buffer_count = 3
# Enable vsync frame synchronization
# Prevents tearing and limits rendering to display refresh rate
enable_vsync = true
# ═══════════════════════════════════════════════════════════════════════════════
# UI SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
[ui]
# Show status bar with current color/thickness/tool
show_status_bar = true
# Status bar position
# Options: "top-left", "top-right", "bottom-left", "bottom-right"
status_bar_position = "bottom-left"
# ───────────────────────────────────────────────────────────────────────────────
# Status Bar Styling
# ───────────────────────────────────────────────────────────────────────────────
[ui.status_bar_style]
# Font size for status bar text
font_size = 21.0
# Padding around status bar text
padding = 15.0
# Background color [R, G, B, A] (0.0-1.0 range)
# Default: semi-transparent black (85% opaque for visibility)
bg_color = [0.0, 0.0, 0.0, 0.85]
# Text color [R, G, B, A] (0.0-1.0 range)
# Default: white
text_color = [1.0, 1.0, 1.0, 1.0]
# Color indicator dot radius
dot_radius = 6.0
# ───────────────────────────────────────────────────────────────────────────────
# Help Overlay Styling (Press F10 to toggle)
# ───────────────────────────────────────────────────────────────────────────────
[ui.help_overlay_style]
# Font size for help overlay text
font_size = 16.0
# Line height for help text
line_height = 22.0
# Padding around help box
padding = 20.0
# Background color [R, G, B, A] (0.0-1.0 range)
# Default: semi-transparent black (darker than status bar)
bg_color = [0.0, 0.0, 0.0, 0.85]
# Border color [R, G, B, A] (0.0-1.0 range)
# Default: light blue
border_color = [0.3, 0.6, 1.0, 0.9]
# Border line width
border_width = 2.0
# Text color [R, G, B, A] (0.0-1.0 range)
# Default: white
text_color = [1.0, 1.0, 1.0, 1.0]
+175
View File
@@ -0,0 +1,175 @@
# AUR Update Workflow
## Quick Update Process
When you're ready to release a new version:
### 1. Update version in your project
```bash
cd ~/code/hyprmarker
# Edit Cargo.toml - update version
vim Cargo.toml
# Commit changes
git add Cargo.toml
git commit -m "Bump version to 0.2.0"
git push origin master
```
### 2. Run the automated AUR update script
```bash
cd ~/code/hyprmarker
./tools/update-aur.sh
```
**What it does:**
1. ✅ Reads version from `Cargo.toml`
2. ✅ Creates git tag (e.g., `v0.2.0`) and pushes to GitHub
3. ✅ Copies `packaging/PKGBUILD.aur` to `~/aur-packages/PKGBUILD`
4. ✅ Updates `pkgver` and resets `pkgrel` to 1
5. ✅ Generates `.SRCINFO`
6. ✅ Optionally tests build locally
7. ✅ Commits and pushes to AUR
**Done!** Users can now update with `yay -Syu hyprmarker`
---
## Manual Update Process
If you prefer to do it manually:
```bash
# 1. Update version in Cargo.toml
cd ~/code/hyprmarker
vim Cargo.toml # Change version to 0.2.0
# 2. Commit and tag
git add Cargo.toml
git commit -m "Bump version to 0.2.0"
git tag -a v0.2.0 -m "Release v0.2.0"
git push origin master --tags
# 3. Update AUR PKGBUILD
cd ~/aur-packages
cp ~/code/hyprmarker/packaging/PKGBUILD.aur ./PKGBUILD
vim PKGBUILD # Update pkgver=0.2.0, pkgrel=1
# 4. Regenerate .SRCINFO
makepkg --printsrcinfo > .SRCINFO
# 5. Test build (optional but recommended)
makepkg -si
# 6. Commit and push to AUR
git add PKGBUILD .SRCINFO
git commit -m "Update to v0.2.0"
git push origin master
```
---
## Version Numbering
### Major releases (breaking changes)
```
0.1.0 → 1.0.0
pkgver=1.0.0
pkgrel=1
```
### Minor releases (new features)
```
0.1.0 → 0.2.0
pkgver=0.2.0
pkgrel=1
```
### Patch releases (bug fixes)
```
0.1.0 → 0.1.1
pkgver=0.1.1
pkgrel=1
```
### PKGBUILD-only updates (no code changes)
```
Same version: 0.1.0
pkgver=0.1.0
pkgrel=2 ← Increment this
```
---
## Troubleshooting
### "Tag already exists"
```bash
# Delete local tag
git tag -d v0.1.0
# Delete remote tag
git push --delete origin v0.1.0
# Recreate
git tag -a v0.1.0 -m "Release v0.1.0"
git push origin v0.1.0
```
### "Build fails on AUR"
```bash
# Test locally first
cd ~/aur-packages
makepkg -si
# Common issues:
# - Missing dependencies in PKGBUILD
# - GitHub tag doesn't exist
# - Wrong source URL
```
### "Permission denied" when pushing to AUR
```bash
# Check SSH key is configured
cat ~/.ssh/config | grep -A 3 "aur.archlinux.org"
# Should show:
# Host aur.archlinux.org
# IdentityFile ~/.ssh/aur_ed25519
# User aur
```
---
## File Locations
| File | Purpose |
|------|---------|
| `~/code/hyprmarker/Cargo.toml` | Source of truth for version |
| `~/code/hyprmarker/packaging/PKGBUILD.aur` | Template for AUR PKGBUILD |
| `~/aur-packages/PKGBUILD` | Actual AUR package file |
| `~/aur-packages/.SRCINFO` | Generated metadata (auto-updated) |
---
## Quick Commands Reference
```bash
# Update AUR (automated)
./tools/update-aur.sh
# Check current version
grep '^version = ' Cargo.toml
# List git tags
git tag
# Check AUR package status
cd ~/aur-packages && git log -1
# Rebuild local package
cd ~/aur-packages && makepkg -si
```
+280
View File
@@ -0,0 +1,280 @@
# Configuration Guide
## Overview
hyprmarker supports customization through a TOML configuration file located at:
```
~/.config/hyprmarker/config.toml
```
All settings are optional. If the configuration file doesn't exist or settings are missing, sensible defaults will be used.
## Configuration File Location
The configuration file should be placed at:
- Linux: `~/.config/hyprmarker/config.toml`
- The directory will be created automatically when you first create the config file
## Example Configuration
See `config.example.toml` in the repository root for a complete example with documentation.
## Configuration Sections
### `[drawing]` - Drawing Defaults
Controls the default appearance of annotations.
```toml
[drawing]
# Default pen color
# Options: "red", "green", "blue", "yellow", "orange", "pink", "white", "black"
# Or RGB array: [255, 0, 0]
default_color = "red"
# Default pen thickness in pixels (1.0 - 20.0)
default_thickness = 3.0
# Default font size for text mode (8.0 - 72.0)
default_font_size = 32.0
```
**Color Options:**
- **Named colors**: `"red"`, `"green"`, `"blue"`, `"yellow"`, `"orange"`, `"pink"`, `"white"`, `"black"`
- **RGB arrays**: `[255, 0, 0]` for red, `[0, 255, 0]` for green, etc.
**Defaults:**
- Color: Red
- Thickness: 3.0px
- Font size: 32.0px
### `[arrow]` - Arrow Geometry
Controls the appearance of arrow annotations.
```toml
[arrow]
# Arrowhead length in pixels
length = 20.0
# Arrowhead angle in degrees (15-60)
# 30 degrees gives a nice balanced arrow
angle_degrees = 30.0
```
**Defaults:**
- Length: 20.0px
- Angle: 30.0°
### `[performance]` - Performance Tuning
Controls rendering performance and smoothness.
```toml
[performance]
# Number of buffers for rendering (2, 3, or 4)
# 2 = double buffering (low memory)
# 3 = triple buffering (recommended, smooth)
# 4 = quad buffering (ultra-smooth on high refresh displays)
buffer_count = 3
# Enable vsync frame synchronization
# Prevents tearing and limits rendering to display refresh rate
enable_vsync = true
```
**Buffer Count:**
- **2**: Double buffering - minimal memory usage, may flicker on fast drawing
- **3**: Triple buffering - recommended default, smooth drawing
- **4**: Quad buffering - for high-refresh displays (144Hz+), ultra-smooth
**VSync:**
- **true** (default): Synchronizes with display refresh rate, no tearing
- **false**: Uncapped rendering, may cause tearing but lower latency
**Defaults:**
- Buffer count: 3 (triple buffering)
- VSync: true
### `[ui]` - User Interface
Controls visual indicators, overlays, and UI styling.
```toml
[ui]
# Show status bar with current color/thickness/tool
show_status_bar = true
# Status bar position
# Options: "top-left", "top-right", "bottom-left", "bottom-right"
status_bar_position = "bottom-left"
# Status bar styling
[ui.status_bar_style]
font_size = 14.0
padding = 10.0
bg_color = [0.0, 0.0, 0.0, 0.7] # Semi-transparent black [R, G, B, A]
text_color = [1.0, 1.0, 1.0, 1.0] # White
dot_radius = 4.0
# Help overlay styling
[ui.help_overlay_style]
font_size = 16.0
line_height = 22.0
padding = 20.0
bg_color = [0.0, 0.0, 0.0, 0.85] # Darker background
border_color = [0.3, 0.6, 1.0, 0.9] # Light blue
border_width = 2.0
text_color = [1.0, 1.0, 1.0, 1.0] # White
```
**Status Bar:**
- Shows current color, pen thickness, and active tool
- Press `F10` to toggle help overlay
- Fully customizable styling (fonts, colors, sizes)
**Position Options:**
- `"top-left"`: Upper left corner
- `"top-right"`: Upper right corner
- `"bottom-left"`: Lower left corner (default)
- `"bottom-right"`: Lower right corner
**UI Styling:**
- **Font sizes**: Customize text size for status bar and help overlay
- **Colors**: All RGBA values (0.0-1.0 range) with transparency control
- **Layout**: Padding, line height, dot size, border width all configurable
**Defaults:**
- Show status bar: true
- Position: bottom-left
- Status bar font: 14px
- Help overlay font: 16px
- Semi-transparent dark backgrounds
- Light blue help overlay border
## Creating Your Configuration
1. Create the directory:
```bash
mkdir -p ~/.config/hyprmarker
```
2. Copy the example config:
```bash
cp config.example.toml ~/.config/hyprmarker/config.toml
```
3. Edit to your preferences:
```bash
nano ~/.config/hyprmarker/config.toml
```
## Configuration Priority
Settings are loaded in this order:
1. Built-in defaults (hardcoded)
2. Configuration file values (override defaults)
3. Runtime changes via keybindings (temporary, not saved)
**Note:** Changes to the config file require restarting hyprmarker daemon to take effect.
To reload config changes:
```bash
# Use the reload script
./reload-daemon.sh
# Or manually
pkill hyprmarker
hyprmarker --daemon &
```
## Troubleshooting
### Config File Not Loading
If your config file isn't being read:
1. Check the file path:
```bash
ls -la ~/.config/hyprmarker/config.toml
```
2. Verify TOML syntax:
```bash
# Install a TOML validator if needed
toml-validator ~/.config/hyprmarker/config.toml
```
3. Check logs for errors:
```bash
RUST_LOG=info hyprmarker --active
```
### Invalid Values
If you specify invalid values:
- **Out of range**: Values will be clamped to valid ranges
- **Invalid color name**: Falls back to default (red)
- **Malformed RGB**: Falls back to default color
- **Parse errors**: Entire config file ignored, defaults used
Check the application logs for warnings about config issues.
## Advanced Usage
### Per-Project Configs
While hyprmarker uses a single global config, you can:
1. Create different config files
2. Symlink the active one to `~/.config/hyprmarker/config.toml`
Example:
```bash
# Create project-specific configs
cp config.example.toml ~/configs/hyprmarker-presentation.toml
cp config.example.toml ~/configs/hyprmarker-recording.toml
# Switch configs
ln -sf ~/configs/hyprmarker-presentation.toml ~/.config/hyprmarker/config.toml
```
### Configuration Examples
**High-contrast presentation mode:**
```toml
[drawing]
default_color = "yellow"
default_thickness = 5.0
default_font_size = 48.0
[ui]
status_bar_position = "top-right"
```
**Screen recording mode (subtle annotations):**
```toml
[drawing]
default_color = "blue"
default_thickness = 2.0
default_font_size = 24.0
[performance]
buffer_count = 4
enable_vsync = true
[ui]
show_status_bar = false
```
**High-refresh display optimization:**
```toml
[performance]
buffer_count = 4
enable_vsync = true
```
## See Also
- `SETUP.md` - Installation and system requirements
- `config.example.toml` - Annotated example configuration
- `README.md` - Main documentation with usage guide
+144
View File
@@ -0,0 +1,144 @@
# Complete Setup Guide
## Installation
### Quick Install
Run the install script:
```bash
./tools/install.sh
```
This will:
1. Build the release binary
2. Copy it to `~/.local/bin/hyprmarker`
3. Tell you how to add Hyprland keybind
### Manual Install
If you prefer manual installation:
```bash
# Build
cargo build --release
# Copy to user bin
mkdir -p ~/.local/bin
cp target/release/hyprmarker ~/.local/bin/
chmod +x ~/.local/bin/hyprmarker
# Make sure ~/.local/bin is in your PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
```
## Hyprland Keybind Setup
### Method 1: Daemon Mode with Toggle (Recommended)
Add to `~/.config/hypr/hyprland.conf`:
```conf
# hyprmarker - Screen annotation daemon (Super+D to toggle)
exec-once = hyprmarker --daemon
bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker
```
Then reload:
```bash
hyprctl reload
```
Now press **Super+D** to toggle the overlay on/off!
### Method 2: One-Shot Mode (Alternative)
For quick one-time annotations without daemon:
```bash
# Run directly (not recommended - daemon mode is better)
hyprmarker --active
```
This starts a fresh overlay each time. Exit with Escape.
**Note:** We recommend using daemon mode with Super+D instead as it preserves your drawings.
## Usage Flow
### Daemon Mode Workflow (Recommended)
1. **Daemon starts automatically** → Runs in background with system tray icon
2. **Press Super+D** → Drawing overlay appears
3. **Draw your annotations** → All tools available
4. **Press Escape or Ctrl+Q** → Overlay hides (daemon keeps running)
5. **Press Super+D again** → Overlay reappears with previous drawings intact
### One-Shot Mode Workflow (Alternative)
1. **Run command** → Fresh drawing overlay appears
2. **Draw your annotations** → All tools available
3. **Press Escape** → Drawing overlay closes completely
4. **Run command again** → New fresh overlay (previous drawings lost)
**Note:** Daemon mode with Super+D is recommended as it preserves your drawings when you toggle the overlay.
## Verification
Test the setup:
```bash
# Test binary is accessible
which hyprmarker
# Test daemon mode
hyprmarker --daemon &
# Test keybind
Press Super+D (should show overlay)
Press Escape (should hide overlay)
```
## Autostart
Daemon mode is already included in Method 1! The `exec-once` line will start hyprmarker automatically on login.
## Troubleshooting
**Keybind not working?**
- Check `hyprctl reload` was run
- Check for conflicts: `hyprctl binds | grep "SUPER, D"`
- Try a different key combo
**Binary not found?**
- Check PATH: `echo $PATH | grep .local/bin`
- Add to PATH if missing (see Manual Install)
- Restart terminal after PATH change
**Want different key?**
- Edit hyprland.conf
- Examples:
- `SUPER, D` → Super+D
- `ALT, D` → Alt+D
- `CTRL SHIFT, 2` → Ctrl+Shift+2
## Uninstall
```bash
rm ~/.local/bin/hyprmarker
# Remove keybind from hyprland.conf
```
## Recommended Setup
**Best setup (daemon mode):**
1. Install: `./tools/install.sh`
2. Add to hyprland.conf:
```conf
exec-once = hyprmarker --daemon
bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker
```
3. Reload: `hyprctl reload`
4. Use: Press Super+D to toggle overlay
Done! Drawings persist, tray icon available. ✨
+15
View File
@@ -0,0 +1,15 @@
use anyhow::Result;
pub mod wayland;
// Removed: Backend trait - no longer needed with single backend
// Removed: BackendChoice enum - Wayland is the only backend
/// Run Wayland backend with full event loop
pub fn run_wayland() -> Result<()> {
let mut backend = wayland::WaylandBackend::new()?;
backend.init()?;
backend.show()?; // show() calls run() internally
backend.hide()?;
Ok(())
}
+938
View File
@@ -0,0 +1,938 @@
// Wayland backend using wlr-layer-shell for overlay
use anyhow::{Context, Result};
use log::{debug, info, warn};
use smithay_client_toolkit::{
compositor::{CompositorHandler, CompositorState},
delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_pointer,
delegate_registry, delegate_seat, delegate_shm,
output::{OutputHandler, OutputState},
registry::{ProvidesRegistryState, RegistryState},
registry_handlers,
seat::{
Capability, SeatHandler, SeatState,
keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers},
pointer::{PointerEvent, PointerEventKind, PointerHandler},
},
shell::{
WaylandSurface,
wlr_layer::{
Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface,
LayerSurfaceConfigure,
},
},
shm::{Shm, ShmHandler, slot::SlotPool},
};
use wayland_client::{
Connection, Dispatch, QueueHandle,
globals::registry_queue_init,
protocol::{wl_buffer, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm, wl_surface},
};
// Removed: Arc, Mutex - not needed after removing WaylandBackend.inner
use crate::config::Config;
use crate::input::{InputState, Key, MouseButton};
/// Wayland backend state
pub struct WaylandBackend {
// Removed: inner Arc<Mutex> was unused - WaylandState is created and used directly in run()
}
/// Internal Wayland state
struct WaylandState {
// Wayland protocol objects
registry_state: RegistryState,
compositor_state: CompositorState,
layer_shell: LayerShell,
shm: Shm,
output_state: OutputState,
seat_state: SeatState,
// Surface and buffer
layer_surface: Option<LayerSurface>,
pool: Option<SlotPool>,
width: u32,
height: u32,
configured: bool,
// Frame synchronization
frame_callback_pending: bool,
// Configuration
config: Config,
// Input state
input_state: InputState,
current_mouse_x: i32,
current_mouse_y: i32,
}
impl WaylandBackend {
pub fn new() -> Result<Self> {
Ok(Self {})
}
pub fn run(&mut self) -> Result<()> {
info!("Starting Wayland backend");
// Connect to Wayland compositor
let conn =
Connection::connect_to_env().context("Failed to connect to Wayland compositor")?;
debug!("Connected to Wayland display");
// Initialize registry and event queue
let (globals, mut event_queue) =
registry_queue_init(&conn).context("Failed to initialize Wayland registry")?;
let qh = event_queue.handle();
// Bind global interfaces
let compositor_state =
CompositorState::bind(&globals, &qh).context("wl_compositor not available")?;
debug!("Bound compositor");
let layer_shell =
LayerShell::bind(&globals, &qh).context("zwlr_layer_shell_v1 not available")?;
debug!("Bound layer shell");
let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;
debug!("Bound shared memory");
let output_state = OutputState::new(&globals, &qh);
debug!("Initialized output state");
let seat_state = SeatState::new(&globals, &qh);
debug!("Initialized seat state");
let registry_state = RegistryState::new(&globals);
// Load configuration
let config = Config::load().unwrap_or_else(|e| {
warn!("Failed to load config: {}. Using defaults.", e);
Config::default()
});
info!("Configuration loaded");
debug!(" Color: {:?}", config.drawing.default_color);
debug!(" Thickness: {:.1}px", config.drawing.default_thickness);
debug!(" Font size: {:.1}px", config.drawing.default_font_size);
debug!(" Buffer count: {}", config.performance.buffer_count);
debug!(" VSync: {}", config.performance.enable_vsync);
debug!(
" Status bar: {} @ {:?}",
config.ui.show_status_bar, config.ui.status_bar_position
);
debug!(
" Status bar font size: {}",
config.ui.status_bar_style.font_size
);
debug!(
" Help overlay font size: {}",
config.ui.help_overlay_style.font_size
);
// Create font descriptor from config
let font_descriptor = crate::draw::FontDescriptor::new(
config.drawing.font_family.clone(),
config.drawing.font_weight.clone(),
config.drawing.font_style.clone(),
);
// Initialize input state with config defaults
let input_state = InputState::with_defaults(
config.drawing.default_color.to_color(),
config.drawing.default_thickness,
config.drawing.default_font_size,
font_descriptor,
config.drawing.text_background_enabled,
config.arrow.length,
config.arrow.angle_degrees,
);
// Create application state
let mut state = WaylandState {
registry_state,
compositor_state,
layer_shell,
shm,
output_state,
seat_state,
layer_surface: None,
pool: None,
width: 0,
height: 0,
configured: false,
frame_callback_pending: false,
config,
input_state,
current_mouse_x: 0,
current_mouse_y: 0,
};
// Create layer shell surface
info!("Creating layer shell surface");
let wl_surface = state.compositor_state.create_surface(&qh);
let layer_surface = state.layer_shell.create_layer_surface(
&qh,
wl_surface,
Layer::Overlay,
Some("hyprmarker"),
None, // Default output
);
// Configure the layer surface for fullscreen overlay
layer_surface.set_anchor(Anchor::all());
// NOTE: Using Exclusive keyboard interactivity for complete input capture
// If clipboard operations are interrupted during overlay toggle, consider switching
// to KeyboardInteractivity::OnDemand which cooperates better with other applications
layer_surface.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
layer_surface.set_size(0, 0); // Use full screen size
layer_surface.set_exclusive_zone(-1);
// Commit the surface
layer_surface.commit();
state.layer_surface = Some(layer_surface);
info!("Layer shell surface created");
// Track consecutive render failures for error recovery
let mut consecutive_render_failures = 0u32;
const MAX_RENDER_FAILURES: u32 = 10;
// Main event loop
let mut loop_error: Option<anyhow::Error> = None;
loop {
// Check if we should exit before blocking
if state.input_state.should_exit {
info!("Exit requested, breaking event loop");
break;
}
// Dispatch all pending events (blocking) but check should_exit after each batch
match event_queue.blocking_dispatch(&mut state) {
Ok(_) => {
// Check immediately after dispatch returns
if state.input_state.should_exit {
info!("Exit requested after dispatch, breaking event loop");
break;
}
}
Err(e) => {
warn!("Event queue error: {}", e);
loop_error = Some(anyhow::anyhow!("Wayland event queue error: {}", e));
break;
}
}
// Render if configured and needs redraw, but only if no frame callback pending
// This throttles rendering to display refresh rate (when vsync is enabled)
let can_render = state.configured
&& state.input_state.needs_redraw
&& (!state.frame_callback_pending || !state.config.performance.enable_vsync);
if can_render {
debug!(
"Main loop: needs_redraw=true, frame_callback_pending={}, triggering render",
state.frame_callback_pending
);
match state.render(&qh) {
Ok(()) => {
// Reset failure counter on successful render
consecutive_render_failures = 0;
state.input_state.needs_redraw = false;
// Only set frame_callback_pending if vsync is enabled
if state.config.performance.enable_vsync {
state.frame_callback_pending = true;
debug!(
"Main loop: needs_redraw set to false, frame_callback_pending set to true (vsync enabled)"
);
} else {
debug!(
"Main loop: needs_redraw set to false, frame_callback_pending unchanged (vsync disabled)"
);
}
}
Err(e) => {
consecutive_render_failures += 1;
warn!(
"Rendering error (attempt {}/{}): {}",
consecutive_render_failures, MAX_RENDER_FAILURES, e
);
if consecutive_render_failures >= MAX_RENDER_FAILURES {
return Err(anyhow::anyhow!(
"Too many consecutive render failures ({}), exiting: {}",
consecutive_render_failures,
e
));
}
// Clear redraw flag to avoid infinite error loop
state.input_state.needs_redraw = false;
}
}
} else if state.input_state.needs_redraw && state.frame_callback_pending {
debug!("Main loop: Skipping render - frame callback already pending");
}
}
info!("Wayland backend exiting");
// Return error if loop exited due to error, otherwise success
match loop_error {
Some(e) => Err(e),
None => Ok(()),
}
}
}
impl WaylandState {
fn render(&mut self, _qh: &QueueHandle<Self>) -> Result<()> {
debug!("=== RENDER START ===");
let layer_surface = self
.layer_surface
.as_ref()
.context("Layer surface not created")?;
let wl_surface = layer_surface.wl_surface();
// Create pool if needed
if self.pool.is_none() {
// Create pool with configured number of buffers to prevent reuse during fast drawing
// This prevents flickering when drawing quickly
let buffer_size = (self.width * self.height * 4) as usize;
let buffer_count = self.config.performance.buffer_count as usize;
let pool_size = buffer_size * buffer_count;
info!(
"Creating new SlotPool ({}x{}, {} bytes, {} buffers)",
self.width, self.height, pool_size, buffer_count
);
let pool = SlotPool::new(pool_size, &self.shm).context("Failed to create slot pool")?;
self.pool = Some(pool);
}
let pool = self
.pool
.as_mut()
.context("Buffer pool not initialized despite check at line 215")?;
// Get a buffer from the pool
debug!("Requesting buffer from pool");
let (buffer, canvas) = pool
.create_buffer(
self.width as i32,
self.height as i32,
(self.width * 4) as i32,
wl_shm::Format::Argb8888,
)
.context("Failed to create buffer")?;
debug!("Buffer acquired from pool");
// Create Cairo surface from the buffer
// SAFETY: This unsafe block creates a Cairo surface from raw memory buffer.
// Safety invariants that must be maintained:
// 1. `canvas` is a valid mutable slice from SlotPool with exactly (width * height * 4) bytes
// 2. The buffer format ARgb32 matches the allocation (4 bytes per pixel: alpha, red, green, blue)
// 3. The stride (width * 4) correctly represents the number of bytes per row
// 4. `cairo_surface` and `ctx` are explicitly dropped (lines 315-316) before the buffer
// is committed to Wayland, ensuring Cairo doesn't access memory after ownership transfers
// 5. No other references to this memory exist during Cairo's usage
// 6. The buffer remains valid throughout Cairo's usage (enforced by Rust's borrow checker
// since `canvas` is borrowed until buffer.damage_buffer() call)
let cairo_surface = unsafe {
cairo::ImageSurface::create_for_data_unsafe(
canvas.as_mut_ptr(),
cairo::Format::ARgb32,
self.width as i32,
self.height as i32,
(self.width * 4) as i32,
)
.context("Failed to create Cairo surface")?
};
// Render using Cairo
let ctx = cairo::Context::new(&cairo_surface).context("Failed to create Cairo context")?;
// Clear with fully transparent background
debug!("Clearing background");
ctx.set_operator(cairo::Operator::Clear);
ctx.paint().context("Failed to clear background")?;
ctx.set_operator(cairo::Operator::Over);
// Render all completed shapes
debug!(
"Rendering {} completed shapes",
self.input_state.frame.shapes.len()
);
crate::draw::render_shapes(&ctx, &self.input_state.frame.shapes);
// Render provisional shape if actively drawing
// Use optimized method that avoids cloning for freehand
if self.input_state.render_provisional_shape(
&ctx,
self.current_mouse_x,
self.current_mouse_y,
) {
debug!("Rendered provisional shape");
}
// Render text cursor/buffer if in text mode
if let crate::input::DrawingState::TextInput { x, y, buffer } = &self.input_state.state {
let preview_text = if buffer.is_empty() {
"_".to_string() // Show cursor when buffer is empty
} else {
// Show buffer with cursor at end (handles newlines naturally)
format!("{}_", buffer)
};
crate::draw::render_text(
&ctx,
*x,
*y,
&preview_text,
self.input_state.current_color,
self.input_state.current_font_size,
&self.input_state.font_descriptor,
self.input_state.text_background_enabled,
);
}
// Render status bar if enabled
if self.config.ui.show_status_bar {
crate::ui::render_status_bar(
&ctx,
&self.input_state,
self.config.ui.status_bar_position,
&self.config.ui.status_bar_style,
self.width,
self.height,
);
}
// Render help overlay if toggled
if self.input_state.show_help {
crate::ui::render_help_overlay(
&ctx,
&self.config.ui.help_overlay_style,
self.width,
self.height,
);
}
// Flush Cairo
debug!("Flushing Cairo surface");
cairo_surface.flush();
drop(ctx);
drop(cairo_surface);
// Attach buffer and commit
debug!("Attaching buffer and committing surface");
wl_surface.attach(Some(buffer.wl_buffer()), 0, 0);
wl_surface.damage_buffer(0, 0, self.width as i32, self.height as i32);
// Only request frame callback if vsync is enabled
// This throttles rendering to display refresh rate
if self.config.performance.enable_vsync {
debug!("Requesting frame callback (vsync enabled)");
wl_surface.frame(_qh, wl_surface.clone());
} else {
debug!("Skipping frame callback (vsync disabled - allows back-to-back renders)");
}
wl_surface.commit();
debug!("=== RENDER COMPLETE ===");
Ok(())
}
}
impl WaylandBackend {
pub fn init(&mut self) -> Result<()> {
info!("Initializing Wayland backend");
Ok(())
}
pub fn show(&mut self) -> Result<()> {
info!("Showing Wayland overlay");
self.run()
}
pub fn hide(&mut self) -> Result<()> {
info!("Hiding Wayland overlay");
Ok(())
}
}
// Implement required trait delegates
delegate_compositor!(WaylandState);
delegate_output!(WaylandState);
delegate_shm!(WaylandState);
delegate_layer!(WaylandState);
delegate_seat!(WaylandState);
delegate_keyboard!(WaylandState);
delegate_pointer!(WaylandState);
delegate_registry!(WaylandState);
// Implement CompositorHandler
impl CompositorHandler for WaylandState {
fn scale_factor_changed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_factor: i32,
) {
debug!("Scale factor changed");
}
fn transform_changed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_transform: wl_output::Transform,
) {
debug!("Transform changed");
}
fn frame(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_time: u32,
) {
// Frame callback - compositor is ready for next frame
debug!(
"Frame callback received (time: {}ms), clearing frame_callback_pending",
_time
);
self.frame_callback_pending = false;
// If we're actively drawing, request another render
// (input events may have set needs_redraw while we were waiting)
if self.input_state.needs_redraw {
debug!(
"Frame callback: needs_redraw is still true, will render on next loop iteration"
);
}
}
fn surface_enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_output: &wl_output::WlOutput,
) {
debug!("Surface entered output");
}
fn surface_leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_output: &wl_output::WlOutput,
) {
debug!("Surface left output");
}
}
// Implement OutputHandler
impl OutputHandler for WaylandState {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
debug!("New output detected");
}
fn update_output(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
debug!("Output updated");
}
fn output_destroyed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
debug!("Output destroyed");
}
}
// Implement ShmHandler
impl ShmHandler for WaylandState {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm
}
}
// Implement LayerShellHandler
impl LayerShellHandler for WaylandState {
fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {
info!("Layer surface closed by compositor");
self.input_state.should_exit = true;
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_layer: &LayerSurface,
configure: LayerSurfaceConfigure,
_serial: u32,
) {
info!(
"Layer surface configured: {}x{}",
configure.new_size.0, configure.new_size.1
);
// Update dimensions
if configure.new_size.0 > 0 && configure.new_size.1 > 0 {
let size_changed =
self.width != configure.new_size.0 || self.height != configure.new_size.1;
self.width = configure.new_size.0;
self.height = configure.new_size.1;
// Recreate pool if dimensions changed
if size_changed && self.pool.is_some() {
info!("Surface size changed - recreating SlotPool");
self.pool = None;
}
// Update input state with actual screen dimensions
self.input_state
.update_screen_dimensions(self.width, self.height);
}
// Mark as configured and request first draw
self.configured = true;
self.input_state.needs_redraw = true;
}
}
// Implement SeatHandler
impl SeatHandler for WaylandState {
fn seat_state(&mut self) -> &mut SeatState {
&mut self.seat_state
}
fn new_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: wl_seat::WlSeat) {
debug!("New seat available");
}
fn new_capability(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
if capability == Capability::Keyboard {
info!("Keyboard capability available");
if self.seat_state.get_keyboard(qh, &seat, None).is_ok() {
debug!("Keyboard initialized");
}
}
if capability == Capability::Pointer {
info!("Pointer capability available");
if self.seat_state.get_pointer(qh, &seat).is_ok() {
debug!("Pointer initialized");
}
}
}
fn remove_capability(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_seat: wl_seat::WlSeat,
capability: Capability,
) {
if capability == Capability::Keyboard {
info!("Keyboard capability removed");
}
if capability == Capability::Pointer {
info!("Pointer capability removed");
}
}
fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: wl_seat::WlSeat) {
debug!("Seat removed");
}
}
// Implement KeyboardHandler
impl KeyboardHandler for WaylandState {
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_surface: &wl_surface::WlSurface,
_serial: u32,
_raw: &[u32],
_keysyms: &[Keysym],
) {
debug!("Keyboard focus entered");
}
fn leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_surface: &wl_surface::WlSurface,
_serial: u32,
) {
debug!("Keyboard focus left");
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
event: KeyEvent,
) {
let key = keysym_to_key(event.keysym);
debug!("Key pressed: {:?}", key);
self.input_state.on_key_press(key);
self.input_state.needs_redraw = true;
}
fn release_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
event: KeyEvent,
) {
let key = keysym_to_key(event.keysym);
debug!("Key released: {:?}", key);
self.input_state.on_key_release(key);
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
modifiers: Modifiers,
_layout: RawModifiers,
_group: u32,
) {
debug!(
"Modifiers: ctrl={} alt={} shift={}",
modifiers.ctrl, modifiers.alt, modifiers.shift
);
}
fn repeat_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
event: KeyEvent,
) {
// Handle key repeat - treat like a regular key press
let key = keysym_to_key(event.keysym);
debug!("Key repeated: {:?}", key);
self.input_state.on_key_press(key);
self.input_state.needs_redraw = true;
}
}
// Implement PointerHandler
impl PointerHandler for WaylandState {
fn pointer_frame(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &wl_pointer::WlPointer,
events: &[PointerEvent],
) {
use smithay_client_toolkit::seat::pointer::{BTN_LEFT, BTN_MIDDLE, BTN_RIGHT};
for event in events {
match event.kind {
PointerEventKind::Enter { .. } => {
debug!(
"Pointer entered at ({}, {})",
event.position.0, event.position.1
);
self.current_mouse_x = event.position.0 as i32;
self.current_mouse_y = event.position.1 as i32;
}
PointerEventKind::Leave { .. } => {
debug!("Pointer left surface");
}
PointerEventKind::Motion { .. } => {
self.current_mouse_x = event.position.0 as i32;
self.current_mouse_y = event.position.1 as i32;
self.input_state
.on_mouse_motion(self.current_mouse_x, self.current_mouse_y);
// Note: needs_redraw is set inside on_mouse_motion if actively drawing
// Don't set it here unconditionally to avoid rendering on every mouse move
}
PointerEventKind::Press { button, .. } => {
debug!(
"Button {} pressed at ({}, {})",
button, event.position.0, event.position.1
);
let mb = match button {
BTN_LEFT => MouseButton::Left,
BTN_MIDDLE => MouseButton::Middle,
BTN_RIGHT => MouseButton::Right,
_ => continue,
};
self.input_state.on_mouse_press(
mb,
event.position.0 as i32,
event.position.1 as i32,
);
self.input_state.needs_redraw = true;
}
PointerEventKind::Release { button, .. } => {
debug!("Button {} released", button);
let mb = match button {
BTN_LEFT => MouseButton::Left,
BTN_MIDDLE => MouseButton::Middle,
BTN_RIGHT => MouseButton::Right,
_ => continue,
};
self.input_state.on_mouse_release(
mb,
event.position.0 as i32,
event.position.1 as i32,
);
self.input_state.needs_redraw = true;
}
PointerEventKind::Axis { vertical, .. } => {
// Use scroll wheel for pen width adjustment
// Use discrete steps if available, otherwise fall back to absolute with threshold
let scroll_direction = if vertical.discrete != 0 {
vertical.discrete
} else if vertical.absolute.abs() > 0.1 {
// Threshold to ignore tiny movements
if vertical.absolute > 0.0 { 1 } else { -1 }
} else {
0
};
if scroll_direction > 0 {
// Scroll up = decrease thickness
self.input_state.current_thickness =
(self.input_state.current_thickness - 1.0).max(1.0);
debug!(
"Thickness decreased: {:.0}px",
self.input_state.current_thickness
);
self.input_state.needs_redraw = true;
} else if scroll_direction < 0 {
// Scroll down = increase thickness
self.input_state.current_thickness =
(self.input_state.current_thickness + 1.0).min(20.0);
debug!(
"Thickness increased: {:.0}px",
self.input_state.current_thickness
);
self.input_state.needs_redraw = true;
}
}
}
}
}
}
// Implement ProvidesRegistryState
impl ProvidesRegistryState for WaylandState {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
registry_handlers![OutputState, SeatState];
}
// Implement Dispatch for wl_buffer (required for buffer lifecycle)
impl Dispatch<wl_buffer::WlBuffer, ()> for WaylandState {
fn event(
_state: &mut Self,
_proxy: &wl_buffer::WlBuffer,
event: wl_buffer::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
if let wl_buffer::Event::Release = event {
debug!("Buffer released by compositor");
}
}
}
// Convert Wayland keysym to our Key enum
fn keysym_to_key(keysym: Keysym) -> Key {
match keysym {
Keysym::Escape => Key::Escape,
Keysym::Return => Key::Return,
Keysym::BackSpace => Key::Backspace,
Keysym::Tab => Key::Tab,
Keysym::space => Key::Space,
Keysym::Shift_L | Keysym::Shift_R => Key::Shift,
Keysym::Control_L | Keysym::Control_R => Key::Ctrl,
Keysym::Alt_L | Keysym::Alt_R => Key::Alt,
Keysym::plus | Keysym::equal => Key::Plus,
Keysym::minus | Keysym::underscore => Key::Minus,
Keysym::t => Key::Char('t'),
Keysym::T => Key::Char('T'),
Keysym::e => Key::Char('e'),
Keysym::E => Key::Char('E'),
Keysym::r => Key::Char('r'),
Keysym::R => Key::Char('R'),
Keysym::g => Key::Char('g'),
Keysym::G => Key::Char('G'),
Keysym::b => Key::Char('b'),
Keysym::B => Key::Char('B'),
Keysym::y => Key::Char('y'),
Keysym::Y => Key::Char('Y'),
Keysym::o => Key::Char('o'),
Keysym::O => Key::Char('O'),
Keysym::p => Key::Char('p'),
Keysym::P => Key::Char('P'),
Keysym::w => Key::Char('w'),
Keysym::W => Key::Char('W'),
Keysym::k => Key::Char('k'),
Keysym::K => Key::Char('K'),
Keysym::z => Key::Char('z'),
Keysym::Z => Key::Char('Z'),
Keysym::F10 => Key::F10,
_ => {
// For other printable characters, try to map them
// Use the raw value to determine if it's ASCII printable
let raw = keysym.raw();
if (0x20..=0x7E).contains(&raw) {
Key::Char(raw as u8 as char)
} else {
Key::Unknown
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Configuration enum types.
use crate::draw::{Color, color::*};
use log::warn;
use serde::{Deserialize, Serialize};
/// Status bar position on screen.
///
/// Controls where the status bar appears relative to screen edges.
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum StatusPosition {
/// Top-left corner
TopLeft,
/// Top-right corner
TopRight,
/// Bottom-left corner
BottomLeft,
/// Bottom-right corner
BottomRight,
}
/// Color specification - either a named color or RGB values.
///
/// # Examples
/// ```toml
/// # Named color
/// default_color = "red"
///
/// # Custom RGB color (0-255 per component)
/// default_color = [255, 128, 0] # Orange
/// ```
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum ColorSpec {
/// Named color: red, green, blue, yellow, orange, pink, white, black
Name(String),
/// RGB color as [red, green, blue] where each component is 0-255
Rgb([u8; 3]),
}
impl ColorSpec {
/// Converts the color specification to a [`Color`] struct.
///
/// Named colors are mapped to predefined RGBA values using `util::name_to_color()`.
/// Unknown color names default to red with a warning. RGB arrays are converted from
/// 0-255 range to 0.0-1.0 range with full opacity.
pub fn to_color(&self) -> Color {
match self {
ColorSpec::Name(name) => crate::util::name_to_color(name).unwrap_or_else(|| {
warn!("Unknown color '{}', using red", name);
RED
}),
ColorSpec::Rgb([r, g, b]) => Color {
r: *r as f64 / 255.0,
g: *g as f64 / 255.0,
b: *b as f64 / 255.0,
a: 1.0,
},
}
}
}
+271
View File
@@ -0,0 +1,271 @@
//! Configuration file support for hyprmarker.
//!
//! This module handles loading and validating user settings from the configuration file
//! located at `~/.config/hyprmarker/config.toml`. Settings include drawing defaults,
//! arrow appearance, performance tuning, and UI preferences.
//!
//! If no config file exists, sensible defaults are used automatically.
pub mod enums;
pub mod types;
// Re-export commonly used types at module level
pub use enums::StatusPosition;
pub use types::{
ArrowConfig, DrawingConfig, HelpOverlayStyle, PerformanceConfig, StatusBarStyle, UiConfig,
};
// Re-export for public API (unused internally but part of public interface)
#[allow(unused_imports)]
pub use enums::ColorSpec;
use anyhow::{Context, Result};
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// Main configuration structure containing all user settings.
///
/// This is the root configuration type that gets deserialized from the TOML file.
/// All fields have sensible defaults and will use those if not specified in the config file.
///
/// # Example TOML
/// ```toml
/// [drawing]
/// default_color = "red"
/// default_thickness = 3.0
/// default_font_size = 32.0
///
/// [arrow]
/// length = 20.0
/// angle_degrees = 30.0
///
/// [performance]
/// buffer_count = 3
/// enable_vsync = true
///
/// [ui]
/// show_status_bar = true
/// status_bar_position = "bottom-left"
/// ```
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
/// Drawing tool defaults (color, thickness, font size)
#[serde(default)]
pub drawing: DrawingConfig,
/// Arrow appearance settings
#[serde(default)]
pub arrow: ArrowConfig,
/// Performance tuning options
#[serde(default)]
pub performance: PerformanceConfig,
/// UI display preferences
#[serde(default)]
pub ui: UiConfig,
}
impl Config {
/// Validates and clamps all configuration values to acceptable ranges.
///
/// This method ensures that user-provided config values won't cause undefined behavior
/// or rendering issues. Invalid values are clamped to the nearest valid value and a
/// warning is logged.
///
/// Validated ranges:
/// - `default_thickness`: 1.0 - 20.0
/// - `default_font_size`: 8.0 - 72.0
/// - `arrow.length`: 5.0 - 50.0
/// - `arrow.angle_degrees`: 15.0 - 60.0
/// - `buffer_count`: 2 - 4
fn validate_and_clamp(&mut self) {
// Thickness: 1.0 - 20.0
if !(1.0..=20.0).contains(&self.drawing.default_thickness) {
log::warn!(
"Invalid default_thickness {:.1}, clamping to 1.0-20.0 range",
self.drawing.default_thickness
);
self.drawing.default_thickness = self.drawing.default_thickness.clamp(1.0, 20.0);
}
// Font size: 8.0 - 72.0
if !(8.0..=72.0).contains(&self.drawing.default_font_size) {
log::warn!(
"Invalid default_font_size {:.1}, clamping to 8.0-72.0 range",
self.drawing.default_font_size
);
self.drawing.default_font_size = self.drawing.default_font_size.clamp(8.0, 72.0);
}
// Arrow length: 5.0 - 50.0
if !(5.0..=50.0).contains(&self.arrow.length) {
log::warn!(
"Invalid arrow length {:.1}, clamping to 5.0-50.0 range",
self.arrow.length
);
self.arrow.length = self.arrow.length.clamp(5.0, 50.0);
}
// Arrow angle: 15.0 - 60.0 degrees
if !(15.0..=60.0).contains(&self.arrow.angle_degrees) {
log::warn!(
"Invalid arrow angle {:.1}°, clamping to 15.0-60.0° range",
self.arrow.angle_degrees
);
self.arrow.angle_degrees = self.arrow.angle_degrees.clamp(15.0, 60.0);
}
// Buffer count: 2 - 4
if !(2..=4).contains(&self.performance.buffer_count) {
log::warn!(
"Invalid buffer_count {}, clamping to 2-4 range",
self.performance.buffer_count
);
self.performance.buffer_count = self.performance.buffer_count.clamp(2, 4);
}
// Validate font weight is reasonable
let valid_weight = matches!(
self.drawing.font_weight.to_lowercase().as_str(),
"normal" | "bold" | "light" | "ultralight" | "heavy" | "ultrabold"
) || self
.drawing
.font_weight
.parse::<u32>()
.is_ok_and(|w| (100..=900).contains(&w));
if !valid_weight {
log::warn!(
"Invalid font_weight '{}', falling back to 'bold'",
self.drawing.font_weight
);
self.drawing.font_weight = "bold".to_string();
}
// Validate font style
if !matches!(
self.drawing.font_style.to_lowercase().as_str(),
"normal" | "italic" | "oblique"
) {
log::warn!(
"Invalid font_style '{}', falling back to 'normal'",
self.drawing.font_style
);
self.drawing.font_style = "normal".to_string();
}
}
/// Returns the path to the configuration file.
///
/// The config file is located at `~/.config/hyprmarker/config.toml`.
///
/// # Errors
/// Returns an error if the config directory cannot be determined (e.g., HOME not set).
pub fn get_config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("hyprmarker");
Ok(config_dir.join("config.toml"))
}
/// Loads configuration from file, or returns defaults if not found.
///
/// Attempts to read and parse the config file at `~/.config/hyprmarker/config.toml`.
/// If the file doesn't exist, returns a Config with default values. All loaded values
/// are validated and clamped to acceptable ranges.
///
/// # Errors
/// Returns an error if:
/// - The config directory path cannot be determined
/// - The file exists but cannot be read
/// - The file exists but contains invalid TOML syntax
pub fn load() -> Result<Self> {
let config_path = Self::get_config_path()?;
if !config_path.exists() {
info!("Config file not found, using defaults");
debug!("Expected config at: {}", config_path.display());
return Ok(Self::default());
}
let config_str = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {}", config_path.display()))?;
let mut config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config from {}", config_path.display()))?;
// Validate and clamp values to acceptable ranges
config.validate_and_clamp();
info!("Loaded config from {}", config_path.display());
debug!("Config: {:?}", config);
Ok(config)
}
/// Saves the current configuration to file.
///
/// Serializes the config to TOML format and writes it to `~/.config/hyprmarker/config.toml`.
/// Creates the parent directory if it doesn't exist. This method is kept for future use
/// (e.g., runtime config editing).
///
/// # Errors
/// Returns an error if:
/// - The config directory cannot be created
/// - The config cannot be serialized to TOML
/// - The file cannot be written
#[allow(dead_code)]
pub fn save(&self) -> Result<()> {
let config_path = Self::get_config_path()?;
// Create directory if it doesn't exist
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context("Failed to create config directory")?;
}
let config_str = toml::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&config_path, config_str)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
info!("Saved config to {}", config_path.display());
Ok(())
}
/// Creates a default configuration file with documentation comments.
///
/// Writes the example config from `config.example.toml` to the user's config directory.
/// This method is kept for future use (e.g., `hyprmarker --init-config`).
///
/// # Errors
/// Returns an error if:
/// - A config file already exists at the target path
/// - The config directory cannot be created
/// - The file cannot be written
#[allow(dead_code)]
pub fn create_default_file() -> Result<()> {
let config_path = Self::get_config_path()?;
if config_path.exists() {
return Err(anyhow::anyhow!(
"Config file already exists at {}",
config_path.display()
));
}
// Create directory
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let default_config = include_str!("../../config.example.toml");
fs::write(&config_path, default_config)?;
info!("Created default config at {}", config_path.display());
Ok(())
}
}
+330
View File
@@ -0,0 +1,330 @@
//! Configuration type definitions.
use super::enums::{ColorSpec, StatusPosition};
use serde::{Deserialize, Serialize};
/// Drawing-related settings.
///
/// Controls the default appearance of drawing tools when the overlay first opens.
/// Users can change these values at runtime using keybindings.
#[derive(Debug, Serialize, Deserialize)]
pub struct DrawingConfig {
/// Default pen color - either a named color (red, green, blue, yellow, orange, pink, white, black)
/// or an RGB array like `[255, 0, 0]` for red
#[serde(default = "default_color")]
pub default_color: ColorSpec,
/// Default pen thickness in pixels (valid range: 1.0 - 20.0)
#[serde(default = "default_thickness")]
pub default_thickness: f64,
/// Default font size for text mode in points (valid range: 8.0 - 72.0)
#[serde(default = "default_font_size")]
pub default_font_size: f64,
/// Font family name for text rendering (e.g., "Sans", "Monospace", "JetBrains Mono")
/// Falls back to "Sans" if the specified font is not available
/// Note: Install fonts system-wide and reference by family name
#[serde(default = "default_font_family")]
pub font_family: String,
/// Font weight (e.g., "normal", "bold", "light", 400, 700)
/// Can be a named weight or a numeric value (100-900)
#[serde(default = "default_font_weight")]
pub font_weight: String,
/// Font style (e.g., "normal", "italic", "oblique")
#[serde(default = "default_font_style")]
pub font_style: String,
/// Enable semi-transparent background box behind text for better contrast
#[serde(default = "default_text_background")]
pub text_background_enabled: bool,
}
impl Default for DrawingConfig {
fn default() -> Self {
Self {
default_color: default_color(),
default_thickness: default_thickness(),
default_font_size: default_font_size(),
font_family: default_font_family(),
font_weight: default_font_weight(),
font_style: default_font_style(),
text_background_enabled: default_text_background(),
}
}
}
/// Arrow drawing settings.
///
/// Controls the appearance of arrowheads when using the arrow tool (Ctrl+Shift+Drag).
#[derive(Debug, Serialize, Deserialize)]
pub struct ArrowConfig {
/// Arrowhead length in pixels (valid range: 5.0 - 50.0)
#[serde(default = "default_arrow_length")]
pub length: f64,
/// Arrowhead angle in degrees (valid range: 15.0 - 60.0)
/// Smaller angles create narrower arrowheads, larger angles create wider ones
#[serde(default = "default_arrow_angle")]
pub angle_degrees: f64,
}
impl Default for ArrowConfig {
fn default() -> Self {
Self {
length: default_arrow_length(),
angle_degrees: default_arrow_angle(),
}
}
}
/// Performance tuning options.
///
/// These settings control rendering performance and smoothness. Most users
/// won't need to change these from their defaults.
#[derive(Debug, Serialize, Deserialize)]
pub struct PerformanceConfig {
/// Number of buffers for buffering (valid range: 2 - 4)
/// - 2 = double buffering (lower memory, potential tearing)
/// - 3 = triple buffering (balanced, recommended)
/// - 4 = quad buffering (highest memory, smoothest)
#[serde(default = "default_buffer_count")]
pub buffer_count: u32,
/// Enable vsync frame synchronization to prevent tearing
/// Set to false for lower latency at the cost of potential screen tearing
#[serde(default = "default_enable_vsync")]
pub enable_vsync: bool,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
buffer_count: default_buffer_count(),
enable_vsync: default_enable_vsync(),
}
}
}
/// UI display preferences.
///
/// Controls the visibility and positioning of on-screen UI elements.
#[derive(Debug, Serialize, Deserialize)]
pub struct UiConfig {
/// Show the status bar displaying current color, thickness, and tool
#[serde(default = "default_show_status")]
pub show_status_bar: bool,
/// Status bar screen position (top-left, top-right, bottom-left, bottom-right)
#[serde(default = "default_status_position")]
pub status_bar_position: StatusPosition,
/// Status bar styling options
#[serde(default)]
pub status_bar_style: StatusBarStyle,
/// Help overlay styling options
#[serde(default)]
pub help_overlay_style: HelpOverlayStyle,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
show_status_bar: default_show_status(),
status_bar_position: default_status_position(),
status_bar_style: StatusBarStyle::default(),
help_overlay_style: HelpOverlayStyle::default(),
}
}
}
/// Status bar styling configuration.
#[derive(Debug, Serialize, Deserialize)]
pub struct StatusBarStyle {
/// Font size for status bar text
#[serde(default = "default_status_font_size")]
pub font_size: f64,
/// Padding around status bar text
#[serde(default = "default_status_padding")]
pub padding: f64,
/// Background color [R, G, B, A] (0.0-1.0 range)
#[serde(default = "default_status_bg_color")]
pub bg_color: [f64; 4],
/// Text color [R, G, B, A] (0.0-1.0 range)
#[serde(default = "default_status_text_color")]
pub text_color: [f64; 4],
/// Color indicator dot radius
#[serde(default = "default_status_dot_radius")]
pub dot_radius: f64,
}
impl Default for StatusBarStyle {
fn default() -> Self {
Self {
font_size: default_status_font_size(),
padding: default_status_padding(),
bg_color: default_status_bg_color(),
text_color: default_status_text_color(),
dot_radius: default_status_dot_radius(),
}
}
}
/// Help overlay styling configuration.
#[derive(Debug, Serialize, Deserialize)]
pub struct HelpOverlayStyle {
/// Font size for help overlay text
#[serde(default = "default_help_font_size")]
pub font_size: f64,
/// Line height for help text
#[serde(default = "default_help_line_height")]
pub line_height: f64,
/// Padding around help box
#[serde(default = "default_help_padding")]
pub padding: f64,
/// Background color [R, G, B, A] (0.0-1.0 range)
#[serde(default = "default_help_bg_color")]
pub bg_color: [f64; 4],
/// Border color [R, G, B, A] (0.0-1.0 range)
#[serde(default = "default_help_border_color")]
pub border_color: [f64; 4],
/// Border line width
#[serde(default = "default_help_border_width")]
pub border_width: f64,
/// Text color [R, G, B, A] (0.0-1.0 range)
#[serde(default = "default_help_text_color")]
pub text_color: [f64; 4],
}
impl Default for HelpOverlayStyle {
fn default() -> Self {
Self {
font_size: default_help_font_size(),
line_height: default_help_line_height(),
padding: default_help_padding(),
bg_color: default_help_bg_color(),
border_color: default_help_border_color(),
border_width: default_help_border_width(),
text_color: default_help_text_color(),
}
}
}
// =============================================================================
// Default value functions
// =============================================================================
fn default_color() -> ColorSpec {
ColorSpec::Name("red".to_string())
}
fn default_thickness() -> f64 {
3.0
}
fn default_font_size() -> f64 {
32.0
}
fn default_font_family() -> String {
"Sans".to_string()
}
fn default_font_weight() -> String {
"bold".to_string()
}
fn default_font_style() -> String {
"normal".to_string()
}
fn default_text_background() -> bool {
false
}
fn default_arrow_length() -> f64 {
20.0
}
fn default_arrow_angle() -> f64 {
30.0
}
fn default_buffer_count() -> u32 {
3
}
fn default_enable_vsync() -> bool {
true
}
fn default_show_status() -> bool {
true
}
fn default_status_position() -> StatusPosition {
StatusPosition::BottomLeft
}
// Status bar style defaults
fn default_status_font_size() -> f64 {
21.0 // 50% larger than previous 14.0
}
fn default_status_padding() -> f64 {
15.0 // 50% larger than previous 10.0
}
fn default_status_bg_color() -> [f64; 4] {
[0.0, 0.0, 0.0, 0.85] // More opaque (was 0.7) for better visibility
}
fn default_status_text_color() -> [f64; 4] {
[1.0, 1.0, 1.0, 1.0]
}
fn default_status_dot_radius() -> f64 {
6.0 // 50% larger than previous 4.0
}
// Help overlay style defaults
fn default_help_font_size() -> f64 {
16.0
}
fn default_help_line_height() -> f64 {
22.0
}
fn default_help_padding() -> f64 {
20.0
}
fn default_help_bg_color() -> [f64; 4] {
[0.0, 0.0, 0.0, 0.85]
}
fn default_help_border_color() -> [f64; 4] {
[0.3, 0.6, 1.0, 0.9]
}
fn default_help_border_width() -> f64 {
2.0
}
fn default_help_text_color() -> [f64; 4] {
[1.0, 1.0, 1.0, 1.0]
}
+321
View File
@@ -0,0 +1,321 @@
/// Daemon mode implementation: background service with toggle activation
use anyhow::{Context, Result};
use ksni::TrayMethods;
use log::{debug, info, warn};
use signal_hook::consts::signal::{SIGINT, SIGTERM, SIGUSR1};
use signal_hook::iterator::Signals;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use crate::backend;
/// Overlay state for daemon mode
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverlayState {
Hidden, // Daemon running, overlay not visible
Visible, // Overlay active, capturing input
}
/// Daemon state manager
pub struct Daemon {
overlay_state: OverlayState,
should_quit: Arc<AtomicBool>,
toggle_requested: Arc<AtomicBool>,
}
impl Daemon {
pub fn new() -> Self {
Self {
overlay_state: OverlayState::Hidden,
should_quit: Arc::new(AtomicBool::new(false)),
toggle_requested: Arc::new(AtomicBool::new(false)),
}
}
/// Run daemon with signal handling
pub fn run(&mut self) -> Result<()> {
info!("Starting hyprmarker daemon");
info!("Send SIGUSR1 to toggle overlay (e.g., pkill -SIGUSR1 hyprmarker)");
info!("Configure Hyprland: bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker");
// Set up signal handling
let mut signals = Signals::new([SIGUSR1, SIGTERM, SIGINT])
.context("Failed to register signal handler")?;
let toggle_flag = self.toggle_requested.clone();
let quit_flag = self.should_quit.clone();
// Spawn signal handler thread
// Note: This thread will run until process termination. The signal_hook iterator
// doesn't provide a clean shutdown mechanism with forever(), but this is acceptable
// for a daemon process as the thread has no resources requiring explicit cleanup.
// The thread will be terminated by the OS when the process exits.
thread::spawn(move || {
for sig in signals.forever() {
match sig {
SIGUSR1 => {
info!("Received SIGUSR1 - toggling overlay");
// Use Release ordering to ensure all prior memory operations
// are visible to the thread that reads this flag
toggle_flag.store(true, Ordering::Release);
}
SIGTERM | SIGINT => {
info!("Received {} - initiating graceful shutdown",
if sig == SIGTERM { "SIGTERM" } else { "SIGINT" });
// Use Release ordering to ensure all prior memory operations
// are visible to the thread that reads this flag
quit_flag.store(true, Ordering::Release);
}
_ => {
warn!("Received unexpected signal: {}", sig);
}
}
}
});
// Start system tray
let tray_toggle = self.toggle_requested.clone();
let tray_quit = self.should_quit.clone();
thread::spawn(move || {
if let Err(e) = run_system_tray(tray_toggle, tray_quit) {
warn!("System tray failed: {}", e);
}
});
info!("Daemon ready - waiting for toggle signal");
// Main daemon loop
loop {
// Check for quit signal
// Use Acquire ordering to ensure we see all memory operations
// that happened before the flag was set
if self.should_quit.load(Ordering::Acquire) {
info!("Quit signal received - exiting daemon");
break;
}
// Check for toggle request
// Use Acquire ordering to ensure we see all memory operations
// that happened before the flag was set
if self.toggle_requested.swap(false, Ordering::Acquire) {
self.toggle_overlay()?;
}
// Small sleep to avoid busy-waiting
thread::sleep(Duration::from_millis(100));
}
info!("Daemon shutting down");
Ok(())
}
/// Toggle overlay visibility
fn toggle_overlay(&mut self) -> Result<()> {
match self.overlay_state {
OverlayState::Hidden => {
info!("Showing overlay");
self.show_overlay()?;
}
OverlayState::Visible => {
info!("Hiding overlay");
self.hide_overlay()?;
}
}
Ok(())
}
/// Show overlay (create layer surface and enter drawing mode)
fn show_overlay(&mut self) -> Result<()> {
if self.overlay_state == OverlayState::Visible {
debug!("Overlay already visible");
return Ok(());
}
// Set state to visible before running
self.overlay_state = OverlayState::Visible;
info!("Overlay state set to Visible");
// Run the Wayland backend (this will block until overlay is closed)
let result = backend::run_wayland();
// When run_wayland returns, the overlay was closed
self.overlay_state = OverlayState::Hidden;
info!("Overlay closed, back to daemon mode");
result
}
/// Hide overlay (destroy layer surface, return to hidden state)
fn hide_overlay(&mut self) -> Result<()> {
if self.overlay_state == OverlayState::Hidden {
debug!("Overlay already hidden");
return Ok(());
}
// NOTE: The overlay will be closed when user presses Escape
// or when the backend exits naturally
self.overlay_state = OverlayState::Hidden;
Ok(())
}
}
/// System tray implementation
fn run_system_tray(toggle_flag: Arc<AtomicBool>, quit_flag: Arc<AtomicBool>) -> Result<()> {
use ksni;
struct HyprmarkerTray {
toggle_flag: Arc<AtomicBool>,
quit_flag: Arc<AtomicBool>,
}
impl ksni::Tray for HyprmarkerTray {
fn id(&self) -> String {
"hyprmarker".into()
}
fn title(&self) -> String {
"Hyprmarker Screen Annotation".into()
}
fn icon_name(&self) -> String {
// Try common icon names - some systems might have these
"applications-graphics".into()
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
icon_name: "applications-graphics".into(),
icon_pixmap: vec![],
title: "Hyprmarker".into(),
description: "Press Super+D to toggle overlay".into(),
}
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
// Create a simple, highly visible pencil icon
// 22x22 pixels in ARGB32 format (4 bytes per pixel)
let size = 22;
let mut data = Vec::with_capacity(size * size * 4);
for y in 0..size {
for x in 0..size {
// Create a diagonal pencil shape pointing down-right
let (a, r, g, b) = if (2..=4).contains(&x) && (2..=4).contains(&y) {
// Pencil tip - dark gray/graphite
(255, 60, 60, 60)
} else if (3..=5).contains(&x) && (5..=7).contains(&y) {
// Wood section - brown
(255, 180, 120, 60)
} else if (4..=8).contains(&x) && (6..=14).contains(&y) {
// Main body - yellow pencil
(255, 255, 220, 0)
} else if (7..=9).contains(&x) && (13..=17).contains(&y) {
// Metal ferrule - silver
(255, 180, 180, 180)
} else if (8..=11).contains(&x) && (16..=19).contains(&y) {
// Eraser - pink
(255, 255, 150, 180)
} else {
// Transparent background
(0, 0, 0, 0)
};
data.push(a);
data.push(r);
data.push(g);
data.push(b);
}
}
vec![ksni::Icon {
width: size as i32,
height: size as i32,
data,
}]
}
fn category(&self) -> ksni::Category {
ksni::Category::ApplicationStatus
}
fn status(&self) -> ksni::Status {
ksni::Status::Active
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*;
vec![
StandardItem {
label: "Toggle Overlay (Super+D)".into(),
icon_name: "tool-pointer".into(),
activate: Box::new(|this: &mut Self| {
// Use Release ordering to ensure memory operations are visible
this.toggle_flag.store(true, Ordering::Release);
}),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Quit".into(),
icon_name: "window-close".into(),
activate: Box::new(|this: &mut Self| {
// Use Release ordering to ensure memory operations are visible
this.quit_flag.store(true, Ordering::Release);
}),
..Default::default()
}
.into(),
]
}
}
let tray = HyprmarkerTray {
toggle_flag,
quit_flag: quit_flag.clone(),
};
info!("Creating tray service...");
// ksni 0.3+ uses async API - spawn service in background tokio runtime
// Note: This thread will be terminated when the main daemon loop exits.
// The tray library handles its own cleanup when dropped.
let _tray_thread = std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(e) => {
warn!("Failed to create Tokio runtime for system tray: {}", e);
return;
}
};
rt.block_on(async {
match tray.spawn().await {
Ok(handle) => {
info!("System tray spawned successfully");
// Monitor quit flag and shutdown gracefully
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if quit_flag.load(Ordering::Acquire) {
info!("Quit signal received - shutting down system tray");
// Shutdown awaiter will clean up the tray service
let _ = handle.shutdown().await;
break;
}
}
}
Err(e) => {
warn!("System tray error: {}", e);
}
}
});
});
info!("System tray thread started");
Ok(())
}
+112
View File
@@ -0,0 +1,112 @@
//! RGBA color type and predefined color constants.
/// Represents an RGBA color with floating-point components.
///
/// All components are in the range 0.0 (minimum) to 1.0 (maximum).
///
/// # Examples
///
/// ```
/// use hyprmarker::draw::Color;
/// let red = Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
/// let semi_transparent_blue = Color { r: 0.0, g: 0.0, b: 1.0, a: 0.5 };
/// ```
#[derive(Clone, Copy, Debug)]
pub struct Color {
/// Red component (0.0 = no red, 1.0 = full red)
pub r: f64,
/// Green component (0.0 = no green, 1.0 = full green)
pub g: f64,
/// Blue component (0.0 = no blue, 1.0 = full blue)
pub b: f64,
/// Alpha/transparency (0.0 = fully transparent, 1.0 = fully opaque)
pub a: f64,
}
impl Color {
/// Creates a new color from RGBA components.
///
/// All values should be in the range 0.0 to 1.0.
/// This method is kept for future extensibility (custom colors in config file).
#[allow(dead_code)]
pub fn new(r: f64, g: f64, b: f64, a: f64) -> Self {
Self { r, g, b, a }
}
}
// ============================================================================
// Predefined Color Constants (ZoomIt-inspired palette)
// ============================================================================
/// Predefined red color (R=1.0, G=0.0, B=0.0)
pub const RED: Color = Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
/// Predefined green color (R=0.0, G=1.0, B=0.0)
pub const GREEN: Color = Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
};
/// Predefined blue color (R=0.0, G=0.0, B=1.0)
pub const BLUE: Color = Color {
r: 0.0,
g: 0.0,
b: 1.0,
a: 1.0,
};
/// Predefined yellow color (R=1.0, G=1.0, B=0.0)
pub const YELLOW: Color = Color {
r: 1.0,
g: 1.0,
b: 0.0,
a: 1.0,
};
/// Predefined orange color (R=1.0, G=0.5, B=0.0)
pub const ORANGE: Color = Color {
r: 1.0,
g: 0.5,
b: 0.0,
a: 1.0,
};
/// Predefined pink/magenta color (R=1.0, G=0.0, B=1.0)
pub const PINK: Color = Color {
r: 1.0,
g: 0.0,
b: 1.0,
a: 1.0,
};
/// Predefined white color (R=1.0, G=1.0, B=1.0)
pub const WHITE: Color = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
/// Predefined black color (R=0.0, G=0.0, B=0.0)
pub const BLACK: Color = Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
/// Fully transparent color - kept for future use (e.g., effects, config file)
#[allow(dead_code)]
pub const TRANSPARENT: Color = Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.0,
};
+105
View File
@@ -0,0 +1,105 @@
//! Font descriptor for text rendering.
/// Font configuration for text rendering.
///
/// Describes which font to use, including family name, weight, and style.
/// This descriptor is passed through the rendering pipeline to ensure
/// consistent font usage across preview and finalized text.
#[derive(Debug, Clone)]
pub struct FontDescriptor {
/// Font family name (e.g., "Sans", "Monospace", "JetBrains Mono")
/// Reference installed system fonts by name
pub family: String,
/// Font weight (e.g., "normal", "bold", "light" or numeric 100-900)
pub weight: String,
/// Font style (e.g., "normal", "italic", "oblique")
pub style: String,
}
impl FontDescriptor {
/// Creates a new font descriptor with the specified parameters.
pub fn new(family: String, weight: String, style: String) -> Self {
Self {
family,
weight,
style,
}
}
/// Creates a default font descriptor matching the current hardcoded behavior.
#[allow(dead_code)]
pub fn default() -> Self {
Self {
family: "Sans".to_string(),
weight: "bold".to_string(),
style: "normal".to_string(),
}
}
/// Converts this font descriptor to a Pango font description string.
///
/// Format: "Family Style Weight Size"
/// Example: "Sans Bold 32" or "Monospace Italic 24"
pub fn to_pango_string(&self, size: f64) -> String {
let mut parts = vec![self.family.clone()];
// Add style if not normal
if self.style.to_lowercase() != "normal" {
parts.push(capitalize_first(&self.style));
}
// Add weight if not normal
if self.weight.to_lowercase() != "normal" {
parts.push(capitalize_first(&self.weight));
}
// Add size
parts.push(format!("{}", size.round() as i32));
parts.join(" ")
}
}
/// Capitalizes the first letter of a string.
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pango_string_default() {
let font = FontDescriptor::default();
assert_eq!(font.to_pango_string(32.0), "Sans Bold 32");
}
#[test]
fn test_pango_string_italic() {
let font = FontDescriptor::new(
"Monospace".to_string(),
None,
"normal".to_string(),
"italic".to_string(),
);
assert_eq!(font.to_pango_string(24.0), "Monospace Italic 24");
}
#[test]
fn test_pango_string_custom() {
let font = FontDescriptor::new(
"JetBrains Mono".to_string(),
None,
"light".to_string(),
"normal".to_string(),
);
assert_eq!(font.to_pango_string(16.0), "JetBrains Mono Light 16");
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Frame container for managing collections of shapes.
use super::shape::Shape;
/// Container for all shapes in the current drawing session.
///
/// Manages a collection of [`Shape`]s and provides operations like adding,
/// clearing, and undoing shapes. Acts as the drawing canvas state.
pub struct Frame {
/// Vector of all shapes in draw order (first = bottom layer, last = top layer)
pub shapes: Vec<Shape>,
}
impl Frame {
/// Creates a new empty frame with no shapes.
pub fn new() -> Self {
Self { shapes: Vec::new() }
}
/// Removes all shapes from the frame, clearing the canvas.
pub fn clear(&mut self) {
self.shapes.clear();
}
/// Adds a new shape to the frame (drawn on top of existing shapes).
pub fn add_shape(&mut self, shape: Shape) {
self.shapes.push(shape);
}
/// Removes the most recently added shape.
///
/// Returns `true` if a shape was removed, `false` if the frame was already empty.
pub fn undo(&mut self) -> bool {
if self.shapes.is_empty() {
false
} else {
self.shapes.pop();
true
}
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Rendering primitives and shape definitions (Cairo-based).
//!
//! This module defines the core drawing types used for screen annotation:
//! - [`Color`]: RGBA color representation with predefined color constants
//! - [`Shape`]: Different annotation types (lines, rectangles, text, etc.)
//! - [`Frame`]: Container for all shapes in the current drawing
//! - Rendering functions for Cairo-based output
pub mod color;
pub mod font;
pub mod frame;
pub mod render;
pub mod shape;
// Re-export commonly used types at module level
pub use color::Color;
pub use font::FontDescriptor;
pub use frame::Frame;
pub use render::{render_freehand_borrowed, render_shape, render_shapes, render_text};
pub use shape::Shape;
// Re-export color constants for public API (unused internally but part of public interface)
#[allow(unused_imports)]
pub use color::{BLACK, BLUE, GREEN, ORANGE, PINK, RED, TRANSPARENT, WHITE, YELLOW};
// Re-export utility functions for public API (unused internally but part of public interface)
#[allow(unused_imports)]
pub use render::fill_transparent;
+363
View File
@@ -0,0 +1,363 @@
//! Cairo-based rendering functions for shapes.
use super::color::Color;
use super::shape::Shape;
use crate::util;
/// Renders all shapes in a collection to a Cairo context.
///
/// Iterates through the shapes slice and renders each one in order.
/// Shapes are drawn in the order they appear (first shape = bottom layer).
///
/// # Arguments
/// * `ctx` - Cairo drawing context to render to
/// * `shapes` - Slice of shapes to render
pub fn render_shapes(ctx: &cairo::Context, shapes: &[Shape]) {
for shape in shapes {
render_shape(ctx, shape);
}
}
/// Renders a single shape to a Cairo context.
///
/// Dispatches to the appropriate internal rendering function based on shape type.
/// Handles all shape variants: Freehand, Line, Rect, Ellipse, Arrow, and Text.
///
/// # Arguments
/// * `ctx` - Cairo drawing context to render to
/// * `shape` - The shape to render
pub fn render_shape(ctx: &cairo::Context, shape: &Shape) {
match shape {
Shape::Freehand {
points,
color,
thick,
} => {
render_freehand_borrowed(ctx, points, *color, *thick);
}
Shape::Line {
x1,
y1,
x2,
y2,
color,
thick,
} => {
render_line(ctx, *x1, *y1, *x2, *y2, *color, *thick);
}
Shape::Rect {
x,
y,
w,
h,
color,
thick,
} => {
render_rect(ctx, *x, *y, *w, *h, *color, *thick);
}
Shape::Ellipse {
cx,
cy,
rx,
ry,
color,
thick,
} => {
render_ellipse(ctx, *cx, *cy, *rx, *ry, *color, *thick);
}
Shape::Arrow {
x1,
y1,
x2,
y2,
color,
thick,
arrow_length,
arrow_angle,
} => {
render_arrow(
ctx,
*x1,
*y1,
*x2,
*y2,
*color,
*thick,
*arrow_length,
*arrow_angle,
);
}
Shape::Text {
x,
y,
text,
color,
size,
font_descriptor,
background_enabled,
} => {
render_text(
ctx,
*x,
*y,
text,
*color,
*size,
font_descriptor,
*background_enabled,
);
}
}
}
/// Render freehand stroke (polyline through points)
///
/// This function accepts a borrowed slice, avoiding clones for better performance.
/// Use this for rendering provisional shapes during drawing to prevent quadratic behavior.
pub fn render_freehand_borrowed(
ctx: &cairo::Context,
points: &[(i32, i32)],
color: Color,
thick: f64,
) {
if points.is_empty() {
return;
}
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.set_line_cap(cairo::LineCap::Round);
ctx.set_line_join(cairo::LineJoin::Round);
// Start at first point
let (x0, y0) = points[0];
ctx.move_to(x0 as f64, y0 as f64);
// Draw lines through all points
for &(x, y) in &points[1..] {
ctx.line_to(x as f64, y as f64);
}
let _ = ctx.stroke();
}
/// Render a straight line
fn render_line(ctx: &cairo::Context, x1: i32, y1: i32, x2: i32, y2: i32, color: Color, thick: f64) {
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.set_line_cap(cairo::LineCap::Round);
ctx.move_to(x1 as f64, y1 as f64);
ctx.line_to(x2 as f64, y2 as f64);
let _ = ctx.stroke();
}
/// Render a rectangle (outline)
fn render_rect(ctx: &cairo::Context, x: i32, y: i32, w: i32, h: i32, color: Color, thick: f64) {
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.set_line_join(cairo::LineJoin::Miter);
// Normalize rectangle to handle any legacy data with negative dimensions
// (InputState already normalizes, but this ensures consistent rendering)
let (norm_x, norm_w) = if w >= 0 {
(x as f64, w as f64)
} else {
((x + w) as f64, (-w) as f64)
};
let (norm_y, norm_h) = if h >= 0 {
(y as f64, h as f64)
} else {
((y + h) as f64, (-h) as f64)
};
ctx.rectangle(norm_x, norm_y, norm_w, norm_h);
let _ = ctx.stroke();
}
/// Render an ellipse using Cairo's arc with scaling
fn render_ellipse(
ctx: &cairo::Context,
cx: i32,
cy: i32,
rx: i32,
ry: i32,
color: Color,
thick: f64,
) {
if rx == 0 || ry == 0 {
return;
}
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.save().ok();
ctx.translate(cx as f64, cy as f64);
ctx.scale(rx as f64, ry as f64);
ctx.arc(0.0, 0.0, 1.0, 0.0, 2.0 * std::f64::consts::PI);
ctx.restore().ok();
let _ = ctx.stroke();
}
/// Render an arrow (line with arrowhead pointing towards start)
fn render_arrow(
ctx: &cairo::Context,
x1: i32,
y1: i32,
x2: i32,
y2: i32,
color: Color,
thick: f64,
arrow_length: f64,
arrow_angle: f64,
) {
// Draw the main line
render_line(ctx, x1, y1, x2, y2, color, thick);
// Draw arrowhead at (x1, y1) pointing towards start
// Returns [left_point, right_point]
let arrow_points = util::calculate_arrowhead_custom(x1, y1, x2, y2, arrow_length, arrow_angle);
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.set_line_cap(cairo::LineCap::Round);
// Draw left line of arrowhead (from start to left point)
ctx.move_to(x1 as f64, y1 as f64);
ctx.line_to(arrow_points[0].0, arrow_points[0].1);
let _ = ctx.stroke();
// Draw right line of arrowhead (from start to right point)
ctx.move_to(x1 as f64, y1 as f64);
ctx.line_to(arrow_points[1].0, arrow_points[1].1);
let _ = ctx.stroke();
}
/// Renders text at a specified position with multi-line support using Pango.
///
/// Uses Pango for advanced font rendering with custom font support. The position (x, y)
/// represents the text baseline starting point for the first line.
/// Text containing newline characters ('\n') will be rendered across multiple lines
/// with proper line spacing determined by the font metrics.
///
/// Text is rendered with a contrasting stroke outline for better visibility
/// against any background color.
///
/// # Arguments
/// * `ctx` - Cairo drawing context to render to
/// * `x` - X coordinate of text baseline start
/// * `y` - Y coordinate of text baseline (first line)
/// * `text` - Text content to render (may contain '\n' for line breaks)
/// * `color` - Text color
/// * `size` - Font size in points
/// * `font_descriptor` - Font configuration (family, weight, style)
/// * `background_enabled` - Whether to draw background box behind text
pub fn render_text(
ctx: &cairo::Context,
x: i32,
y: i32,
text: &str,
color: Color,
size: f64,
font_descriptor: &super::FontDescriptor,
background_enabled: bool,
) {
// Save context state to prevent settings from leaking to other drawing operations
ctx.save().ok();
// Use Best antialiasing (gray) instead of Subpixel for ARGB overlay
// Subpixel can cause color fringing on transparent/composited surfaces
ctx.set_antialias(cairo::Antialias::Best);
// Create Pango layout for text rendering
let layout = pangocairo::functions::create_layout(ctx);
// Set font description from config
let font_desc_str = font_descriptor.to_pango_string(size);
let font_desc = pango::FontDescription::from_string(&font_desc_str);
layout.set_font_description(Some(&font_desc));
// Set the text (Pango handles newlines automatically)
layout.set_text(text);
// Get layout extents for background and effects
let (ink_rect, _logical_rect) = layout.extents();
// Include ink rect offsets for italic/stroked glyphs with negative bearings
let ink_x = ink_rect.x() as f64 / pango::SCALE as f64;
let ink_y = ink_rect.y() as f64 / pango::SCALE as f64;
let ink_width = ink_rect.width() as f64 / pango::SCALE as f64;
let ink_height = ink_rect.height() as f64 / pango::SCALE as f64;
// Calculate brightness to determine background/stroke color
let brightness = color.r * 0.299 + color.g * 0.587 + color.b * 0.114;
let (bg_r, bg_g, bg_b) = if brightness > 0.5 {
(0.0, 0.0, 0.0) // Dark background/stroke for light text colors
} else {
(1.0, 1.0, 1.0) // Light background/stroke for dark text colors
};
// Adjust y position (Pango measures from top-left, we want baseline)
let baseline = layout.baseline() as f64 / pango::SCALE as f64;
let adjusted_y = y as f64 - baseline;
// First pass: draw semi-transparent background rectangle (if enabled)
if background_enabled && ink_width > 0.0 && ink_height > 0.0 {
let padding = size * 0.15;
// Use ink rect offsets to properly align background for italic/stroked glyphs
ctx.rectangle(
x as f64 + ink_x - padding,
adjusted_y + ink_y - padding,
ink_width + padding * 2.0,
ink_height + padding * 2.0,
);
ctx.set_source_rgba(bg_r, bg_g, bg_b, 0.3);
let _ = ctx.fill();
}
// Second pass: draw drop shadow for depth
let shadow_offset = size * 0.04;
ctx.move_to(x as f64 + shadow_offset, adjusted_y + shadow_offset);
ctx.set_source_rgba(0.0, 0.0, 0.0, 0.4);
pangocairo::functions::show_layout(ctx, &layout);
// Third pass: render text with contrasting stroke outline
ctx.move_to(x as f64, adjusted_y);
// Create path from layout for stroking
pangocairo::functions::layout_path(ctx, &layout);
// Fully opaque stroke for maximum contrast and crispness
ctx.set_source_rgba(bg_r, bg_g, bg_b, 1.0);
ctx.set_line_width(size * 0.06);
ctx.set_line_join(cairo::LineJoin::Round);
let _ = ctx.stroke_preserve();
// Fill with bright, full-intensity color
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
let _ = ctx.fill();
// Restore context state
ctx.restore().ok();
}
/// Fills the entire surface with a semi-transparent tinted background.
///
/// Creates a barely visible dark tint (0.05 alpha) to confirm the overlay is active
/// without obscuring the screen content. This function is kept for potential future use.
///
/// # Arguments
/// * `ctx` - Cairo drawing context to fill
/// * `width` - Surface width in pixels
/// * `height` - Surface height in pixels
#[allow(dead_code)]
pub fn fill_transparent(ctx: &cairo::Context, width: i32, height: i32) {
// Use a very slight tint so we can see the overlay is there
// 0.05 alpha = barely visible, just enough to confirm it's working
ctx.set_source_rgba(0.1, 0.1, 0.1, 0.05);
ctx.set_operator(cairo::Operator::Source);
ctx.rectangle(0.0, 0.0, width as f64, height as f64);
let _ = ctx.fill();
}
+102
View File
@@ -0,0 +1,102 @@
//! Shape definitions for screen annotations.
use super::color::Color;
use super::font::FontDescriptor;
/// Represents a drawable shape or annotation on screen.
///
/// Each variant represents a different drawing tool/primitive with its specific parameters.
/// All shapes store their own color and size information for independent rendering.
#[derive(Clone, Debug)]
pub enum Shape {
/// Freehand drawing - polyline connecting mouse drag points
Freehand {
/// Sequence of (x, y) coordinates traced by the mouse
points: Vec<(i32, i32)>,
/// Stroke color
color: Color,
/// Line thickness in pixels
thick: f64,
},
/// Straight line between two points (drawn with Shift modifier)
Line {
/// Starting X coordinate
x1: i32,
/// Starting Y coordinate
y1: i32,
/// Ending X coordinate
x2: i32,
/// Ending Y coordinate
y2: i32,
/// Line color
color: Color,
/// Line thickness in pixels
thick: f64,
},
/// Rectangle outline (drawn with Ctrl modifier)
Rect {
/// Top-left X coordinate
x: i32,
/// Top-left Y coordinate
y: i32,
/// Width in pixels
w: i32,
/// Height in pixels
h: i32,
/// Border color
color: Color,
/// Border thickness in pixels
thick: f64,
},
/// Ellipse/circle outline (drawn with Tab modifier)
Ellipse {
/// Center X coordinate
cx: i32,
/// Center Y coordinate
cy: i32,
/// Horizontal radius
rx: i32,
/// Vertical radius
ry: i32,
/// Border color
color: Color,
/// Border thickness in pixels
thick: f64,
},
/// Arrow with directional head (drawn with Ctrl+Shift modifiers)
Arrow {
/// Starting X coordinate (arrowhead location)
x1: i32,
/// Starting Y coordinate (arrowhead location)
y1: i32,
/// Ending X coordinate (arrow tail)
x2: i32,
/// Ending Y coordinate (arrow tail)
y2: i32,
/// Arrow color
color: Color,
/// Line thickness in pixels
thick: f64,
/// Arrowhead length in pixels
arrow_length: f64,
/// Arrowhead angle in degrees
arrow_angle: f64,
},
/// Text annotation (activated with 'T' key)
Text {
/// Baseline X coordinate
x: i32,
/// Baseline Y coordinate
y: i32,
/// Text content to display
text: String,
/// Text color
color: Color,
/// Font size in points
size: f64,
/// Font descriptor (family, weight, style)
font_descriptor: FontDescriptor,
/// Whether to draw background box behind text
background_enabled: bool,
},
}
+51
View File
@@ -0,0 +1,51 @@
//! Generic input event types for cross-backend compatibility.
/// Generic key representation for cross-backend compatibility.
///
/// Backend implementations map their native key codes to these generic
/// key values for unified input handling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] // Some variants used only in specific contexts
pub enum Key {
/// Regular character key (a-z, 0-9, symbols)
Char(char),
/// Escape key
Escape,
/// Return/Enter key
Return,
/// Backspace key
Backspace,
/// Tab key
Tab,
/// Space bar
Space,
/// Shift modifier
Shift,
/// Ctrl modifier
Ctrl,
/// Alt modifier
Alt,
/// Plus key (increase thickness)
Plus,
/// Minus key (decrease thickness)
Minus,
/// Equals key (alternate for plus)
Equals,
/// Underscore key (alternate for minus)
Underscore,
/// F10 function key (toggle help)
F10,
/// Unmapped or unrecognized key
Unknown,
}
/// Mouse button identification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseButton {
/// Left mouse button (primary drawing button)
Left,
/// Right mouse button (cancel action)
Right,
/// Middle mouse button (currently unused)
Middle,
}
+19
View File
@@ -0,0 +1,19 @@
//! Input handling and tool state machine.
//!
//! This module translates backend keyboard and mouse events into drawing actions.
//! It maintains the current tool state, drawing parameters (color, thickness),
//! and manages the state machine for different drawing modes (idle, drawing, text input).
pub mod events;
pub mod modifiers;
pub mod state;
pub mod tool;
// Re-export commonly used types at module level
pub use events::{Key, MouseButton};
pub use state::{DrawingState, InputState};
pub use tool::Tool;
// Re-export for public API (unused internally but part of public interface)
#[allow(unused_imports)]
pub use modifiers::Modifiers;
+53
View File
@@ -0,0 +1,53 @@
//! Keyboard modifier state tracking.
use super::tool::Tool;
/// Keyboard modifier state.
///
/// Tracks which modifier keys (Shift, Ctrl, Alt, Tab) are currently pressed.
/// Used to determine the active drawing tool and handle keyboard shortcuts.
#[derive(Debug, Clone, Copy)]
pub struct Modifiers {
/// Shift key pressed
pub shift: bool,
/// Ctrl key pressed
pub ctrl: bool,
/// Alt key pressed
pub alt: bool,
/// Tab key pressed
pub tab: bool,
}
impl Modifiers {
/// Creates a new Modifiers instance with all keys released.
pub fn new() -> Self {
Self {
shift: false,
ctrl: false,
alt: false,
tab: false,
}
}
/// Determines which drawing tool is active based on current modifier state.
///
/// # Tool Selection Priority
/// 1. Ctrl+Shift → Arrow
/// 2. Ctrl → Rectangle
/// 3. Shift → Line
/// 4. Tab → Ellipse
/// 5. None → Pen (default)
pub fn current_tool(&self) -> Tool {
if self.ctrl && self.shift {
Tool::Arrow
} else if self.ctrl {
Tool::Rect
} else if self.shift {
Tool::Line
} else if self.tab {
Tool::Ellipse
} else {
Tool::Pen
}
}
}
+573
View File
@@ -0,0 +1,573 @@
//! Drawing state machine and input state management.
use super::events::{Key, MouseButton};
use super::modifiers::Modifiers;
use super::tool::Tool;
use crate::draw::{Color, FontDescriptor, Frame, Shape};
use crate::util;
/// Current drawing mode state machine.
///
/// Tracks whether the user is idle, actively drawing a shape, or entering text.
/// State transitions occur based on mouse and keyboard events.
#[derive(Debug)]
pub enum DrawingState {
/// Not actively drawing - waiting for user input
Idle,
/// Actively drawing a shape (mouse button held down)
Drawing {
/// Which tool is being used for this shape
tool: Tool,
/// Starting X coordinate (where mouse was pressed)
start_x: i32,
/// Starting Y coordinate (where mouse was pressed)
start_y: i32,
/// Accumulated points for freehand drawing
points: Vec<(i32, i32)>,
},
/// Text input mode - user is typing text to place on screen
TextInput {
/// X coordinate where text will be placed
x: i32,
/// Y coordinate where text will be placed
y: i32,
/// Accumulated text buffer
buffer: String,
},
}
/// Main input state containing all drawing session state.
///
/// This struct holds the current frame (all drawn shapes), drawing parameters,
/// modifier keys, drawing mode, and UI flags. It processes all keyboard and
/// mouse events to update the drawing state and determine when redraws are needed.
pub struct InputState {
/// Container for all shapes drawn in this session
pub frame: Frame,
/// Current drawing color (changed with color keys: R, G, B, etc.)
pub current_color: Color,
/// Current pen/line thickness in pixels (changed with +/- keys)
pub current_thickness: f64,
/// Current font size for text mode (from config)
pub current_font_size: f64,
/// Font descriptor for text rendering (family, weight, style)
pub font_descriptor: FontDescriptor,
/// Whether to draw background behind text
pub text_background_enabled: bool,
/// Arrowhead length in pixels (from config)
pub arrow_length: f64,
/// Arrowhead angle in degrees (from config)
pub arrow_angle: f64,
/// Current modifier key state
pub modifiers: Modifiers,
/// Current drawing mode state machine
pub state: DrawingState,
/// Whether user requested to exit the overlay
pub should_exit: bool,
/// Whether the display needs to be redrawn
pub needs_redraw: bool,
/// Whether the help overlay is currently visible (toggled with F10)
pub show_help: bool,
/// Screen width in pixels (set by backend after configuration)
pub screen_width: u32,
/// Screen height in pixels (set by backend after configuration)
pub screen_height: u32,
}
impl InputState {
/// Creates a new InputState with specified defaults.
///
/// Screen dimensions default to 0 and should be updated by the backend
/// after surface configuration (see `update_screen_dimensions`).
///
/// # Arguments
/// * `color` - Initial drawing color
/// * `thickness` - Initial pen thickness in pixels
/// * `font_size` - Font size for text mode in points
/// * `font_descriptor` - Font configuration for text rendering
/// * `text_background_enabled` - Whether to draw background behind text
/// * `arrow_length` - Arrowhead length in pixels
/// * `arrow_angle` - Arrowhead angle in degrees
pub fn with_defaults(
color: Color,
thickness: f64,
font_size: f64,
font_descriptor: FontDescriptor,
text_background_enabled: bool,
arrow_length: f64,
arrow_angle: f64,
) -> Self {
Self {
frame: Frame::new(),
current_color: color,
current_thickness: thickness,
current_font_size: font_size,
font_descriptor,
text_background_enabled,
arrow_length,
arrow_angle,
modifiers: Modifiers::new(),
state: DrawingState::Idle,
should_exit: false,
needs_redraw: true,
show_help: false,
screen_width: 0,
screen_height: 0,
}
}
/// Updates screen dimensions after backend configuration.
///
/// This should be called by the backend when it receives the actual
/// screen dimensions from the display server.
///
/// # Arguments
/// * `width` - Screen width in pixels
/// * `height` - Screen height in pixels
pub fn update_screen_dimensions(&mut self, width: u32, height: u32) {
self.screen_width = width;
self.screen_height = height;
}
/// Processes a key press event.
///
/// Handles all keyboard input including:
/// - Drawing color selection (R, G, B, Y, O, P, W, K)
/// - Tool actions (T for text mode, E for clear, Ctrl+Z for undo)
/// - Text input (when in TextInput state)
/// - Exit commands (Escape, Ctrl+Q)
/// - Thickness adjustment (+/-, mouse scroll handled separately)
/// - Help toggle (F10)
/// - Modifier key tracking
pub fn on_key_press(&mut self, key: Key) {
match key {
Key::Escape => {
// Exit drawing mode or cancel current action
match &self.state {
DrawingState::TextInput { .. } => {
// Cancel text input
self.state = DrawingState::Idle;
self.needs_redraw = true;
}
DrawingState::Drawing { .. } => {
// Cancel current drawing
self.state = DrawingState::Idle;
self.needs_redraw = true;
}
DrawingState::Idle => {
// Exit application
self.should_exit = true;
}
}
}
Key::Char(c) => {
// Handle character input - check if we're in text mode first
if let DrawingState::TextInput { buffer, .. } = &mut self.state {
// In text mode, ALL characters go to the buffer
buffer.push(c);
self.needs_redraw = true;
} else {
// Not in text mode, handle special keys
match c {
't' | 'T' => {
// Enter text mode
if matches!(self.state, DrawingState::Idle) {
self.state = DrawingState::TextInput {
x: (self.screen_width / 2) as i32,
y: (self.screen_height / 2) as i32,
buffer: String::new(),
};
self.needs_redraw = true;
}
}
'e' | 'E' => {
// Clear all annotations
self.frame.clear();
self.needs_redraw = true;
}
'z' | 'Z' if self.modifiers.ctrl => {
// Undo last shape
if self.frame.undo() {
self.needs_redraw = true;
}
}
'q' | 'Q' if self.modifiers.ctrl => {
// Exit overlay (return to daemon mode)
log::info!("Ctrl+Q pressed - setting should_exit=true");
self.should_exit = true;
// Trigger redraw to force event loop to check should_exit
self.needs_redraw = true;
}
_ => {
// Check if it's a color key
if let Some(color) = util::key_to_color(c) {
self.current_color = color;
self.needs_redraw = true;
}
}
}
}
}
Key::Backspace => {
if let DrawingState::TextInput { buffer, .. } = &mut self.state {
buffer.pop();
self.needs_redraw = true;
}
}
Key::Return => {
// Finalize text input or insert newline if Shift is held
if let DrawingState::TextInput { x, y, buffer } = &mut self.state {
if self.modifiers.shift {
// Shift+Enter: insert newline
buffer.push('\n');
self.needs_redraw = true;
} else {
// Plain Enter: finalize text input
if !buffer.is_empty() {
self.frame.add_shape(Shape::Text {
x: *x,
y: *y,
text: buffer.clone(),
color: self.current_color,
size: self.current_font_size,
font_descriptor: self.font_descriptor.clone(),
background_enabled: self.text_background_enabled,
});
self.needs_redraw = true;
}
self.state = DrawingState::Idle;
}
}
}
Key::Space => {
if let DrawingState::TextInput { buffer, .. } = &mut self.state {
buffer.push(' ');
self.needs_redraw = true;
}
}
Key::Shift => self.modifiers.shift = true,
Key::Ctrl => self.modifiers.ctrl = true,
Key::Alt => self.modifiers.alt = true,
Key::Tab => self.modifiers.tab = true,
Key::Plus | Key::Equals => {
// Increase thickness
self.current_thickness = (self.current_thickness + 1.0).min(20.0);
self.needs_redraw = true;
}
Key::Minus | Key::Underscore => {
// Decrease thickness
self.current_thickness = (self.current_thickness - 1.0).max(1.0);
self.needs_redraw = true;
}
Key::F10 => {
// Toggle help overlay
self.show_help = !self.show_help;
self.needs_redraw = true;
}
_ => {}
}
}
/// Processes a key release event.
///
/// Currently only tracks modifier key releases to update the modifier state.
pub fn on_key_release(&mut self, key: Key) {
match key {
Key::Shift => self.modifiers.shift = false,
Key::Ctrl => self.modifiers.ctrl = false,
Key::Alt => self.modifiers.alt = false,
Key::Tab => self.modifiers.tab = false,
_ => {}
}
}
/// Processes a mouse button press event.
///
/// # Arguments
/// * `button` - Which mouse button was pressed
/// * `x` - Mouse X coordinate
/// * `y` - Mouse Y coordinate
///
/// # Behavior
/// - Left click while Idle: Starts drawing with the current tool (based on modifiers)
/// - Left click during TextInput: Updates text position
/// - Right click: Cancels current action
pub fn on_mouse_press(&mut self, button: MouseButton, x: i32, y: i32) {
match button {
MouseButton::Left => {
// Start drawing with current tool
if matches!(self.state, DrawingState::Idle) {
let tool = self.modifiers.current_tool();
self.state = DrawingState::Drawing {
tool,
start_x: x,
start_y: y,
points: vec![(x, y)],
};
self.needs_redraw = true;
} else if let DrawingState::TextInput { x: tx, y: ty, .. } = &mut self.state {
// Update text position if in text mode
*tx = x;
*ty = y;
self.needs_redraw = true;
}
}
MouseButton::Right => {
// Right-click could cancel or exit
if !matches!(self.state, DrawingState::Idle) {
self.state = DrawingState::Idle;
self.needs_redraw = true;
}
}
_ => {}
}
}
/// Processes mouse motion (dragging) events.
///
/// # Arguments
/// * `x` - Current mouse X coordinate
/// * `y` - Current mouse Y coordinate
///
/// # Behavior
/// - When drawing with Pen tool: Adds points to the freehand stroke
/// - When drawing with other tools: Triggers redraw for live preview
pub fn on_mouse_motion(&mut self, x: i32, y: i32) {
if let DrawingState::Drawing { tool, points, .. } = &mut self.state {
if *tool == Tool::Pen {
// Add point to freehand stroke
points.push((x, y));
}
// For other tools, we'll update the end point in release
self.needs_redraw = true;
}
}
/// Processes mouse button release events.
///
/// # Arguments
/// * `button` - Which mouse button was released
/// * `x` - Mouse X coordinate at release
/// * `y` - Mouse Y coordinate at release
///
/// # Behavior
/// When left button is released during drawing:
/// - Finalizes the shape using start position and current position
/// - Adds the completed shape to the frame
/// - Returns to Idle state
pub fn on_mouse_release(&mut self, button: MouseButton, x: i32, y: i32) {
if button != MouseButton::Left {
return;
}
if let DrawingState::Drawing {
tool,
start_x,
start_y,
points,
} = &self.state
{
let shape = match tool {
Tool::Pen => Shape::Freehand {
points: points.clone(),
color: self.current_color,
thick: self.current_thickness,
},
Tool::Line => Shape::Line {
x1: *start_x,
y1: *start_y,
x2: x,
y2: y,
color: self.current_color,
thick: self.current_thickness,
},
Tool::Rect => {
// Normalize rectangle to handle dragging in any direction
let (x, w) = if x >= *start_x {
(*start_x, x - start_x)
} else {
(x, start_x - x)
};
let (y, h) = if y >= *start_y {
(*start_y, y - start_y)
} else {
(y, start_y - y)
};
Shape::Rect {
x,
y,
w,
h,
color: self.current_color,
thick: self.current_thickness,
}
}
Tool::Ellipse => {
let (cx, cy, rx, ry) = util::ellipse_bounds(*start_x, *start_y, x, y);
Shape::Ellipse {
cx,
cy,
rx,
ry,
color: self.current_color,
thick: self.current_thickness,
}
}
Tool::Arrow => Shape::Arrow {
x1: *start_x,
y1: *start_y,
x2: x,
y2: y,
color: self.current_color,
thick: self.current_thickness,
arrow_length: self.arrow_length,
arrow_angle: self.arrow_angle,
},
};
self.frame.add_shape(shape);
self.state = DrawingState::Idle;
self.needs_redraw = true;
}
}
/// Returns the shape currently being drawn for live preview.
///
/// # Arguments
/// * `current_x` - Current mouse X coordinate
/// * `current_y` - Current mouse Y coordinate
///
/// # Returns
/// - `Some(Shape)` if actively drawing (for preview rendering)
/// - `None` if idle or in text input mode
///
/// # Note
/// For Pen tool (freehand), this clones the points vector. For better performance
/// with long strokes, consider using `render_provisional_shape` directly with a
/// borrow instead of calling this method and rendering separately.
///
/// This allows the backend to render a preview of the shape being drawn
/// before the mouse button is released.
pub fn get_provisional_shape(&self, current_x: i32, current_y: i32) -> Option<Shape> {
if let DrawingState::Drawing {
tool,
start_x,
start_y,
points,
} = &self.state
{
match tool {
Tool::Pen => Some(Shape::Freehand {
points: points.clone(), // TODO: Consider using Cow or separate borrow API
color: self.current_color,
thick: self.current_thickness,
}),
Tool::Line => Some(Shape::Line {
x1: *start_x,
y1: *start_y,
x2: current_x,
y2: current_y,
color: self.current_color,
thick: self.current_thickness,
}),
Tool::Rect => {
// Normalize rectangle to handle dragging in any direction
let (x, w) = if current_x >= *start_x {
(*start_x, current_x - start_x)
} else {
(current_x, start_x - current_x)
};
let (y, h) = if current_y >= *start_y {
(*start_y, current_y - start_y)
} else {
(current_y, start_y - current_y)
};
Some(Shape::Rect {
x,
y,
w,
h,
color: self.current_color,
thick: self.current_thickness,
})
}
Tool::Ellipse => {
let (cx, cy, rx, ry) =
util::ellipse_bounds(*start_x, *start_y, current_x, current_y);
Some(Shape::Ellipse {
cx,
cy,
rx,
ry,
color: self.current_color,
thick: self.current_thickness,
})
}
Tool::Arrow => Some(Shape::Arrow {
x1: *start_x,
y1: *start_y,
x2: current_x,
y2: current_y,
color: self.current_color,
thick: self.current_thickness,
arrow_length: self.arrow_length,
arrow_angle: self.arrow_angle,
}),
// No provisional shape for other tools
}
} else {
None
}
}
/// Renders the provisional shape directly to a Cairo context without cloning.
///
/// This is an optimized version for freehand drawing that avoids cloning
/// the points vector on every render, preventing quadratic performance.
///
/// # Arguments
/// * `ctx` - Cairo context to render to
/// * `current_x` - Current mouse X coordinate
/// * `current_y` - Current mouse Y coordinate
///
/// # Returns
/// `true` if a provisional shape was rendered, `false` otherwise
pub fn render_provisional_shape(
&self,
ctx: &cairo::Context,
current_x: i32,
current_y: i32,
) -> bool {
if let DrawingState::Drawing {
tool,
start_x: _,
start_y: _,
points,
} = &self.state
{
match tool {
Tool::Pen => {
// Render freehand without cloning - just borrow the points
crate::draw::render_freehand_borrowed(
ctx,
points,
self.current_color,
self.current_thickness,
);
true
}
_ => {
// For other tools, use the normal path (no clone needed)
if let Some(shape) = self.get_provisional_shape(current_x, current_y) {
crate::draw::render_shape(ctx, &shape);
true
} else {
false
}
}
}
} else {
false
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Drawing tool selection.
/// Drawing tool selection.
///
/// The active tool determines what shape is created when the user drags the mouse.
/// Tools are selected by holding modifier keys (Shift, Ctrl, Tab) while dragging.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
/// Freehand drawing - follows mouse path (default, no modifiers)
Pen,
/// Straight line - between start and end points (Shift)
Line,
/// Rectangle outline - from corner to corner (Ctrl)
Rect,
/// Ellipse/circle outline - from center outward (Tab)
Ellipse,
/// Arrow with directional head (Ctrl+Shift)
Arrow,
// Note: Text mode uses DrawingState::TextInput instead of Tool::Text
}
+89
View File
@@ -0,0 +1,89 @@
use clap::{ArgAction, Parser};
mod backend;
mod config;
mod daemon;
mod draw;
mod input;
mod ui;
mod util;
#[derive(Parser, Debug)]
#[command(name = "hyprmarker")]
#[command(version, about = "Screen annotation tool for Wayland compositors")]
struct Cli {
/// Run as daemon (background, toggle with Super+D)
#[arg(long, short = 'd', action = ArgAction::SetTrue)]
daemon: bool,
/// Start active (show overlay immediately, one-shot mode)
#[arg(long, short = 'a', action = ArgAction::SetTrue)]
active: bool,
}
fn main() -> anyhow::Result<()> {
env_logger::init();
let cli = Cli::parse();
// Check for Wayland environment
if std::env::var("WAYLAND_DISPLAY").is_err() && (cli.daemon || cli.active) {
log::error!("WAYLAND_DISPLAY not set - this application requires Wayland.");
log::error!("Please run on a Wayland compositor (Hyprland, Sway, etc.).");
return Err(anyhow::anyhow!("Wayland environment required"));
}
if cli.daemon {
// Daemon mode: background service with toggle activation
log::info!("Starting in daemon mode");
let mut daemon = daemon::Daemon::new();
daemon.run()?;
} else if cli.active {
// One-shot mode: show overlay immediately and exit when done
log::info!("Starting Wayland overlay...");
log::info!("Starting annotation overlay...");
log::info!("Controls:");
log::info!(" - Freehand: Just drag");
log::info!(" - Line: Hold Shift + drag");
log::info!(" - Rectangle: Hold Ctrl + drag");
log::info!(" - Ellipse: Hold Tab + drag");
log::info!(" - Arrow: Hold Ctrl+Shift + drag");
log::info!(" - Text: Press T, click to position, type, press Enter");
log::info!(
" - Colors: R (red), G (green), B (blue), Y (yellow), O (orange), P (pink), W (white), K (black)"
);
log::info!(" - Undo: Ctrl+Z");
log::info!(" - Clear all: E");
log::info!(" - Increase thickness: + or = or scroll down");
log::info!(" - Decrease thickness: - or _ or scroll up");
log::info!(" - Help: F10");
log::info!(" - Exit: Escape");
log::info!("");
// Run Wayland backend
backend::run_wayland()?;
log::info!("Annotation overlay closed.");
} else {
// No flags: show usage
println!("hyprmarker: Screen annotation tool for Wayland compositors");
println!();
println!("Usage:");
println!(" hyprmarker --daemon Run as background daemon (toggle with Super+D)");
println!(" hyprmarker --active Show overlay immediately (one-shot mode)");
println!(" hyprmarker --help Show help");
println!();
println!("Daemon mode (recommended):");
println!(" 1. Run: hyprmarker --daemon");
println!(" 2. Add to Hyprland config:");
println!(" exec-once = hyprmarker --daemon");
println!(" bind = SUPER, D, exec, pkill -SIGUSR1 hyprmarker");
println!(" 3. Press Super+D to toggle overlay on/off");
println!();
println!("Requirements:");
println!(" - Wayland compositor (Hyprland, Sway, etc.)");
println!(" - wlr-layer-shell protocol support");
}
Ok(())
}
+234
View File
@@ -0,0 +1,234 @@
/// UI rendering: status bar, help overlay, visual indicators
use crate::config::StatusPosition;
use crate::input::{DrawingState, InputState, Tool};
// ============================================================================
// UI Layout Constants (not configurable)
// ============================================================================
/// Background rectangle X offset
const STATUS_BG_OFFSET_X: f64 = 5.0;
/// Background rectangle Y offset
const STATUS_BG_OFFSET_Y: f64 = 3.0;
/// Background rectangle width padding
const STATUS_BG_WIDTH_PAD: f64 = 10.0;
/// Background rectangle height padding
const STATUS_BG_HEIGHT_PAD: f64 = 8.0;
/// Color indicator dot X offset
const STATUS_DOT_OFFSET_X: f64 = 3.0;
/// Fallback character width for monospace font estimation
const HELP_CHAR_WIDTH_ESTIMATE: f64 = 9.0;
/// Render status bar showing current color, thickness, and tool
pub fn render_status_bar(
ctx: &cairo::Context,
input_state: &InputState,
position: StatusPosition,
style: &crate::config::StatusBarStyle,
screen_width: u32,
screen_height: u32,
) {
let color = &input_state.current_color;
let thickness = input_state.current_thickness;
let tool = input_state.modifiers.current_tool();
// Determine tool name
let tool_name = match &input_state.state {
DrawingState::TextInput { .. } => "Text",
DrawingState::Drawing { tool, .. } => match tool {
Tool::Pen => "Pen",
Tool::Line => "Line",
Tool::Rect => "Rectangle",
Tool::Ellipse => "Circle",
Tool::Arrow => "Arrow",
},
DrawingState::Idle => match tool {
Tool::Pen => "Pen",
Tool::Line => "Line",
Tool::Rect => "Rectangle",
Tool::Ellipse => "Circle",
Tool::Arrow => "Arrow",
},
};
// Determine color name
let color_name = crate::util::color_to_name(color);
// Build status text
let status_text = format!(
"[{}] [{}px] [{}] F10=Help",
color_name, thickness as i32, tool_name
);
// Set font
log::debug!("Status bar font_size from config: {}", style.font_size);
ctx.set_font_size(style.font_size);
ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
// Measure text
let extents = match ctx.text_extents(&status_text) {
Ok(ext) => ext,
Err(e) => {
log::warn!(
"Failed to measure status bar text: {}, skipping status bar",
e
);
return; // Gracefully skip rendering if font measurement fails
}
};
let text_width = extents.width();
let text_height = extents.height();
// Calculate position using configurable padding
let padding = style.padding;
let (x, y) = match position {
StatusPosition::TopLeft => (padding, padding + text_height),
StatusPosition::TopRight => (
screen_width as f64 - text_width - padding,
padding + text_height,
),
StatusPosition::BottomLeft => (padding, screen_height as f64 - padding),
StatusPosition::BottomRight => (
screen_width as f64 - text_width - padding,
screen_height as f64 - padding,
),
};
// Draw semi-transparent background
let [r, g, b, a] = style.bg_color;
ctx.set_source_rgba(r, g, b, a);
ctx.rectangle(
x - STATUS_BG_OFFSET_X,
y - text_height - STATUS_BG_OFFSET_Y,
text_width + STATUS_BG_WIDTH_PAD,
text_height + STATUS_BG_HEIGHT_PAD,
);
let _ = ctx.fill();
// Draw color indicator dot
let dot_x = x + STATUS_DOT_OFFSET_X;
let dot_y = y - text_height / 2.0;
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.arc(
dot_x,
dot_y,
style.dot_radius,
0.0,
2.0 * std::f64::consts::PI,
);
let _ = ctx.fill();
// Draw text
let [r, g, b, a] = style.text_color;
ctx.set_source_rgba(r, g, b, a);
ctx.move_to(x, y);
let _ = ctx.show_text(&status_text);
}
/// Render help overlay showing all keybindings
pub fn render_help_overlay(
ctx: &cairo::Context,
style: &crate::config::HelpOverlayStyle,
screen_width: u32,
screen_height: u32,
) {
let help_text = vec![
"━━━━━━━━━━━ CONTROLS ━━━━━━━━━━━",
"",
"Drawing Tools:",
" Drag = Freehand pen",
" Shift + Drag = Straight line",
" Ctrl + Drag = Rectangle",
" Tab + Drag = Circle",
" Ctrl+Shift + Drag = Arrow",
" T = Text mode",
"",
"Text Mode:",
" Type = Enter text",
" Shift + Enter = New line",
" Enter = Finish text",
" Backspace = Delete char",
"",
"Colors:",
" R = Red G = Green B = Blue",
" Y = Yellow O = Orange P = Pink",
" W = White K = Black",
"",
"Pen Thickness:",
" + or = = Increase",
" - or _ = Decrease",
" Scroll Up = Increase",
" Scroll Down = Decrease",
"",
"Actions:",
" E = Clear all",
" Ctrl+Z = Undo last",
" Escape or Ctrl+Q = Exit",
"",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"",
"Press F10 to hide help",
];
// Set font
ctx.set_font_size(style.font_size);
ctx.select_font_face(
"Monospace",
cairo::FontSlant::Normal,
cairo::FontWeight::Normal,
);
// Find longest line for width
let mut max_width: f64 = 0.0;
for line in &help_text {
let extents = match ctx.text_extents(line) {
Ok(ext) => ext,
Err(e) => {
log::warn!(
"Failed to measure help text line '{}': {}, using fallback width",
line,
e
);
// Use a fallback width estimate based on character count
let fallback_width = line.len() as f64 * HELP_CHAR_WIDTH_ESTIMATE;
max_width = max_width.max(fallback_width);
continue;
}
};
if extents.width() > max_width {
max_width = extents.width();
}
}
let box_width = max_width + style.padding * 2.0;
let box_height = (help_text.len() as f64) * style.line_height + style.padding * 2.0;
// Center the box
let box_x = (screen_width as f64 - box_width) / 2.0;
let box_y = (screen_height as f64 - box_height) / 2.0;
// Draw semi-transparent background
let [r, g, b, a] = style.bg_color;
ctx.set_source_rgba(r, g, b, a);
ctx.rectangle(box_x, box_y, box_width, box_height);
let _ = ctx.fill();
// Draw border
let [r, g, b, a] = style.border_color;
ctx.set_source_rgba(r, g, b, a);
ctx.set_line_width(style.border_width);
ctx.rectangle(box_x, box_y, box_width, box_height);
let _ = ctx.stroke();
// Draw text
let [r, g, b, a] = style.text_color;
ctx.set_source_rgba(r, g, b, a);
for (i, line) in help_text.iter().enumerate() {
let text_x = box_x + style.padding;
let text_y = box_y + style.padding + (i as f64 + 1.0) * style.line_height;
ctx.move_to(text_x, text_y);
let _ = ctx.show_text(line);
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Utility functions for colors, geometry, and arrowhead calculations.
//!
//! This module provides:
//! - Key-to-color mapping for keyboard shortcuts (constants moved to draw::color)
//! - Arrowhead geometry calculations
//! - Ellipse bounding box calculations
use crate::draw::{Color, color::*};
// ============================================================================
// Arrowhead Geometry
// ============================================================================
/// Calculates arrowhead points with custom length and angle.
///
/// Creates a V-shaped arrowhead at position (x1, y1) pointing in the direction
/// from (x2, y2) to (x1, y1). The arrowhead length is automatically capped at
/// 30% of the line length to prevent weird-looking arrows on short lines.
///
/// # Arguments
/// * `x1` - Arrowhead tip X coordinate
/// * `y1` - Arrowhead tip Y coordinate
/// * `x2` - Arrow tail X coordinate
/// * `y2` - Arrow tail Y coordinate
/// * `length` - Desired arrowhead length in pixels (will be capped at 30% of line length)
/// * `angle_degrees` - Arrowhead angle in degrees (angle between arrowhead lines and main line)
///
/// # Returns
/// Array of two points `[(left_x, left_y), (right_x, right_y)]` for the arrowhead lines.
/// If the line is too short (< 1 pixel), both points equal (x1, y1).
pub fn calculate_arrowhead_custom(
x1: i32,
y1: i32,
x2: i32,
y2: i32,
length: f64,
angle_degrees: f64,
) -> [(f64, f64); 2] {
let dx = (x1 - x2) as f64; // Direction from END to START (reversed)
let dy = (y1 - y2) as f64;
let line_length = (dx * dx + dy * dy).sqrt();
if line_length < 1.0 {
// Line too short for arrowhead
return [(x1 as f64, y1 as f64), (x1 as f64, y1 as f64)];
}
// Normalize direction vector (pointing from end to start)
let ux = dx / line_length;
let uy = dy / line_length;
// Arrowhead length (max 30% of line length to avoid weird-looking arrows on short lines)
let arrow_length = length.min(line_length * 0.3);
// Convert angle to radians
let angle = angle_degrees.to_radians();
let cos_a = angle.cos();
let sin_a = angle.sin();
// Left side of arrowhead (at START point)
let left_x = x1 as f64 - arrow_length * (ux * cos_a - uy * sin_a);
let left_y = y1 as f64 - arrow_length * (uy * cos_a + ux * sin_a);
// Right side of arrowhead (at START point)
let right_x = x1 as f64 - arrow_length * (ux * cos_a + uy * sin_a);
let right_y = y1 as f64 - arrow_length * (uy * cos_a - ux * sin_a);
[(left_x, left_y), (right_x, right_y)]
}
// ============================================================================
// Color Mapping
// ============================================================================
/// Maps keyboard characters to colors for quick color switching.
///
/// # Supported Keys (case-insensitive)
/// - `R` → Red
/// - `G` → Green
/// - `B` → Blue
/// - `Y` → Yellow
/// - `O` → Orange
/// - `P` → Pink
/// - `W` → White
/// - `K` → Black (K for blacK, since B is blue)
///
/// # Arguments
/// * `c` - Character key pressed by user
///
/// # Returns
/// - `Some(Color)` if the character maps to a predefined color
/// - `None` if the character doesn't correspond to any color
pub fn key_to_color(c: char) -> Option<Color> {
match c.to_ascii_uppercase() {
'R' => Some(RED),
'G' => Some(GREEN),
'B' => Some(BLUE),
'Y' => Some(YELLOW),
'O' => Some(ORANGE),
'P' => Some(PINK),
'W' => Some(WHITE),
'K' => Some(BLACK), // K for blacK
_ => None,
}
}
/// Maps color name strings to Color values.
///
/// Used by the configuration system to parse color names from the config file.
///
/// # Supported Names (case-insensitive)
/// - "red", "green", "blue", "yellow", "orange", "pink", "white", "black"
///
/// # Arguments
/// * `name` - Color name string
///
/// # Returns
/// - `Some(Color)` if the name matches a predefined color
/// - `None` if the name is not recognized
pub fn name_to_color(name: &str) -> Option<Color> {
match name.to_lowercase().as_str() {
"red" => Some(RED),
"green" => Some(GREEN),
"blue" => Some(BLUE),
"yellow" => Some(YELLOW),
"orange" => Some(ORANGE),
"pink" => Some(PINK),
"white" => Some(WHITE),
"black" => Some(BLACK),
_ => None,
}
}
/// Maps a Color value to its human-readable name.
///
/// Uses approximate matching (threshold-based) to identify colors.
/// Used by the UI status bar to display the current color name.
///
/// # Arguments
/// * `color` - The color to identify
///
/// # Returns
/// A static string with the color name, or "Custom" if the color doesn't
/// match any predefined color.
pub fn color_to_name(color: &Color) -> &'static str {
// Match colors approximately with 0.1 tolerance
if color.r > 0.9 && color.g < 0.1 && color.b < 0.1 {
"Red"
} else if color.r < 0.1 && color.g > 0.9 && color.b < 0.1 {
"Green"
} else if color.r < 0.1 && color.g < 0.1 && color.b > 0.9 {
"Blue"
} else if color.r > 0.9 && color.g > 0.9 && color.b < 0.1 {
"Yellow"
} else if color.r > 0.9 && (0.4..=0.6).contains(&color.g) && color.b < 0.1 {
"Orange"
} else if color.r > 0.9 && color.g < 0.1 && color.b > 0.9 {
"Pink"
} else if color.r > 0.9 && color.g > 0.9 && color.b > 0.9 {
"White"
} else if color.r < 0.1 && color.g < 0.1 && color.b < 0.1 {
"Black"
} else {
"Custom"
}
}
// ============================================================================
// Geometry Utilities
// ============================================================================
/// Clamps a value to a specified range.
///
/// Kept for future use (e.g., dirty region optimization, bounds checking).
///
/// # Arguments
/// * `val` - Value to clamp
/// * `min` - Minimum allowed value
/// * `max` - Maximum allowed value
///
/// # Returns
/// The clamped value: `min` if `val < min`, `max` if `val > max`, otherwise `val`.
#[allow(dead_code)]
pub fn clamp(val: i32, min: i32, max: i32) -> i32 {
if val < min {
min
} else if val > max {
max
} else {
val
}
}
/// Calculates ellipse parameters from two corner points.
///
/// Converts a drag rectangle (from corner to corner) into ellipse parameters
/// (center point and radii) suitable for Cairo's ellipse rendering.
///
/// # Arguments
/// * `x1` - First corner X coordinate
/// * `y1` - First corner Y coordinate
/// * `x2` - Opposite corner X coordinate
/// * `y2` - Opposite corner Y coordinate
///
/// # Returns
/// Tuple `(cx, cy, rx, ry)` where:
/// - `cx`, `cy` = center point coordinates
/// - `rx` = horizontal radius (half width)
/// - `ry` = vertical radius (half height)
pub fn ellipse_bounds(x1: i32, y1: i32, x2: i32, y2: i32) -> (i32, i32, i32, i32) {
let cx = (x1 + x2) / 2;
let cy = (y1 + y2) / 2;
let rx = ((x2 - x1).abs()) / 2;
let ry = ((y2 - y1).abs()) / 2;
(cx, cy, rx, ry)
}
+21
View File
@@ -0,0 +1,21 @@
# Tools
Helper scripts for development and installation.
## Scripts
- **install.sh** - Installation script for hyprmarker
- Builds and installs binary to `~/.local/bin`
- Sets up config directory
- Optionally configures systemd or Hyprland autostart
- Usage: `./tools/install.sh`
- **run.sh** - Quick run script for development
- Runs hyprmarker in daemon mode with debug logging
- Usage: `./tools/run.sh`
- **reload-daemon.sh** - Reload running daemon
- Kills and restarts the daemon (picks up config changes)
- Usage: `./tools/reload-daemon.sh`
All scripts work from any location in the project.
+215
View File
@@ -0,0 +1,215 @@
#!/bin/bash
# Installation script for hyprmarker
set -e
# Get the directory where the script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Get the project root (parent of tools/)
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="$HOME/.local/bin"
BINARY_NAME="hyprmarker"
CONFIG_DIR="$HOME/.config/hyprmarker"
HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf"
echo "================================"
echo " Hyprmarker Installation"
echo "================================"
echo ""
# Check if binary exists
if [ ! -f "$PROJECT_ROOT/target/release/$BINARY_NAME" ]; then
echo "Error: Binary not found at $PROJECT_ROOT/target/release/$BINARY_NAME"
echo "Please run 'cargo build --release' from the project root first."
exit 1
fi
# Create install directory if needed
echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# Copy binary
echo "Installing binary to $INSTALL_DIR/$BINARY_NAME"
cp "$PROJECT_ROOT/target/release/$BINARY_NAME" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
# Check if install directory is in PATH
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo ""
echo "⚠️ Warning: $INSTALL_DIR is not in your PATH"
echo " Add this line to your ~/.bashrc or ~/.zshrc:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
echo ""
fi
# Create config directory
echo "Creating config directory: $CONFIG_DIR"
mkdir -p "$CONFIG_DIR"
# Copy example config if config doesn't exist
if [ ! -f "$CONFIG_DIR/config.toml" ]; then
if [ -f "$PROJECT_ROOT/config.example.toml" ]; then
echo "Installing example config to $CONFIG_DIR/config.toml"
cp "$PROJECT_ROOT/config.example.toml" "$CONFIG_DIR/config.toml"
fi
fi
echo ""
echo "✅ Installation complete!"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Setup Instructions"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "1. Test the installation:"
echo " $BINARY_NAME --help"
echo ""
echo "2. Run in daemon mode (recommended):"
echo " $BINARY_NAME --daemon &"
echo ""
echo "3. For Hyprland integration, add to $HYPR_CONFIG:"
echo ""
echo " # Autostart hyprmarker daemon"
echo " exec-once = $INSTALL_DIR/$BINARY_NAME --daemon"
echo ""
echo " # Toggle overlay with Super+D"
echo " bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME"
echo ""
# Setup autostart options
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Autostart Setup"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Choose autostart method:"
echo " 1) Systemd user service (recommended - runs on login)"
echo " 2) Hyprland exec-once (Hyprland only)"
echo " 3) Skip autostart setup"
echo ""
read -p "Enter choice [1-3]: " -n 1 -r
echo ""
echo ""
case $REPLY in
1)
# Systemd user service
SYSTEMD_DIR="$HOME/.config/systemd/user"
SERVICE_FILE="$SYSTEMD_DIR/hyprmarker.service"
echo "Setting up systemd user service..."
mkdir -p "$SYSTEMD_DIR"
if [ -f "$PROJECT_ROOT/packaging/hyprmarker.service" ]; then
cp "$PROJECT_ROOT/packaging/hyprmarker.service" "$SERVICE_FILE"
echo "✅ Service file installed to $SERVICE_FILE"
# Enable and start the service
systemctl --user daemon-reload
systemctl --user enable hyprmarker.service
systemctl --user start hyprmarker.service
echo "✅ Service enabled and started"
echo ""
echo "Service status:"
systemctl --user status hyprmarker.service --no-pager -l
echo ""
echo "Commands:"
echo " Start: systemctl --user start hyprmarker"
echo " Stop: systemctl --user stop hyprmarker"
echo " Status: systemctl --user status hyprmarker"
echo " Logs: journalctl --user -u hyprmarker -f"
else
echo "⚠️ Service file not found. Please run installer from repository root."
fi
# Still add Hyprland keybind if config exists
if [ -f "$HYPR_CONFIG" ]; then
echo ""
read -p "Add Super+D keybind to Hyprland config? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
if grep -q "pkill -SIGUSR1 $BINARY_NAME" "$HYPR_CONFIG"; then
echo "⚠️ Keybind already configured"
else
echo "" >> "$HYPR_CONFIG"
echo "# hyprmarker toggle keybind" >> "$HYPR_CONFIG"
echo "bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME" >> "$HYPR_CONFIG"
echo "✅ Keybind added to Hyprland config"
echo ""
echo "Reload Hyprland: hyprctl reload"
fi
fi
fi
;;
2)
# Hyprland exec-once
if [ -f "$HYPR_CONFIG" ]; then
echo "Adding to Hyprland config..."
if grep -q "hyprmarker --daemon" "$HYPR_CONFIG"; then
echo "⚠️ hyprmarker already configured in Hyprland config"
else
echo "" >> "$HYPR_CONFIG"
echo "# hyprmarker - Screen annotation tool" >> "$HYPR_CONFIG"
echo "exec-once = $INSTALL_DIR/$BINARY_NAME --daemon" >> "$HYPR_CONFIG"
echo "bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME" >> "$HYPR_CONFIG"
echo "✅ Added to Hyprland config"
fi
echo ""
echo "Reload Hyprland to activate:"
echo " hyprctl reload"
else
echo "⚠️ Hyprland config not found at $HYPR_CONFIG"
echo "Add these lines manually to your Hyprland config:"
echo " exec-once = $INSTALL_DIR/$BINARY_NAME --daemon"
echo " bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME"
fi
;;
3)
echo "Skipping autostart setup."
echo "To start manually: $BINARY_NAME --daemon &"
;;
*)
echo "Invalid choice. Skipping autostart setup."
;;
esac
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Usage"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Daemon mode (background, toggle with Super+D):"
echo " $BINARY_NAME --daemon"
echo ""
echo "One-shot mode (overlay shows immediately):"
echo " $BINARY_NAME --active"
echo ""
echo "Controls:"
echo " - Freehand: Drag mouse"
echo " - Line: Shift + drag"
echo " - Rectangle: Ctrl + drag"
echo " - Ellipse: Tab + drag"
echo " - Arrow: Ctrl+Shift + drag"
echo " - Text: Press T"
echo " - Colors: R/G/B/Y/O/P/W/K"
echo " - Thickness: +/- or scroll wheel"
echo " - Help: F10"
echo " - Undo: Ctrl+Z"
echo " - Clear: E"
echo " - Exit: Escape"
echo ""
echo "Configuration:"
echo " Edit: $CONFIG_DIR/config.toml"
echo ""
echo "Documentation:"
echo " docs/SETUP.md"
echo " docs/CONFIG.md"
echo " docs/QUICKSTART.md"
echo ""
echo "Happy annotating! 🎨"
echo ""
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Reload hyprmarker daemon
# This will kill the old daemon and start a new one with updated config
echo "Stopping hyprmarker daemon..."
pkill hyprmarker
# Wait a moment for clean shutdown
sleep 0.5
echo "Starting hyprmarker daemon..."
hyprmarker --daemon &
# Wait to verify it started
sleep 0.5
if pgrep -x hyprmarker > /dev/null; then
echo "✓ Daemon restarted successfully (PID: $(pgrep -x hyprmarker))"
echo "Press Super+D to toggle overlay"
else
echo "✗ Failed to start daemon"
exit 1
fi
+174
View File
@@ -0,0 +1,174 @@
#!/bin/bash
# Automated AUR package update script
# Updates PKGBUILD version, builds, tests, and pushes to AUR
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
AUR_DIR="$HOME/aur-packages"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "═══════════════════════════════════════════════════════════════"
echo " HYPRMARKER - AUR UPDATE AUTOMATION"
echo "═══════════════════════════════════════════════════════════════"
echo ""
# Check we're in the right directory
if [ ! -f "$PROJECT_ROOT/Cargo.toml" ]; then
echo -e "${RED}❌ Error: Not in hyprmarker project root${NC}"
exit 1
fi
# Get version from Cargo.toml
CARGO_VERSION=$(grep '^version = ' "$PROJECT_ROOT/Cargo.toml" | head -1 | sed 's/version = "\(.*\)"/\1/')
echo -e "${GREEN}📦 Current version in Cargo.toml: $CARGO_VERSION${NC}"
echo ""
# Check if version tag exists on GitHub
cd "$PROJECT_ROOT"
if ! git tag | grep -q "^v$CARGO_VERSION\$"; then
echo -e "${YELLOW}⚠️ Git tag v$CARGO_VERSION does not exist${NC}"
echo ""
read -p "Create and push tag v$CARGO_VERSION? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
git tag -a "v$CARGO_VERSION" -m "Release v$CARGO_VERSION"
git push origin "v$CARGO_VERSION"
echo -e "${GREEN}✅ Tag created and pushed${NC}"
else
echo -e "${RED}❌ Aborted - tag required for AUR${NC}"
exit 1
fi
fi
# Check AUR directory exists
if [ ! -d "$AUR_DIR" ]; then
echo -e "${RED}❌ Error: AUR directory not found: $AUR_DIR${NC}"
echo ""
echo "Initialize it first:"
echo " mkdir -p $AUR_DIR"
echo " cd $AUR_DIR"
echo " git init"
echo " git remote add origin ssh://aur@aur.archlinux.org/hyprmarker.git"
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "STEP 1: Update PKGBUILD"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Copy PKGBUILD.aur to AUR directory
if [ ! -f "$PROJECT_ROOT/packaging/PKGBUILD.aur" ]; then
echo -e "${RED}❌ Error: PKGBUILD.aur not found${NC}"
exit 1
fi
cp "$PROJECT_ROOT/packaging/PKGBUILD.aur" "$AUR_DIR/PKGBUILD"
echo -e "${GREEN}✅ Copied PKGBUILD.aur to $AUR_DIR/PKGBUILD${NC}"
# Update version in PKGBUILD
cd "$AUR_DIR"
sed -i "s/^pkgver=.*/pkgver=$CARGO_VERSION/" PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD
echo -e "${GREEN}✅ Updated PKGBUILD: pkgver=$CARGO_VERSION, pkgrel=1${NC}"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "STEP 2: Generate .SRCINFO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
makepkg --printsrcinfo > .SRCINFO
echo -e "${GREEN}✅ Generated .SRCINFO${NC}"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "STEP 3: Test build locally"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
read -p "Test build locally? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Building package..."
if makepkg -f; then
echo -e "${GREEN}✅ Build successful${NC}"
echo ""
read -p "Install locally to test? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
makepkg -i
fi
else
echo -e "${RED}❌ Build failed - fix errors before pushing to AUR${NC}"
exit 1
fi
else
echo -e "${YELLOW}⚠️ Skipping local build test${NC}"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "STEP 4: Commit and push to AUR"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Show git status
echo "Files to be committed:"
git status --short
echo ""
read -p "Push to AUR? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Check if git is initialized
if [ ! -d "$AUR_DIR/.git" ]; then
echo "Initializing git repository..."
git init
git remote add origin ssh://aur@aur.archlinux.org/hyprmarker.git
fi
# Add and commit
git add PKGBUILD .SRCINFO .gitignore 2>/dev/null || git add PKGBUILD .SRCINFO
git commit -m "Update to v$CARGO_VERSION"
# Push
if git push origin master 2>/dev/null; then
echo ""
echo -e "${GREEN}✅ Successfully pushed to AUR!${NC}"
else
# If master doesn't exist, try pushing with -u
git push -u origin master
echo ""
echo -e "${GREEN}✅ Successfully pushed to AUR!${NC}"
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo -e "${GREEN}✅ AUR PACKAGE UPDATED SUCCESSFULLY${NC}"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "Package URL: https://aur.archlinux.org/packages/hyprmarker"
echo "Version: $CARGO_VERSION"
echo ""
echo "Users can update with:"
echo " yay -Syu hyprmarker"
echo " paru -Syu hyprmarker"
echo ""
else
echo -e "${YELLOW}⚠️ Push to AUR cancelled${NC}"
echo ""
echo "To push manually later:"
echo " cd $AUR_DIR"
echo " git add PKGBUILD .SRCINFO"
echo " git commit -m 'Update to v$CARGO_VERSION'"
echo " git push origin master"
fi