commit 95c0373db6c9160910a02daabfbca90c967870e4 Author: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Thu Jun 18 00:17:51 2026 +0400 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7e3e4c0 --- /dev/null +++ b/Cargo.toml @@ -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 "] + +[[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 diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..f30dd1c --- /dev/null +++ b/config.toml.example @@ -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 | +# position.y: top | center | bottom | + +# 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" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ddd5352 --- /dev/null +++ b/docs/README.md @@ -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 +``` diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..d894c01 --- /dev/null +++ b/docs/config.md @@ -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 +``` diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..b543d00 --- /dev/null +++ b/src/app.rs @@ -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 = build_screensaver(&config, font.clone())?; + let mut widgets: Vec> = 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 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> { + 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>> { + let mut out: Vec> = vec![]; + for wc in &config.widgets { + let w: Box = 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) +} diff --git a/src/canvas.rs b/src/canvas.rs new file mode 100644 index 0000000..8ad76af --- /dev/null +++ b/src/canvas.rs @@ -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, + 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 { + 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 = 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 { + 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 +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2e78546 --- /dev/null +++ b/src/config.rs @@ -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, + 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 { + 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) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c748a61 --- /dev/null +++ b/src/main.rs @@ -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, + + /// Monitor index to display on (0 = primary) + #[arg(short, long, default_value_t = 0)] + monitor: usize, + + #[command(subcommand)] + command: Option, +} + +#[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(); + } + } + } +} diff --git a/src/screensaver/blank.rs b/src/screensaver/blank.rs new file mode 100644 index 0000000..b68c607 --- /dev/null +++ b/src/screensaver/blank.rs @@ -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(); + } +} diff --git a/src/screensaver/gif.rs b/src/screensaver/gif.rs new file mode 100644 index 0000000..db56a12 --- /dev/null +++ b/src/screensaver/gif.rs @@ -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, + current: usize, + elapsed: f64, + scale: Scale, +} + +impl GifScreensaver { + pub fn load(path: &Path, scale: Scale) -> Result { + 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(); + } + } +} diff --git a/src/screensaver/image_ss.rs b/src/screensaver/image_ss.rs new file mode 100644 index 0000000..b8dcff7 --- /dev/null +++ b/src/screensaver/image_ss.rs @@ -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 { + 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); + } + } + } +} diff --git a/src/screensaver/mod.rs b/src/screensaver/mod.rs new file mode 100644 index 0000000..c03762d --- /dev/null +++ b/src/screensaver/mod.rs @@ -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); +} diff --git a/src/screensaver/text.rs b/src/screensaver/text.rs new file mode 100644 index 0000000..ca21416 --- /dev/null +++ b/src/screensaver/text.rs @@ -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 = 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); + } + } + } +} diff --git a/src/widget/clock.rs b/src/widget/clock.rs new file mode 100644 index 0000000..0025b35 --- /dev/null +++ b/src/widget/clock.rs @@ -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, 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, 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); +} diff --git a/src/widget/date.rs b/src/widget/date.rs new file mode 100644 index 0000000..7e1653b --- /dev/null +++ b/src/widget/date.rs @@ -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); + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs new file mode 100644 index 0000000..c530cd9 --- /dev/null +++ b/src/widget/mod.rs @@ -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); +} diff --git a/src/widget/weather.rs b/src/widget/weather.rs new file mode 100644 index 0000000..6c910ac --- /dev/null +++ b/src/widget/weather.rs @@ -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, + 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>>, + 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::().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") => "🌫", + _ => "~", + } +}