feat: initial implementation of scr33ny OLED screensaver
Screensavers: blank (pure black), GIF animation, static image, text with float/typewriter/fade animations. Widgets: digital/analog clock, date, weather (OpenWeatherMap API). Anti burn-in pixel shift with configurable interval. Wayland-native via winit 0.29 + softbuffer 0.4. TOML config with serde, full CLI via clap, idle daemon mode.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
[package]
|
||||||
|
name = "scr33ny"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "OLED-friendly screensaver with Material 3 Expressive widgets"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["itlxrd <ilyakm@icloud.com>"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "scr33ny"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
winit = "0.29"
|
||||||
|
softbuffer = "0.4"
|
||||||
|
image = { version = "0.25", features = ["gif", "png", "jpeg", "bmp"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "macros"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
fontdue = "0.8"
|
||||||
|
chrono = "0.4"
|
||||||
|
dirs = "5"
|
||||||
|
shellexpand = "3"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
anyhow = "1"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# scr33ny — OLED screensaver config
|
||||||
|
# Copy to ~/.config/scr33ny/config.toml
|
||||||
|
|
||||||
|
# ── Screensaver ───────────────────────────────────────────────────────────────
|
||||||
|
# type: blank | gif | image | text
|
||||||
|
|
||||||
|
[screensaver]
|
||||||
|
type = "blank" # pure black (default, OLED-optimal)
|
||||||
|
|
||||||
|
# ── GIF example ───────────────────────────────────────────────────────────────
|
||||||
|
# [screensaver]
|
||||||
|
# type = "gif"
|
||||||
|
# path = "~/.config/scr33ny/cat.gif"
|
||||||
|
# scale = "fit" # fit | fill | original
|
||||||
|
|
||||||
|
# ── Image example ─────────────────────────────────────────────────────────────
|
||||||
|
# [screensaver]
|
||||||
|
# type = "image"
|
||||||
|
# path = "~/.config/scr33ny/wallpaper.png"
|
||||||
|
# scale = "fill"
|
||||||
|
|
||||||
|
# ── Text animation example ────────────────────────────────────────────────────
|
||||||
|
# [screensaver]
|
||||||
|
# type = "text"
|
||||||
|
# content = "scr33ny"
|
||||||
|
# font_size = 96.0
|
||||||
|
# color = "#ffffff"
|
||||||
|
# animation = "float" # float | typewriter | fade
|
||||||
|
|
||||||
|
# ── Display settings ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[display]
|
||||||
|
monitor = 0 # monitor index (0 = primary). See: scr33ny monitors
|
||||||
|
burn_shift = 2 # pixel shift for OLED burn-in prevention
|
||||||
|
shift_interval = 120 # seconds between shifts
|
||||||
|
|
||||||
|
# ── Idle daemon ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[daemon]
|
||||||
|
enabled = false # set true to use: scr33ny daemon
|
||||||
|
idle_timeout = 300 # seconds of inactivity before activating
|
||||||
|
|
||||||
|
# ── Widgets ───────────────────────────────────────────────────────────────────
|
||||||
|
# Each widget is a [[widgets]] block.
|
||||||
|
# position.x: left | center | right | <percent 0-100>
|
||||||
|
# position.y: top | center | bottom | <percent 0-100>
|
||||||
|
|
||||||
|
# Digital clock (center screen)
|
||||||
|
[[widgets]]
|
||||||
|
type = "clock"
|
||||||
|
style = "digital" # digital | analog
|
||||||
|
font_size = 72.0
|
||||||
|
color = "#ffffff"
|
||||||
|
show_seconds = true
|
||||||
|
|
||||||
|
[widgets.position]
|
||||||
|
x = "center"
|
||||||
|
y = "center"
|
||||||
|
|
||||||
|
# Date below the clock
|
||||||
|
[[widgets]]
|
||||||
|
type = "date"
|
||||||
|
font_size = 28.0
|
||||||
|
color = "#aaaaaa"
|
||||||
|
format = "%A, %B %d" # chrono strftime format
|
||||||
|
|
||||||
|
[widgets.position]
|
||||||
|
x = "center"
|
||||||
|
y = 62.0 # 62% from top
|
||||||
|
|
||||||
|
# Weather widget (requires OpenWeatherMap API key)
|
||||||
|
# [[widgets]]
|
||||||
|
# type = "weather"
|
||||||
|
# api_key = "YOUR_OWM_API_KEY" # openweathermap.org
|
||||||
|
# location = "Moscow"
|
||||||
|
# units = "metric" # metric | imperial
|
||||||
|
# font_size = 22.0
|
||||||
|
# color = "#cccccc"
|
||||||
|
#
|
||||||
|
# [widgets.position]
|
||||||
|
# x = "right"
|
||||||
|
# y = "top"
|
||||||
|
|
||||||
|
# Analog clock example
|
||||||
|
# [[widgets]]
|
||||||
|
# type = "clock"
|
||||||
|
# style = "analog"
|
||||||
|
# font_size = 40.0 # controls clock size
|
||||||
|
# color = "#ffffff"
|
||||||
|
# show_seconds = true
|
||||||
|
#
|
||||||
|
# [widgets.position]
|
||||||
|
# x = "center"
|
||||||
|
# y = "center"
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
# scr33ny
|
||||||
|
|
||||||
|
OLED-friendly screensaver for Linux / Wayland with Material 3 Expressive widgets.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Pure black default** — all OLED pixels off, zero burn-in
|
||||||
|
- **Anti burn-in shift** — content subtly shifts every N minutes
|
||||||
|
- **Screensavers**: blank, GIF animation, image, text (float / typewriter / fade)
|
||||||
|
- **Widgets**: digital/analog clock, date, weather (OpenWeatherMap)
|
||||||
|
- **Wayland-native** via winit + softbuffer (no Xorg dependency)
|
||||||
|
- **TOML config** — human-readable, well-documented
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build release binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
# binary at: target/release/scr33ny
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with defaults (black screen, no widgets)
|
||||||
|
scr33ny
|
||||||
|
|
||||||
|
# Use a config file
|
||||||
|
scr33ny --config ~/my-config.toml
|
||||||
|
|
||||||
|
# List monitors
|
||||||
|
scr33ny monitors
|
||||||
|
|
||||||
|
# Display on monitor 1
|
||||||
|
scr33ny --monitor 1
|
||||||
|
|
||||||
|
# Show config path
|
||||||
|
scr33ny config-path
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy the example config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/scr33ny
|
||||||
|
cp config.toml.example ~/.config/scr33ny/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
See [config.md](config.md) for full reference.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Q` | Exit screensaver |
|
||||||
|
| `Esc` | Exit screensaver |
|
||||||
|
|
||||||
|
## Idle daemon
|
||||||
|
|
||||||
|
For automatic activation on idle, use the daemon mode.
|
||||||
|
|
||||||
|
### With swayidle (recommended for Sway/wlroots)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your Sway config:
|
||||||
|
exec swayidle -w \
|
||||||
|
timeout 300 'scr33ny' \
|
||||||
|
resume 'killall scr33ny'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in daemon
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# In config.toml:
|
||||||
|
[daemon]
|
||||||
|
enabled = true
|
||||||
|
idle_timeout = 300
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scr33ny daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon tries `swayidle` first; falls back to a simple timer if not found.
|
||||||
|
|
||||||
|
## Screensaver types
|
||||||
|
|
||||||
|
### blank
|
||||||
|
|
||||||
|
Pure black. Optimal for OLED — consumes near-zero power.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[screensaver]
|
||||||
|
type = "blank"
|
||||||
|
```
|
||||||
|
|
||||||
|
### gif
|
||||||
|
|
||||||
|
Animated GIF, looped.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[screensaver]
|
||||||
|
type = "gif"
|
||||||
|
path = "~/.config/scr33ny/cat.gif"
|
||||||
|
scale = "fit" # fit | fill | original
|
||||||
|
```
|
||||||
|
|
||||||
|
### image
|
||||||
|
|
||||||
|
Static image (PNG, JPEG, BMP, WebP).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[screensaver]
|
||||||
|
type = "image"
|
||||||
|
path = "~/.config/scr33ny/bg.png"
|
||||||
|
scale = "fill"
|
||||||
|
```
|
||||||
|
|
||||||
|
### text
|
||||||
|
|
||||||
|
Text with animation.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[screensaver]
|
||||||
|
type = "text"
|
||||||
|
content = "hello"
|
||||||
|
font_size = 96.0
|
||||||
|
color = "#ffffff"
|
||||||
|
animation = "float" # float | typewriter | fade
|
||||||
|
```
|
||||||
|
|
||||||
|
**Animations:**
|
||||||
|
- `float` — Lissajous curve orbit, maximum anti-burn-in protection
|
||||||
|
- `typewriter` — types characters one by one
|
||||||
|
- `fade` — fade in → hold → fade out cycle
|
||||||
|
|
||||||
|
## Widget reference
|
||||||
|
|
||||||
|
See [config.md](config.md#widgets) for full widget documentation.
|
||||||
|
|
||||||
|
## System fonts
|
||||||
|
|
||||||
|
scr33ny searches common paths automatically:
|
||||||
|
|
||||||
|
- `/usr/share/fonts/TTF/DejaVuSans.ttf`
|
||||||
|
- `/usr/share/fonts/noto/NotoSans-Regular.ttf`
|
||||||
|
- `/usr/share/fonts/liberation/LiberationSans-Regular.ttf`
|
||||||
|
- `~/.local/share/fonts/` (scanned recursively)
|
||||||
|
|
||||||
|
If no font is found:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S ttf-dejavu
|
||||||
|
|
||||||
|
# Ubuntu / Debian
|
||||||
|
sudo apt install fonts-dejavu
|
||||||
|
```
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
# Config reference
|
||||||
|
|
||||||
|
Default location: `~/.config/scr33ny/config.toml`
|
||||||
|
|
||||||
|
Override: `scr33ny --config /path/to/config.toml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [screensaver]
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `type` | string | `"blank"` | `blank` \| `gif` \| `image` \| `text` |
|
||||||
|
|
||||||
|
### type = "gif"
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `path` | string | — | Path to GIF file. `~` is expanded. |
|
||||||
|
| `scale` | string | `"fit"` | `fit` \| `fill` \| `original` |
|
||||||
|
|
||||||
|
### type = "image"
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `path` | string | — | Path to image (PNG/JPEG/BMP/WebP). `~` is expanded. |
|
||||||
|
| `scale` | string | `"fit"` | `fit` \| `fill` \| `original` |
|
||||||
|
|
||||||
|
### type = "text"
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `content` | string | — | Text to display |
|
||||||
|
| `font_size` | float | `48.0` | Font size in pixels |
|
||||||
|
| `color` | string | `"#ffffff"` | Hex color |
|
||||||
|
| `animation` | string | `"float"` | `float` \| `typewriter` \| `fade` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [display]
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `monitor` | int | `0` | Monitor index. `scr33ny monitors` to list. |
|
||||||
|
| `burn_shift` | int | `2` | Pixel offset applied for OLED burn-in prevention |
|
||||||
|
| `shift_interval` | int | `120` | Seconds between shifts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [daemon]
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `enabled` | bool | `false` | Enable idle daemon |
|
||||||
|
| `idle_timeout` | int | `300` | Seconds of inactivity before activation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [[widgets]]
|
||||||
|
|
||||||
|
Each widget is declared as a `[[widgets]]` array entry.
|
||||||
|
|
||||||
|
### Common fields
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `type` | string | — | Widget type |
|
||||||
|
| `font_size` | float | `48.0` | Font size in pixels |
|
||||||
|
| `color` | string | `"#ffffff"` | Hex color |
|
||||||
|
| `[widgets.position]` | table | center/center | Positioning (see below) |
|
||||||
|
|
||||||
|
### Position
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[widgets.position]
|
||||||
|
x = "center" # left | center | right | <0-100 percent>
|
||||||
|
y = "center" # top | center | bottom | <0-100 percent>
|
||||||
|
```
|
||||||
|
|
||||||
|
### type = "clock"
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `style` | string | `"digital"` | `digital` \| `analog` |
|
||||||
|
| `show_seconds` | bool | `false` | Show second hand / seconds in digital mode |
|
||||||
|
|
||||||
|
### type = "date"
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `format` | string | `"%A, %B %d"` | [strftime](https://docs.rs/chrono/latest/chrono/format/strftime/index.html) format |
|
||||||
|
|
||||||
|
### type = "weather"
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `api_key` | string | — | OpenWeatherMap API key |
|
||||||
|
| `location` | string | — | City name or `"lat,lon"` |
|
||||||
|
| `units` | string | `"metric"` | `metric` (°C) \| `imperial` (°F) |
|
||||||
|
|
||||||
|
Weather data is fetched on start and refreshed every 30 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color format
|
||||||
|
|
||||||
|
All color fields accept hex strings: `"#rrggbb"` or `"rrggbb"`.
|
||||||
|
|
||||||
|
Examples: `"#ffffff"`, `"#ff6b35"`, `"aaaaaa"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[screensaver]
|
||||||
|
type = "text"
|
||||||
|
content = "scr33ny"
|
||||||
|
font_size = 96.0
|
||||||
|
color = "#ffffff"
|
||||||
|
animation = "float"
|
||||||
|
|
||||||
|
[display]
|
||||||
|
monitor = 0
|
||||||
|
burn_shift = 2
|
||||||
|
shift_interval = 120
|
||||||
|
|
||||||
|
[daemon]
|
||||||
|
enabled = false
|
||||||
|
idle_timeout = 300
|
||||||
|
|
||||||
|
[[widgets]]
|
||||||
|
type = "clock"
|
||||||
|
style = "digital"
|
||||||
|
font_size = 72.0
|
||||||
|
color = "#ffffff"
|
||||||
|
show_seconds = true
|
||||||
|
|
||||||
|
[widgets.position]
|
||||||
|
x = "center"
|
||||||
|
y = "center"
|
||||||
|
|
||||||
|
[[widgets]]
|
||||||
|
type = "date"
|
||||||
|
font_size = 28.0
|
||||||
|
color = "#888888"
|
||||||
|
format = "%A, %B %d"
|
||||||
|
|
||||||
|
[widgets.position]
|
||||||
|
x = "center"
|
||||||
|
y = 62.0
|
||||||
|
```
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
use std::num::NonZeroU32;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use winit::{
|
||||||
|
event::{ElementState, Event, KeyEvent, WindowEvent},
|
||||||
|
event_loop::{ControlFlow, EventLoop},
|
||||||
|
keyboard::{KeyCode, PhysicalKey},
|
||||||
|
window::{Fullscreen, WindowBuilder},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
canvas::{Canvas, load_system_font},
|
||||||
|
config::{Config, ScreensaverConfig, WidgetConfig},
|
||||||
|
screensaver::{BlankScreensaver, GifScreensaver, ImageScreensaver, Screensaver, TextScreensaver},
|
||||||
|
widget::{ClockWidget, DateWidget, WeatherWidget, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Anti-burn-in pixel shift ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct BurnShift {
|
||||||
|
x: i32, y: i32,
|
||||||
|
elapsed: f64, interval: f64,
|
||||||
|
amount: i32, step: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BurnShift {
|
||||||
|
fn new(amount: u32, interval: u64) -> Self {
|
||||||
|
Self { x: 0, y: 0, elapsed: 0.0, interval: interval as f64, amount: amount as i32, step: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, dt: f64) {
|
||||||
|
self.elapsed += dt;
|
||||||
|
if self.elapsed >= self.interval {
|
||||||
|
self.elapsed = 0.0;
|
||||||
|
self.step = (self.step + 1) % 4;
|
||||||
|
(self.x, self.y) = match self.step {
|
||||||
|
0 => (0, 0),
|
||||||
|
1 => (self.amount, 0),
|
||||||
|
2 => (self.amount, self.amount),
|
||||||
|
_ => (0, self.amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn run(config: Config, monitor_idx: usize) -> Result<()> {
|
||||||
|
let font = load_system_font().ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"No system font found. Install DejaVu, Noto, or Liberation fonts."
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let mut screensaver: Box<dyn Screensaver> = build_screensaver(&config, font.clone())?;
|
||||||
|
let mut widgets: Vec<Box<dyn Widget>> = build_widgets(&config, font)?;
|
||||||
|
|
||||||
|
let event_loop = EventLoop::new()?;
|
||||||
|
event_loop.set_control_flow(ControlFlow::Poll);
|
||||||
|
|
||||||
|
let monitors: Vec<_> = event_loop.available_monitors().collect();
|
||||||
|
let monitor = monitors.get(monitor_idx).or_else(|| monitors.first()).cloned();
|
||||||
|
|
||||||
|
// Rc<Window> so softbuffer can hold a reference without borrowing stack var
|
||||||
|
let window = Rc::new(
|
||||||
|
WindowBuilder::new()
|
||||||
|
.with_title("scr33ny")
|
||||||
|
.with_fullscreen(Some(Fullscreen::Borderless(monitor)))
|
||||||
|
.with_decorations(false)
|
||||||
|
.with_resizable(false)
|
||||||
|
.build(&event_loop)?
|
||||||
|
);
|
||||||
|
window.set_cursor_visible(false);
|
||||||
|
|
||||||
|
let context = softbuffer::Context::new(Rc::clone(&window))
|
||||||
|
.map_err(|e| anyhow::anyhow!("softbuffer context: {e}"))?;
|
||||||
|
let mut surface = softbuffer::Surface::new(&context, Rc::clone(&window))
|
||||||
|
.map_err(|e| anyhow::anyhow!("softbuffer surface: {e}"))?;
|
||||||
|
let mut canvas = Canvas::new(window.inner_size().width, window.inner_size().height);
|
||||||
|
|
||||||
|
let mut last_frame = Instant::now();
|
||||||
|
let mut burn = BurnShift::new(config.display.burn_shift, config.display.shift_interval);
|
||||||
|
|
||||||
|
event_loop.run(move |event, elwt| {
|
||||||
|
match event {
|
||||||
|
Event::WindowEvent { event, window_id } if window_id == window.id() => {
|
||||||
|
match event {
|
||||||
|
WindowEvent::CloseRequested => elwt.exit(),
|
||||||
|
|
||||||
|
WindowEvent::KeyboardInput { event: KeyEvent {
|
||||||
|
physical_key: PhysicalKey::Code(KeyCode::KeyQ),
|
||||||
|
state: ElementState::Pressed, ..
|
||||||
|
}, .. } => elwt.exit(),
|
||||||
|
|
||||||
|
WindowEvent::KeyboardInput { event: KeyEvent {
|
||||||
|
physical_key: PhysicalKey::Code(KeyCode::Escape),
|
||||||
|
state: ElementState::Pressed, ..
|
||||||
|
}, .. } => elwt.exit(),
|
||||||
|
|
||||||
|
WindowEvent::Resized(size) => {
|
||||||
|
canvas.resize(size.width, size.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::RedrawRequested => {
|
||||||
|
let now = Instant::now();
|
||||||
|
let dt = now.duration_since(last_frame).as_secs_f64().min(0.1);
|
||||||
|
last_frame = now;
|
||||||
|
|
||||||
|
burn.update(dt);
|
||||||
|
canvas.clear();
|
||||||
|
|
||||||
|
screensaver.render(&mut canvas, dt);
|
||||||
|
for widget in &mut widgets {
|
||||||
|
widget.render(&mut canvas, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(w), Some(h)) = (
|
||||||
|
NonZeroU32::new(canvas.width),
|
||||||
|
NonZeroU32::new(canvas.height),
|
||||||
|
) {
|
||||||
|
if surface.resize(w, h).is_ok() {
|
||||||
|
if let Ok(mut buf) = surface.buffer_mut() {
|
||||||
|
apply_shifted(&canvas, &mut buf, burn.x, burn.y);
|
||||||
|
let _ = buf.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::AboutToWait => window.request_redraw(),
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy canvas pixels to surface buffer with burn-in shift
|
||||||
|
fn apply_shifted(canvas: &Canvas, buf: &mut [u32], sx: i32, sy: i32) {
|
||||||
|
let w = canvas.width as i32;
|
||||||
|
let h = canvas.height as i32;
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let src_x = x - sx;
|
||||||
|
let src_y = y - sy;
|
||||||
|
buf[(y * w + x) as usize] = if src_x >= 0 && src_y >= 0 && src_x < w && src_y < h {
|
||||||
|
canvas.buf[(src_y * w + src_x) as usize]
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screensaver factory ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_screensaver(config: &Config, font: fontdue::Font) -> Result<Box<dyn Screensaver>> {
|
||||||
|
Ok(match &config.screensaver {
|
||||||
|
ScreensaverConfig::Blank => Box::new(BlankScreensaver),
|
||||||
|
|
||||||
|
ScreensaverConfig::Gif { path, scale } => {
|
||||||
|
let p = shellexpand::tilde(path).to_string();
|
||||||
|
Box::new(GifScreensaver::load(std::path::Path::new(&p), scale.clone())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreensaverConfig::Image { path, scale } => {
|
||||||
|
let p = shellexpand::tilde(path).to_string();
|
||||||
|
Box::new(ImageScreensaver::load(std::path::Path::new(&p), scale.clone())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreensaverConfig::Text { content, font_size, color, animation } => {
|
||||||
|
Box::new(TextScreensaver::new(
|
||||||
|
content.clone(), font, *font_size, color, animation.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widget factory ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_widgets(config: &Config, font: fontdue::Font) -> Result<Vec<Box<dyn Widget>>> {
|
||||||
|
let mut out: Vec<Box<dyn Widget>> = vec![];
|
||||||
|
for wc in &config.widgets {
|
||||||
|
let w: Box<dyn Widget> = match wc {
|
||||||
|
WidgetConfig::Clock { position, color, font_size, style, show_seconds } =>
|
||||||
|
Box::new(ClockWidget::new(font.clone(), *font_size, color, position.clone(), style.clone(), *show_seconds)),
|
||||||
|
WidgetConfig::Date { position, color, font_size, format } =>
|
||||||
|
Box::new(DateWidget::new(font.clone(), *font_size, color, position.clone(), format.clone())),
|
||||||
|
WidgetConfig::Weather { position, api_key, location, units, color, font_size } =>
|
||||||
|
Box::new(WeatherWidget::new(font.clone(), *font_size, color, position.clone(), api_key.clone(), location.clone(), units.clone())),
|
||||||
|
};
|
||||||
|
out.push(w);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
use fontdue::{Font, FontSettings};
|
||||||
|
use image::RgbaImage;
|
||||||
|
|
||||||
|
// ── Canvas ────────────────────────────────────────────────────────────────────
|
||||||
|
// Pixel buffer: each u32 = 0x00RRGGBB (softbuffer format)
|
||||||
|
|
||||||
|
pub struct Canvas {
|
||||||
|
pub buf: Vec<u32>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Canvas {
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
Self { buf: vec![0u32; (width * height) as usize], width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
self.buf.resize((width * height) as usize, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear to black (OLED off)
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.buf.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn set_pixel(&mut self, x: i32, y: i32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
|
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 { return; }
|
||||||
|
let idx = y as usize * self.width as usize + x as usize;
|
||||||
|
if a == 255 {
|
||||||
|
self.buf[idx] = rgb(r, g, b);
|
||||||
|
} else if a > 0 {
|
||||||
|
// alpha-blend over current pixel
|
||||||
|
let dst = self.buf[idx];
|
||||||
|
let dr = ((dst >> 16) & 0xff) as u8;
|
||||||
|
let dg = ((dst >> 8) & 0xff) as u8;
|
||||||
|
let db = (dst & 0xff) as u8;
|
||||||
|
let af = a as u32;
|
||||||
|
let or_ = ((r as u32 * af + dr as u32 * (255 - af)) / 255) as u8;
|
||||||
|
let og = ((g as u32 * af + dg as u32 * (255 - af)) / 255) as u8;
|
||||||
|
let ob = ((b as u32 * af + db as u32 * (255 - af)) / 255) as u8;
|
||||||
|
self.buf[idx] = rgb(or_, og, ob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fill_rect(&mut self, x: i32, y: i32, w: u32, h: u32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
|
for dy in 0..h as i32 {
|
||||||
|
for dx in 0..w as i32 {
|
||||||
|
self.set_pixel(x + dx, y + dy, r, g, b, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_circle(&mut self, cx: i32, cy: i32, radius: i32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
|
for dy in -radius..=radius {
|
||||||
|
for dx in -radius..=radius {
|
||||||
|
if dx * dx + dy * dy <= radius * radius {
|
||||||
|
self.set_pixel(cx + dx, cy + dy, r, g, b, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_ring(&mut self, cx: i32, cy: i32, outer: i32, inner: i32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
|
for dy in -outer..=outer {
|
||||||
|
for dx in -outer..=outer {
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
if d2 <= outer * outer && d2 >= inner * inner {
|
||||||
|
self.set_pixel(cx + dx, cy + dy, r, g, b, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a line using Bresenham's algorithm
|
||||||
|
pub fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, thickness: u32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
|
let dx = (x1 - x0).abs();
|
||||||
|
let dy = (y1 - y0).abs();
|
||||||
|
let sx = if x0 < x1 { 1 } else { -1 };
|
||||||
|
let sy = if y0 < y1 { 1 } else { -1 };
|
||||||
|
let mut err = dx - dy;
|
||||||
|
let (mut x, mut y) = (x0, y0);
|
||||||
|
let t = thickness as i32;
|
||||||
|
loop {
|
||||||
|
for ty in -(t / 2)..=(t / 2) {
|
||||||
|
for tx in -(t / 2)..=(t / 2) {
|
||||||
|
self.set_pixel(x + tx, y + ty, r, g, b, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x == x1 && y == y1 { break; }
|
||||||
|
let e2 = 2 * err;
|
||||||
|
if e2 > -dy { err -= dy; x += sx; }
|
||||||
|
if e2 < dx { err += dx; y += sy; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw an RGBA image at (x, y)
|
||||||
|
pub fn draw_image(&mut self, x: i32, y: i32, img: &RgbaImage) {
|
||||||
|
for (px, py, pixel) in img.enumerate_pixels() {
|
||||||
|
self.set_pixel(
|
||||||
|
x + px as i32,
|
||||||
|
y + py as i32,
|
||||||
|
pixel[0], pixel[1], pixel[2], pixel[3],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw an RGBA image scaled to fill (w, h) at (x, y)
|
||||||
|
pub fn draw_image_scaled(&mut self, x: i32, y: i32, w: u32, h: u32, img: &RgbaImage) {
|
||||||
|
let sw = img.width();
|
||||||
|
let sh = img.height();
|
||||||
|
for dy in 0..h {
|
||||||
|
for dx in 0..w {
|
||||||
|
let sx = (dx as f32 / w as f32 * sw as f32) as u32;
|
||||||
|
let sy = (dy as f32 / h as f32 * sh as f32) as u32;
|
||||||
|
let sx = sx.min(sw - 1);
|
||||||
|
let sy = sy.min(sh - 1);
|
||||||
|
let p = img.get_pixel(sx, sy);
|
||||||
|
self.set_pixel(x + dx as i32, y + dy as i32, p[0], p[1], p[2], p[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render text, returns (pixel_width, pixel_height)
|
||||||
|
pub fn draw_text(&mut self, x: i32, y: i32, text: &str, font: &Font, size: f32, r: u8, g: u8, b: u8) -> (u32, u32) {
|
||||||
|
let mut cursor = x;
|
||||||
|
let mut max_h = 0u32;
|
||||||
|
for ch in text.chars() {
|
||||||
|
let (metrics, bitmap) = font.rasterize(ch, size);
|
||||||
|
let bx = cursor + metrics.xmin;
|
||||||
|
let by = y - metrics.height as i32 - metrics.ymin;
|
||||||
|
for py in 0..metrics.height {
|
||||||
|
for px in 0..metrics.width {
|
||||||
|
let cov = bitmap[py * metrics.width + px];
|
||||||
|
if cov > 0 {
|
||||||
|
self.set_pixel(bx + px as i32, by + py as i32, r, g, b, cov);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor += metrics.advance_width as i32;
|
||||||
|
max_h = max_h.max(metrics.height as u32);
|
||||||
|
}
|
||||||
|
((cursor - x) as u32, max_h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure text without rendering
|
||||||
|
pub fn measure_text(text: &str, font: &Font, size: f32) -> (u32, u32) {
|
||||||
|
let mut w = 0u32;
|
||||||
|
let mut h = 0u32;
|
||||||
|
for ch in text.chars() {
|
||||||
|
let m = font.metrics(ch, size);
|
||||||
|
w += m.advance_width as u32;
|
||||||
|
h = h.max(m.height as u32);
|
||||||
|
}
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw rounded rectangle (Material card)
|
||||||
|
pub fn draw_rounded_rect(&mut self, x: i32, y: i32, w: u32, h: u32, radius: u32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
|
let rad = radius as i32;
|
||||||
|
// fill center
|
||||||
|
self.fill_rect(x + rad, y, w - 2 * radius, h, r, g, b, a);
|
||||||
|
self.fill_rect(x, y + rad, w, h - 2 * radius, r, g, b, a);
|
||||||
|
// four corner circles
|
||||||
|
let corners = [
|
||||||
|
(x + rad, y + rad),
|
||||||
|
(x + w as i32 - rad - 1, y + rad),
|
||||||
|
(x + rad, y + h as i32 - rad - 1),
|
||||||
|
(x + w as i32 - rad - 1, y + h as i32 - rad - 1),
|
||||||
|
];
|
||||||
|
for (cx, cy) in corners {
|
||||||
|
self.draw_circle(cx, cy, rad, r, g, b, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_to(&self, target: &mut [u32]) {
|
||||||
|
target.copy_from_slice(&self.buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn rgb(r: u8, g: u8, b: u8) -> u32 {
|
||||||
|
((r as u32) << 16) | ((g as u32) << 8) | b as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Font loading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Try common system font paths, return the first that loads
|
||||||
|
pub fn load_system_font() -> Option<Font> {
|
||||||
|
let candidates = [
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/TTF/Hack-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||||
|
"/usr/share/fonts/OTF/SFNSDisplay.otf",
|
||||||
|
"/usr/share/fonts/TTF/Ubuntu-R.ttf",
|
||||||
|
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
|
||||||
|
];
|
||||||
|
|
||||||
|
// also check user-local fonts
|
||||||
|
let home_fonts = dirs::home_dir()
|
||||||
|
.map(|h| h.join(".local/share/fonts"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let user_candidates: Vec<String> = if home_fonts.exists() {
|
||||||
|
walkdir_fonts(&home_fonts)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
for path in candidates.iter().map(|s| s.to_string()).chain(user_candidates) {
|
||||||
|
if let Ok(data) = std::fs::read(&path) {
|
||||||
|
if let Ok(font) = Font::from_bytes(data.as_slice(), FontSettings::default()) {
|
||||||
|
log::info!("loaded font: {}", path);
|
||||||
|
return Some(font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walkdir_fonts(dir: &std::path::Path) -> Vec<String> {
|
||||||
|
let mut out = vec![];
|
||||||
|
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if let Some(ext) = p.extension() {
|
||||||
|
if matches!(ext.to_str(), Some("ttf") | Some("otf")) {
|
||||||
|
out.push(p.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
// ── Top-level ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub screensaver: ScreensaverConfig,
|
||||||
|
pub daemon: DaemonConfig,
|
||||||
|
pub widgets: Vec<WidgetConfig>,
|
||||||
|
pub display: DisplayConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
screensaver: ScreensaverConfig::Blank,
|
||||||
|
daemon: DaemonConfig::default(),
|
||||||
|
widgets: vec![],
|
||||||
|
display: DisplayConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screensaver ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum ScreensaverConfig {
|
||||||
|
Blank,
|
||||||
|
Gif { path: String, #[serde(default)] scale: Scale },
|
||||||
|
Image { path: String, #[serde(default)] scale: Scale },
|
||||||
|
Text {
|
||||||
|
content: String,
|
||||||
|
#[serde(default = "default_font_size")] font_size: f32,
|
||||||
|
#[serde(default = "default_white")] color: String,
|
||||||
|
#[serde(default)] animation: TextAnimation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Scale {
|
||||||
|
#[default] Fit,
|
||||||
|
Fill,
|
||||||
|
Original,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TextAnimation {
|
||||||
|
#[default] Float,
|
||||||
|
Typewriter,
|
||||||
|
Fade,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widgets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum WidgetConfig {
|
||||||
|
Clock {
|
||||||
|
#[serde(default)] position: Position,
|
||||||
|
#[serde(default = "default_white")] color: String,
|
||||||
|
#[serde(default = "default_font_size")] font_size: f32,
|
||||||
|
#[serde(default)] style: ClockStyle,
|
||||||
|
#[serde(default)] show_seconds: bool,
|
||||||
|
},
|
||||||
|
Date {
|
||||||
|
#[serde(default)] position: Position,
|
||||||
|
#[serde(default = "default_white")] color: String,
|
||||||
|
#[serde(default = "default_font_size")] font_size: f32,
|
||||||
|
#[serde(default = "default_date_fmt")] format: String,
|
||||||
|
},
|
||||||
|
Weather {
|
||||||
|
#[serde(default)] position: Position,
|
||||||
|
api_key: String,
|
||||||
|
location: String,
|
||||||
|
#[serde(default)] units: WeatherUnits,
|
||||||
|
#[serde(default = "default_white")] color: String,
|
||||||
|
#[serde(default = "default_font_size")] font_size: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ClockStyle {
|
||||||
|
#[default] Digital,
|
||||||
|
Analog,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum WeatherUnits {
|
||||||
|
#[default] Metric,
|
||||||
|
Imperial,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positioning ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct Position {
|
||||||
|
#[serde(default = "default_center")] pub x: Anchor,
|
||||||
|
#[serde(default = "default_center")] pub y: Anchor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Position {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { x: default_center(), y: default_center() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Anchor {
|
||||||
|
Named(NamedAnchor),
|
||||||
|
Percent(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum NamedAnchor {
|
||||||
|
Left, Center, Right, Top, Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Anchor {
|
||||||
|
pub fn resolve(&self, total: u32, size: u32) -> i32 {
|
||||||
|
let v = match self {
|
||||||
|
Anchor::Named(NamedAnchor::Left | NamedAnchor::Top) => 0.0,
|
||||||
|
Anchor::Named(NamedAnchor::Center) => 0.5,
|
||||||
|
Anchor::Named(NamedAnchor::Right | NamedAnchor::Bottom) => 1.0,
|
||||||
|
Anchor::Percent(p) => *p / 100.0,
|
||||||
|
};
|
||||||
|
((total as f32 * v) as i32) - (size as i32 / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DisplayConfig {
|
||||||
|
/// Monitor index (0 = primary)
|
||||||
|
pub monitor: usize,
|
||||||
|
/// Anti-burn-in pixel shift in pixels, applied every shift_interval seconds
|
||||||
|
pub burn_shift: u32,
|
||||||
|
pub shift_interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DisplayConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { monitor: 0, burn_shift: 2, shift_interval: 120 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daemon ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DaemonConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Idle timeout in seconds before screensaver activates
|
||||||
|
pub idle_timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DaemonConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { enabled: false, idle_timeout: 300 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn load(path: Option<&Path>) -> Result<Config> {
|
||||||
|
let path = match path {
|
||||||
|
Some(p) => p.to_path_buf(),
|
||||||
|
None => default_config_path(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
log::info!("no config at {}, using defaults", path.display());
|
||||||
|
return Ok(Config::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("reading config {}", path.display()))?;
|
||||||
|
|
||||||
|
toml::from_str(&text)
|
||||||
|
.with_context(|| format!("parsing config {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("scr33ny")
|
||||||
|
.join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn default_font_size() -> f32 { 48.0 }
|
||||||
|
fn default_white() -> String { "#ffffff".to_string() }
|
||||||
|
fn default_date_fmt() -> String { "%A, %B %d".to_string() }
|
||||||
|
fn default_center() -> Anchor { Anchor::Named(NamedAnchor::Center) }
|
||||||
|
|
||||||
|
pub fn parse_hex_color(hex: &str) -> (u8, u8, u8) {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
let n = u32::from_str_radix(hex, 16).unwrap_or(0xffffff);
|
||||||
|
(((n >> 16) & 0xff) as u8, ((n >> 8) & 0xff) as u8, (n & 0xff) as u8)
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
mod app;
|
||||||
|
mod canvas;
|
||||||
|
mod config;
|
||||||
|
mod screensaver;
|
||||||
|
mod widget;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "scr33ny", about = "OLED screensaver with Material 3 widgets", version)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to config file (default: ~/.config/scr33ny/config.toml)
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Monitor index to display on (0 = primary)
|
||||||
|
#[arg(short, long, default_value_t = 0)]
|
||||||
|
monitor: usize,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Print the default config path
|
||||||
|
ConfigPath,
|
||||||
|
/// List all connected monitors
|
||||||
|
Monitors,
|
||||||
|
/// Start the idle daemon (auto-launch on inactivity)
|
||||||
|
Daemon,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::ConfigPath) => {
|
||||||
|
println!("{}", config::default_config_path().display());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(Commands::Monitors) => {
|
||||||
|
print_monitors();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(Commands::Daemon) => {
|
||||||
|
let cfg = config::load(cli.config.as_deref())?;
|
||||||
|
return run_daemon(cfg);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = config::load(cli.config.as_deref())?;
|
||||||
|
|
||||||
|
// Tokio runtime for async widgets (weather fetch)
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(1)
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let _guard = rt.enter();
|
||||||
|
|
||||||
|
app::run(cfg, cli.monitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_monitors() {
|
||||||
|
use winit::event_loop::EventLoop;
|
||||||
|
let el = EventLoop::new().unwrap();
|
||||||
|
for (i, m) in el.available_monitors().enumerate() {
|
||||||
|
let size = m.size();
|
||||||
|
let name = m.name().unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
println!("[{i}] {name} {}×{}", size.width, size.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_daemon(cfg: config::Config) -> Result<()> {
|
||||||
|
if !cfg.daemon.enabled {
|
||||||
|
anyhow::bail!(
|
||||||
|
"daemon.enabled is false in config — set it to true to use daemon mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = cfg.daemon.idle_timeout;
|
||||||
|
log::info!("daemon: idle timeout = {timeout}s");
|
||||||
|
|
||||||
|
// Prefer swayidle (standard Wayland idle tool)
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.to_str().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| "scr33ny".to_string());
|
||||||
|
|
||||||
|
let result = std::process::Command::new("swayidle")
|
||||||
|
.args([
|
||||||
|
"-w",
|
||||||
|
"timeout", &timeout.to_string(), &exe,
|
||||||
|
"resume", "killall scr33ny",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(s) if s.success() => Ok(()),
|
||||||
|
Ok(s) => anyhow::bail!("swayidle exited: {s}"),
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("swayidle not found — using simple sleep loop");
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(timeout));
|
||||||
|
let _ = std::process::Command::new(&exe).status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::canvas::Canvas;
|
||||||
|
use super::Screensaver;
|
||||||
|
|
||||||
|
/// Pure black — all OLED pixels off, zero power draw
|
||||||
|
pub struct BlankScreensaver;
|
||||||
|
|
||||||
|
impl Screensaver for BlankScreensaver {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, _dt: f64) {
|
||||||
|
canvas.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use image::{AnimationDecoder, codecs::gif::GifDecoder, RgbaImage};
|
||||||
|
use std::{fs::File, io::BufReader, path::Path, time::Duration};
|
||||||
|
|
||||||
|
use crate::{canvas::Canvas, config::Scale};
|
||||||
|
use super::Screensaver;
|
||||||
|
|
||||||
|
pub struct GifFrame {
|
||||||
|
pub image: RgbaImage,
|
||||||
|
pub delay: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GifScreensaver {
|
||||||
|
frames: Vec<GifFrame>,
|
||||||
|
current: usize,
|
||||||
|
elapsed: f64,
|
||||||
|
scale: Scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GifScreensaver {
|
||||||
|
pub fn load(path: &Path, scale: Scale) -> Result<Self> {
|
||||||
|
let file = BufReader::new(
|
||||||
|
File::open(path).with_context(|| format!("opening GIF {}", path.display()))?
|
||||||
|
);
|
||||||
|
let decoder = GifDecoder::new(file)
|
||||||
|
.context("decoding GIF header")?;
|
||||||
|
let frames = decoder.into_frames()
|
||||||
|
.collect_frames()
|
||||||
|
.context("collecting GIF frames")?;
|
||||||
|
|
||||||
|
let frames = frames
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| {
|
||||||
|
let delay_ms = f.delay().numer_denom_ms();
|
||||||
|
let delay = Duration::from_millis(
|
||||||
|
(delay_ms.0 as u64 * 1000) / delay_ms.1.max(1) as u64,
|
||||||
|
);
|
||||||
|
GifFrame { image: f.into_buffer(), delay }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Self { frames, current: 0, elapsed: 0.0, scale })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screensaver for GifScreensaver {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, dt: f64) {
|
||||||
|
if self.frames.is_empty() { return; }
|
||||||
|
|
||||||
|
canvas.clear();
|
||||||
|
|
||||||
|
let frame = &self.frames[self.current];
|
||||||
|
let (iw, ih) = (frame.image.width(), frame.image.height());
|
||||||
|
let (cw, ch) = (canvas.width, canvas.height);
|
||||||
|
|
||||||
|
match self.scale {
|
||||||
|
Scale::Original => {
|
||||||
|
let x = (cw as i32 - iw as i32) / 2;
|
||||||
|
let y = (ch as i32 - ih as i32) / 2;
|
||||||
|
canvas.draw_image(x, y, &frame.image);
|
||||||
|
}
|
||||||
|
Scale::Fit => {
|
||||||
|
let scale = (cw as f32 / iw as f32).min(ch as f32 / ih as f32);
|
||||||
|
let nw = (iw as f32 * scale) as u32;
|
||||||
|
let nh = (ih as f32 * scale) as u32;
|
||||||
|
let x = (cw as i32 - nw as i32) / 2;
|
||||||
|
let y = (ch as i32 - nh as i32) / 2;
|
||||||
|
canvas.draw_image_scaled(x, y, nw, nh, &frame.image);
|
||||||
|
}
|
||||||
|
Scale::Fill => {
|
||||||
|
let scale = (cw as f32 / iw as f32).max(ch as f32 / ih as f32);
|
||||||
|
let nw = (iw as f32 * scale) as u32;
|
||||||
|
let nh = (ih as f32 * scale) as u32;
|
||||||
|
let x = (cw as i32 - nw as i32) / 2;
|
||||||
|
let y = (ch as i32 - nh as i32) / 2;
|
||||||
|
canvas.draw_image_scaled(x, y, nw, nh, &frame.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.elapsed += dt;
|
||||||
|
if self.elapsed >= frame.delay.as_secs_f64() {
|
||||||
|
self.elapsed = 0.0;
|
||||||
|
self.current = (self.current + 1) % self.frames.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use image::RgbaImage;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{canvas::Canvas, config::Scale};
|
||||||
|
use super::Screensaver;
|
||||||
|
|
||||||
|
pub struct ImageScreensaver {
|
||||||
|
image: RgbaImage,
|
||||||
|
scale: Scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageScreensaver {
|
||||||
|
pub fn load(path: &Path, scale: Scale) -> Result<Self> {
|
||||||
|
let img = image::open(path)
|
||||||
|
.with_context(|| format!("opening image {}", path.display()))?
|
||||||
|
.to_rgba8();
|
||||||
|
Ok(Self { image: img, scale })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screensaver for ImageScreensaver {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, _dt: f64) {
|
||||||
|
canvas.clear();
|
||||||
|
let (iw, ih) = (self.image.width(), self.image.height());
|
||||||
|
let (cw, ch) = (canvas.width, canvas.height);
|
||||||
|
|
||||||
|
match self.scale {
|
||||||
|
Scale::Original => {
|
||||||
|
let x = (cw as i32 - iw as i32) / 2;
|
||||||
|
let y = (ch as i32 - ih as i32) / 2;
|
||||||
|
canvas.draw_image(x, y, &self.image);
|
||||||
|
}
|
||||||
|
Scale::Fit => {
|
||||||
|
let scale = (cw as f32 / iw as f32).min(ch as f32 / ih as f32);
|
||||||
|
let nw = (iw as f32 * scale) as u32;
|
||||||
|
let nh = (ih as f32 * scale) as u32;
|
||||||
|
let x = (cw as i32 - nw as i32) / 2;
|
||||||
|
let y = (ch as i32 - nh as i32) / 2;
|
||||||
|
canvas.draw_image_scaled(x, y, nw, nh, &self.image);
|
||||||
|
}
|
||||||
|
Scale::Fill => {
|
||||||
|
let scale = (cw as f32 / iw as f32).max(ch as f32 / ih as f32);
|
||||||
|
let nw = (iw as f32 * scale) as u32;
|
||||||
|
let nh = (ih as f32 * scale) as u32;
|
||||||
|
let x = (cw as i32 - nw as i32) / 2;
|
||||||
|
let y = (ch as i32 - nh as i32) / 2;
|
||||||
|
canvas.draw_image_scaled(x, y, nw, nh, &self.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
mod blank;
|
||||||
|
mod gif;
|
||||||
|
mod image_ss;
|
||||||
|
mod text;
|
||||||
|
|
||||||
|
pub use blank::BlankScreensaver;
|
||||||
|
pub use gif::GifScreensaver;
|
||||||
|
pub use image_ss::ImageScreensaver;
|
||||||
|
pub use text::TextScreensaver;
|
||||||
|
|
||||||
|
use crate::canvas::Canvas;
|
||||||
|
|
||||||
|
pub trait Screensaver: Send {
|
||||||
|
/// Called every frame. `dt` is elapsed seconds since last frame.
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, dt: f64);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use fontdue::Font;
|
||||||
|
use crate::{canvas::Canvas, config::{TextAnimation, parse_hex_color}};
|
||||||
|
use super::Screensaver;
|
||||||
|
|
||||||
|
pub struct TextScreensaver {
|
||||||
|
content: String,
|
||||||
|
font: Font,
|
||||||
|
font_size: f32,
|
||||||
|
color: (u8, u8, u8),
|
||||||
|
animation: TextAnimation,
|
||||||
|
time: f64,
|
||||||
|
// Lissajous orbit for anti-burn-in floating
|
||||||
|
lx: f64,
|
||||||
|
ly: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextScreensaver {
|
||||||
|
pub fn new(content: String, font: Font, font_size: f32, color: &str, animation: TextAnimation) -> Self {
|
||||||
|
let color = parse_hex_color(color);
|
||||||
|
// random-ish starting phase so it doesn't start center every time
|
||||||
|
Self {
|
||||||
|
content,
|
||||||
|
font,
|
||||||
|
font_size,
|
||||||
|
color,
|
||||||
|
animation,
|
||||||
|
time: 0.0,
|
||||||
|
lx: 0.0,
|
||||||
|
ly: std::f64::consts::FRAC_PI_2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screensaver for TextScreensaver {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, dt: f64) {
|
||||||
|
canvas.clear();
|
||||||
|
self.time += dt;
|
||||||
|
|
||||||
|
let (cw, ch) = (canvas.width as f32, canvas.height as f32);
|
||||||
|
let (tw, th) = Canvas::measure_text(&self.content, &self.font, self.font_size);
|
||||||
|
|
||||||
|
let (ox, oy) = match self.animation {
|
||||||
|
TextAnimation::Float => {
|
||||||
|
// Lissajous curve: a=3, b=2 gives a nice non-repeating pattern
|
||||||
|
let margin_x = (cw * 0.3) as f64;
|
||||||
|
let margin_y = (ch * 0.3) as f64;
|
||||||
|
let cx = cw as f64 / 2.0 - tw as f64 / 2.0;
|
||||||
|
let cy = ch as f64 / 2.0 - th as f64 / 2.0;
|
||||||
|
let x = cx + margin_x * (3.0 * self.time * 0.05 + self.lx).sin();
|
||||||
|
let y = cy + margin_y * (2.0 * self.time * 0.05 + self.ly).sin();
|
||||||
|
(x as i32, y as i32)
|
||||||
|
}
|
||||||
|
TextAnimation::Typewriter => {
|
||||||
|
let cx = (cw / 2.0 - tw as f32 / 2.0) as i32;
|
||||||
|
let cy = (ch / 2.0 - th as f32 / 2.0) as i32;
|
||||||
|
(cx, cy)
|
||||||
|
}
|
||||||
|
TextAnimation::Fade => {
|
||||||
|
let cx = (cw / 2.0 - tw as f32 / 2.0) as i32;
|
||||||
|
let cy = (ch / 2.0 - th as f32 / 2.0) as i32;
|
||||||
|
(cx, cy)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (r, g, b) = self.color;
|
||||||
|
|
||||||
|
match self.animation {
|
||||||
|
TextAnimation::Float => {
|
||||||
|
canvas.draw_text(ox, oy + th as i32, &self.content, &self.font, self.font_size, r, g, b);
|
||||||
|
}
|
||||||
|
TextAnimation::Typewriter => {
|
||||||
|
// show chars one by one, cycling every 3 seconds per char
|
||||||
|
let chars: Vec<char> = self.content.chars().collect();
|
||||||
|
let visible = ((self.time * 2.0) as usize % (chars.len() * 2 + 4)).min(chars.len());
|
||||||
|
let partial: String = chars[..visible].iter().collect();
|
||||||
|
canvas.draw_text(ox, oy + th as i32, &partial, &self.font, self.font_size, r, g, b);
|
||||||
|
// blinking cursor
|
||||||
|
if (self.time * 2.0) as u32 % 2 == 0 && visible < chars.len() {
|
||||||
|
let (pw, _) = Canvas::measure_text(&partial, &self.font, self.font_size);
|
||||||
|
canvas.fill_rect(ox + pw as i32, oy, 3, th, r, g, b, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextAnimation::Fade => {
|
||||||
|
// fade in/out cycle: 3s in, 1s hold, 3s out, 1s black
|
||||||
|
let cycle = 8.0_f64;
|
||||||
|
let t = self.time % cycle;
|
||||||
|
let alpha: u8 = if t < 3.0 {
|
||||||
|
(t / 3.0 * 255.0) as u8
|
||||||
|
} else if t < 4.0 {
|
||||||
|
255
|
||||||
|
} else if t < 7.0 {
|
||||||
|
((7.0 - t) / 3.0 * 255.0) as u8
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
// draw to temp layer
|
||||||
|
let ar = (r as u32 * alpha as u32 / 255) as u8;
|
||||||
|
let ag = (g as u32 * alpha as u32 / 255) as u8;
|
||||||
|
let ab = (b as u32 * alpha as u32 / 255) as u8;
|
||||||
|
canvas.draw_text(ox, oy + th as i32, &self.content, &self.font, self.font_size, ar, ag, ab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use chrono::Local;
|
||||||
|
use fontdue::Font;
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
canvas::Canvas,
|
||||||
|
config::{ClockStyle, Position, parse_hex_color},
|
||||||
|
};
|
||||||
|
use super::Widget;
|
||||||
|
|
||||||
|
pub struct ClockWidget {
|
||||||
|
font: Font,
|
||||||
|
font_size: f32,
|
||||||
|
color: (u8, u8, u8),
|
||||||
|
position: Position,
|
||||||
|
style: ClockStyle,
|
||||||
|
show_seconds: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClockWidget {
|
||||||
|
pub fn new(font: Font, font_size: f32, color: &str, position: Position, style: ClockStyle, show_seconds: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
font,
|
||||||
|
font_size,
|
||||||
|
color: parse_hex_color(color),
|
||||||
|
position,
|
||||||
|
style,
|
||||||
|
show_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ClockWidget {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, _dt: f64) {
|
||||||
|
let now = Local::now();
|
||||||
|
let (r, g, b) = self.color;
|
||||||
|
|
||||||
|
match self.style {
|
||||||
|
ClockStyle::Digital => render_digital(self, canvas, &now, r, g, b),
|
||||||
|
ClockStyle::Analog => render_analog(self, canvas, &now, r, g, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_digital(w: &ClockWidget, canvas: &mut Canvas, now: &chrono::DateTime<chrono::Local>, r: u8, g: u8, b: u8) {
|
||||||
|
let text = if w.show_seconds {
|
||||||
|
now.format("%H:%M:%S").to_string()
|
||||||
|
} else {
|
||||||
|
now.format("%H:%M").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tw, th) = Canvas::measure_text(&text, &w.font, w.font_size);
|
||||||
|
let x = w.position.x.resolve(canvas.width, tw);
|
||||||
|
let y = w.position.y.resolve(canvas.height, th) + th as i32;
|
||||||
|
|
||||||
|
// Material 3 card background — very slightly lifted from black
|
||||||
|
let pad = (w.font_size * 0.4) as u32;
|
||||||
|
canvas.draw_rounded_rect(
|
||||||
|
x - pad as i32,
|
||||||
|
y - th as i32 - pad as i32,
|
||||||
|
tw + pad * 2,
|
||||||
|
th + pad * 2,
|
||||||
|
(w.font_size * 0.25) as u32,
|
||||||
|
10, 10, 10, 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.draw_text(x, y, &text, &w.font, w.font_size, r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_analog(w: &ClockWidget, canvas: &mut Canvas, now: &chrono::DateTime<chrono::Local>, r: u8, g: u8, b: u8) {
|
||||||
|
use chrono::Timelike;
|
||||||
|
|
||||||
|
let size = (w.font_size * 3.0) as i32;
|
||||||
|
let cx = w.position.x.resolve(canvas.width, size as u32 * 2) + size;
|
||||||
|
let cy = w.position.y.resolve(canvas.height, size as u32 * 2) + size;
|
||||||
|
let radius = size;
|
||||||
|
|
||||||
|
// Face ring
|
||||||
|
canvas.draw_ring(cx, cy, radius, radius - 4, r / 3, g / 3, b / 3, 180);
|
||||||
|
|
||||||
|
// Hour ticks
|
||||||
|
for i in 0..12 {
|
||||||
|
let angle = i as f64 * PI / 6.0 - PI / 2.0;
|
||||||
|
let inner = (radius as f64 * 0.85) as i32;
|
||||||
|
let outer = radius - 2;
|
||||||
|
let x0 = cx + (angle.cos() * inner as f64) as i32;
|
||||||
|
let y0 = cy + (angle.sin() * inner as f64) as i32;
|
||||||
|
let x1 = cx + (angle.cos() * outer as f64) as i32;
|
||||||
|
let y1 = cy + (angle.sin() * outer as f64) as i32;
|
||||||
|
canvas.draw_line(x0, y0, x1, y1, 2, r / 2, g / 2, b / 2, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hands
|
||||||
|
let h = now.hour() as f64 % 12.0;
|
||||||
|
let m = now.minute() as f64;
|
||||||
|
let s = now.second() as f64;
|
||||||
|
|
||||||
|
let hour_angle = (h / 12.0 + m / 720.0) * 2.0 * PI - PI / 2.0;
|
||||||
|
let minute_angle = (m / 60.0 + s / 3600.0) * 2.0 * PI - PI / 2.0;
|
||||||
|
let second_angle = (s / 60.0) * 2.0 * PI - PI / 2.0;
|
||||||
|
|
||||||
|
let hr = (radius as f64 * 0.55) as i32;
|
||||||
|
let mr = (radius as f64 * 0.78) as i32;
|
||||||
|
let sr = (radius as f64 * 0.88) as i32;
|
||||||
|
|
||||||
|
// hour hand
|
||||||
|
canvas.draw_line(cx, cy,
|
||||||
|
cx + (hour_angle.cos() * hr as f64) as i32,
|
||||||
|
cy + (hour_angle.sin() * hr as f64) as i32,
|
||||||
|
4, r, g, b, 255);
|
||||||
|
// minute hand
|
||||||
|
canvas.draw_line(cx, cy,
|
||||||
|
cx + (minute_angle.cos() * mr as f64) as i32,
|
||||||
|
cy + (minute_angle.sin() * mr as f64) as i32,
|
||||||
|
3, r, g, b, 220);
|
||||||
|
// second hand (accent color — Material 3 tertiary)
|
||||||
|
if w.show_seconds {
|
||||||
|
canvas.draw_line(cx, cy,
|
||||||
|
cx + (second_angle.cos() * sr as f64) as i32,
|
||||||
|
cy + (second_angle.sin() * sr as f64) as i32,
|
||||||
|
2, 255, 120, 80, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
// center dot
|
||||||
|
canvas.draw_circle(cx, cy, 5, r, g, b, 255);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use chrono::Local;
|
||||||
|
use fontdue::Font;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
canvas::Canvas,
|
||||||
|
config::{Position, parse_hex_color},
|
||||||
|
};
|
||||||
|
use super::Widget;
|
||||||
|
|
||||||
|
pub struct DateWidget {
|
||||||
|
font: Font,
|
||||||
|
font_size: f32,
|
||||||
|
color: (u8, u8, u8),
|
||||||
|
position: Position,
|
||||||
|
format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DateWidget {
|
||||||
|
pub fn new(font: Font, font_size: f32, color: &str, position: Position, format: String) -> Self {
|
||||||
|
Self {
|
||||||
|
font,
|
||||||
|
font_size,
|
||||||
|
color: parse_hex_color(color),
|
||||||
|
position,
|
||||||
|
format,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for DateWidget {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, _dt: f64) {
|
||||||
|
let text = Local::now().format(&self.format).to_string();
|
||||||
|
let (r, g, b) = self.color;
|
||||||
|
let (tw, th) = Canvas::measure_text(&text, &self.font, self.font_size);
|
||||||
|
let x = self.position.x.resolve(canvas.width, tw);
|
||||||
|
let y = self.position.y.resolve(canvas.height, th) + th as i32;
|
||||||
|
canvas.draw_text(x, y, &text, &self.font, self.font_size, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
mod clock;
|
||||||
|
mod date;
|
||||||
|
mod weather;
|
||||||
|
|
||||||
|
pub use clock::ClockWidget;
|
||||||
|
pub use date::DateWidget;
|
||||||
|
pub use weather::WeatherWidget;
|
||||||
|
|
||||||
|
use crate::canvas::Canvas;
|
||||||
|
|
||||||
|
pub trait Widget: Send {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, dt: f64);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use fontdue::Font;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
canvas::Canvas,
|
||||||
|
config::{Position, WeatherUnits, parse_hex_color},
|
||||||
|
};
|
||||||
|
use super::Widget;
|
||||||
|
|
||||||
|
// ── OpenWeatherMap response structs ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OWMResponse {
|
||||||
|
main: OWMMain,
|
||||||
|
weather: Vec<OWMWeather>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OWMMain {
|
||||||
|
temp: f32,
|
||||||
|
humidity: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OWMWeather {
|
||||||
|
description: String,
|
||||||
|
icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widget ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct WeatherData {
|
||||||
|
temp: f32,
|
||||||
|
humidity: u32,
|
||||||
|
description: String,
|
||||||
|
icon: String,
|
||||||
|
city: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WeatherWidget {
|
||||||
|
font: Font,
|
||||||
|
font_size: f32,
|
||||||
|
color: (u8, u8, u8),
|
||||||
|
position: Position,
|
||||||
|
api_key: String,
|
||||||
|
location: String,
|
||||||
|
units: WeatherUnits,
|
||||||
|
data: Arc<Mutex<Option<WeatherData>>>,
|
||||||
|
refresh_in: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeatherWidget {
|
||||||
|
pub fn new(
|
||||||
|
font: Font,
|
||||||
|
font_size: f32,
|
||||||
|
color: &str,
|
||||||
|
position: Position,
|
||||||
|
api_key: String,
|
||||||
|
location: String,
|
||||||
|
units: WeatherUnits,
|
||||||
|
) -> Self {
|
||||||
|
let widget = Self {
|
||||||
|
font,
|
||||||
|
font_size,
|
||||||
|
color: parse_hex_color(color),
|
||||||
|
position,
|
||||||
|
api_key,
|
||||||
|
location,
|
||||||
|
units,
|
||||||
|
data: Arc::new(Mutex::new(None)),
|
||||||
|
refresh_in: 0.0,
|
||||||
|
};
|
||||||
|
widget.fetch_async();
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_async(&self) {
|
||||||
|
let data = Arc::clone(&self.data);
|
||||||
|
let api_key = self.api_key.clone();
|
||||||
|
let location = self.location.clone();
|
||||||
|
let units = match self.units { WeatherUnits::Metric => "metric", WeatherUnits::Imperial => "imperial" };
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.openweathermap.org/data/2.5/weather?q={location}&appid={api_key}&units={units}"
|
||||||
|
);
|
||||||
|
match reqwest::get(&url).await {
|
||||||
|
Ok(resp) => match resp.json::<OWMResponse>().await {
|
||||||
|
Ok(json) => {
|
||||||
|
let w = json.weather.into_iter().next();
|
||||||
|
let mut lock = data.lock().unwrap();
|
||||||
|
*lock = Some(WeatherData {
|
||||||
|
temp: json.main.temp,
|
||||||
|
humidity: json.main.humidity,
|
||||||
|
description: w.as_ref().map(|w| w.description.clone()).unwrap_or_default(),
|
||||||
|
icon: w.as_ref().map(|w| w.icon.clone()).unwrap_or_default(),
|
||||||
|
city: json.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("weather parse error: {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => log::warn!("weather fetch error: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for WeatherWidget {
|
||||||
|
fn render(&mut self, canvas: &mut Canvas, dt: f64) {
|
||||||
|
self.refresh_in -= dt;
|
||||||
|
if self.refresh_in <= 0.0 {
|
||||||
|
self.refresh_in = 1800.0; // refresh every 30 min
|
||||||
|
self.fetch_async();
|
||||||
|
}
|
||||||
|
|
||||||
|
let lock = self.data.lock().unwrap();
|
||||||
|
let (r, g, b) = self.color;
|
||||||
|
|
||||||
|
let unit_sym = match self.units { WeatherUnits::Metric => "°C", WeatherUnits::Imperial => "°F" };
|
||||||
|
|
||||||
|
let text = match lock.as_ref() {
|
||||||
|
None => "Loading weather…".to_string(),
|
||||||
|
Some(d) => format!(
|
||||||
|
"{} {} {:.0}{unit_sym} H:{:.0}% {}",
|
||||||
|
d.city,
|
||||||
|
icon_char(&d.icon),
|
||||||
|
d.temp,
|
||||||
|
d.humidity,
|
||||||
|
d.description,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tw, th) = Canvas::measure_text(&text, &self.font, self.font_size);
|
||||||
|
let pad = (self.font_size * 0.4) as u32;
|
||||||
|
let x = self.position.x.resolve(canvas.width, tw);
|
||||||
|
let y = self.position.y.resolve(canvas.height, th) + th as i32;
|
||||||
|
|
||||||
|
// card background
|
||||||
|
canvas.draw_rounded_rect(
|
||||||
|
x - pad as i32,
|
||||||
|
y - th as i32 - pad as i32,
|
||||||
|
tw + pad * 2,
|
||||||
|
th + pad * 2,
|
||||||
|
(self.font_size * 0.25) as u32,
|
||||||
|
10, 10, 10, 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.draw_text(x, y, &text, &self.font, self.font_size, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map OWM icon codes to Unicode weather symbols
|
||||||
|
fn icon_char(icon: &str) -> &'static str {
|
||||||
|
match icon {
|
||||||
|
s if s.starts_with("01") => "☀",
|
||||||
|
s if s.starts_with("02") => "⛅",
|
||||||
|
s if s.starts_with("03") => "☁",
|
||||||
|
s if s.starts_with("04") => "☁",
|
||||||
|
s if s.starts_with("09") => "🌧",
|
||||||
|
s if s.starts_with("10") => "🌦",
|
||||||
|
s if s.starts_with("11") => "⛈",
|
||||||
|
s if s.starts_with("13") => "❄",
|
||||||
|
s if s.starts_with("50") => "🌫",
|
||||||
|
_ => "~",
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user