feat: add ahfail-ui crate with animation, audio, display, config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-05-06 09:28:14 +02:00
parent 2b89653be6
commit 3dc0733cd0
9 changed files with 697 additions and 1 deletions

View File

@@ -2,3 +2,18 @@
name = "ahfail-ui"
version = "0.1.0"
edition = "2021"
[lib]
name = "ahfail_ui"
crate-type = ["rlib"]
[dependencies]
gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
gstreamer-player = { version = "0.18", package = "gstreamer-player" }
glib = { version = "0.15", package = "glib" }
gio = { version = "0.15", package = "gio" }
gdk-pixbuf = "0.15"
rand = "0.8"
ureq = "2"

View File

@@ -0,0 +1,43 @@
use gtk::{gdk_pixbuf, gio};
use gdk_pixbuf::InterpType;
use glib::Cast;
const SPRITE_SCALE: f64 = 0.6;
extern "C" {
pub fn ahfail_get_resource() -> *mut gio::ffi::GResource;
}
/// Registers GResources and loads all sprite frames into a looping PixbufSimpleAnim.
/// Returns None if GResources unavailable or no frames found.
pub unsafe fn load_animation() -> Option<gdk_pixbuf::PixbufAnimation> {
use glib::translate::from_glib_none;
let resource_ptr = ahfail_get_resource();
if resource_ptr.is_null() { return None; }
let resource = from_glib_none::<_, gio::Resource>(resource_ptr);
gio::resources_register(&resource);
let mut frames = gio::resources_enumerate_children(
"/ahfail/sprites", gio::ResourceLookupFlags::NONE,
).ok()?;
frames.sort();
let mut loaded: Vec<gdk_pixbuf::Pixbuf> = Vec::new();
for name in frames {
let path = format!("/ahfail/sprites/{}", name);
if let Ok(pb) = gdk_pixbuf::Pixbuf::from_resource(&path) {
let w = (pb.width() as f64 * SPRITE_SCALE) as i32;
let h = (pb.height() as f64 * SPRITE_SCALE) as i32;
let scaled = pb.scale_simple(w, h, InterpType::Bilinear).unwrap_or(pb);
loaded.push(scaled);
}
}
if loaded.is_empty() { return None; }
let first = &loaded[0];
let anim = gdk_pixbuf::PixbufSimpleAnim::new(first.width(), first.height(), 12.0);
anim.set_loop(true);
for frame in loaded { anim.add_frame(&frame); }
Some(anim.upcast())
}

View File

@@ -0,0 +1,12 @@
use gstreamer_player as gst_player;
use gtk::glib;
pub fn create_player(uri: &str) -> gst_player::Player {
let player = gst_player::Player::new(None, None);
player.set_uri(Some(uri));
player.connect_end_of_stream(glib::clone!(@weak player => move |_| {
player.seek(gstreamer::ClockTime::from_seconds(0));
}));
player.connect_error(|_, err| eprintln!("[ahfail] GStreamer error: {}", err));
player
}

View File

@@ -0,0 +1,37 @@
use gtk::gdk;
use std::ffi::CStr;
use std::ptr;
// Storage for the command line argument string
pub static mut DEADZONE_ARG: *mut std::os::raw::c_char = ptr::null_mut();
pub const DEADZONE_LONG: &[u8] = b"deadzone\0";
pub const DEADZONE_DESC: &[u8] = b"Area to avoid spawning sprites (x,y,w,h)\0";
pub const DEADZONE_ARG_DESC: &[u8] = b"x,y,w,h\0";
pub struct ModuleConfig {
pub deadzone: Option<gdk::Rectangle>,
}
impl ModuleConfig {
pub unsafe fn from_args() -> Self {
let mut deadzone = None;
if !DEADZONE_ARG.is_null() {
let c_str = CStr::from_ptr(DEADZONE_ARG);
if let Ok(s) = c_str.to_str() {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() == 4 {
if let (Ok(x), Ok(y), Ok(w), Ok(h)) = (parts[0].parse(), parts[1].parse(), parts[2].parse(), parts[3].parse()) {
deadzone = Some(gdk::Rectangle::new(x, y, w, h));
println!("[ahfail] Configured deadzone: {:?}", deadzone);
} else {
eprintln!("[ahfail] Invalid numbers in deadzone argument: {}", s);
}
} else {
eprintln!("[ahfail] Invalid format for deadzone argument (expected x,y,w,h): {}", s);
}
}
}
Self { deadzone }
}
}

View File

@@ -0,0 +1,43 @@
use gtk::{gdk_pixbuf, prelude::*};
use rand::Rng;
use crate::config::ModuleConfig;
const SPRITE_MARGIN: i32 = 100;
const RETRY_ATTEMPTS: usize = 10;
pub fn place_sprite(
fixed: &gtk::Fixed,
animation: &gdk_pixbuf::PixbufAnimation,
screen_w: i32,
screen_h: i32,
config: &ModuleConfig,
) -> gtk::Image {
let sprite_w = animation.width();
let sprite_h = animation.height();
let safe_w = screen_w - SPRITE_MARGIN;
let safe_h = screen_h - SPRITE_MARGIN;
let max_x = (safe_w - sprite_w).max(0);
let max_y = (safe_h - sprite_h).max(0);
let mut rng = rand::thread_rng();
let mut x = rng.gen_range(0..=max_x);
let mut y = rng.gen_range(0..=max_y);
if let Some(dz) = config.deadzone {
for _ in 0..RETRY_ATTEMPTS {
let overlaps_x = x < dz.x() + dz.width() && x + sprite_w > dz.x();
let overlaps_y = y < dz.y() + dz.height() && y + sprite_h > dz.y();
if !(overlaps_x && overlaps_y) {
break;
}
x = rng.gen_range(0..=max_x);
y = rng.gen_range(0..=max_y);
}
}
let image = gtk::Image::from_animation(animation);
fixed.put(&image, x, y);
image
}

View File

@@ -1 +1,8 @@
pub fn placeholder() {}
pub mod animation;
pub mod audio;
pub mod config;
pub mod display;
pub mod update;
pub mod volume;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -0,0 +1 @@
pub fn check_for_update(_current_version: &str) {}

View File

@@ -0,0 +1,7 @@
pub struct VolumeState;
pub fn save_and_set_max() -> Option<VolumeState> {
None
}
pub fn restore(_state: VolumeState) {}