commit defe26acc385db5c1055d2f61c646d03a297840a Author: CanadianBaconBoi Date: Fri May 8 20:11:51 2026 +0200 Initial Commit diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ab70c46 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +SLINT_ENABLE_EXPERIMENTAL_FEATURES = "1" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fa8152 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/cache +Cargo.lock \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0bda752 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ckgreeter" +version = "0.1.0" +build = "build.rs" +edition = "2024" + +[dependencies] +#slint = { version = "1.16.1", default-features = false, features = [ +slint = { git = "https://github.com/slint-ui/slint", branch = "master", default-features = false, features = [ + "std", + "compat-1-2", + "renderer-femtovg", + "backend-linuxkms", + "backend-winit-wayland", +] } +anyhow = "1.0.102" +greetd_ipc = { version = "0.10.3", features = [ + "codec", + "async-trait", + "tokio-codec", +] } +i-slint-core = { git = "https://github.com/slint-ui/slint", branch = "master" } +toml = { version = "1.1.2+spec-1.1.0", features = ["serde"] } +serde = { version = "1.0.228", features = ["derive"] } +serde-aux = "4.7.0" +zbus = { version = "5.15.0", features = ["blocking"] } +tokio = { version = "1.52.2", features = ["macros", "rt-multi-thread", "net", "fs", "full"] } +tokio-stream = {version = "0.1.18", features = ["fs"]} +greetd-stub = "0.3.0" +chrono = "0.4.44" + +pexels-api = "0.0.5" +reqwest = { version = "0.13.3", features = ["json", "default-tls"] } + +magick_rust = "2.0.0" + + +[build-dependencies] +#slint-build = "1.16.1" +slint-build = { git = "https://github.com/slint-ui/slint", branch = "master" } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9a9e1c3 --- /dev/null +++ b/build.rs @@ -0,0 +1,9 @@ +use slint_build::CompilerConfiguration; + +fn main() { + slint_build::compile_with_config( + "ui/window.slint", + CompilerConfiguration::new().with_style("fluent-dark".into()), + ) + .unwrap(); +} \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..31578d3 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" \ No newline at end of file diff --git a/src/callbacks.rs b/src/callbacks.rs new file mode 100644 index 0000000..e707c21 --- /dev/null +++ b/src/callbacks.rs @@ -0,0 +1,571 @@ +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; +use anyhow::{anyhow, Context}; +use greetd_ipc::{AuthMessageType, ErrorType, Request, Response}; +use greetd_ipc::codec::TokioCodec; +use i_slint_core::api::{ComponentHandle, Global, Image, Rgba8Pixel, SharedPixelBuffer, Weak}; +use i_slint_core::model::{Model, VecModel}; +use magick_rust::MagickWand; +use pexels_api::{Pexels, SearchBuilder}; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, BufReader, Lines}; +use tokio::net::UnixStream; +use crate::{GreeterData, GreeterDisplay, LoginOptionTileData}; +use crate::config::Config; +use crate::input::KeyboardLockStates; + +#[cfg(not(debug_assertions))] +use zbus::{Connection, proxy}; +#[cfg(not(debug_assertions))] +use slint::SharedString; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::ReadDirStream; + +pub fn register_login_callback(data: &GreeterData, display_weak: Weak, tiles: Rc>) { + data.on_login({ + let tiles = tiles.clone(); + let display_weak = display_weak.clone(); + + move |username, password| { + let tiles = tiles.clone(); + let display_weak = display_weak.clone(); + slint::spawn_local(async move { + let display = display_weak.unwrap(); + + let username = username.to_string(); + let password = password.to_string(); + + let index = display.get_selected_index(); + let tile = &tiles.row_data(index as usize); + let tile = match tile { + Some(tile) => {tile} + None => { + display.invoke_set_error("Failed to login: Invalid index for tile".into()); + return; + } + }; + + match login( + username.as_str(), + password.as_str(), + tile.command.as_str(), + ) + .await + { + Ok(successful) => { + if !successful { + display.invoke_set_error("Failed to login: Bad Login".into()); + return; + } + + if let Err(err) = slint::quit_event_loop() { + display.invoke_set_error(format!("Failed to exit greeter: {err}").into()); + } + } + Err(err) => { + display.invoke_set_error(format!("Failed to login: {}", err).into()); + return; + } + }; + }) + .unwrap(); + } + }); + + async fn login( + username: &str, + password: &str, + cmd: &str, + ) -> anyhow::Result { + if username.trim().is_empty() { + return Err(anyhow::anyhow!("Username is required")); + } + + if cmd.trim().is_empty() { + return Err(anyhow::anyhow!("Command is required")); + } + + let socket = std::env::var("GREETD_SOCK") + .context("GREETD_SOCK is not set")?; + + let mut stream = UnixStream::connect(socket).await.context("failed to connect to greetd socket")?; + + let mut next_request = Request::CreateSession { username: username.to_string() }; + let mut starting = false; + loop { + next_request.write_to(&mut stream).await?; + + match Response::read_from(&mut stream).await? { + Response::AuthMessage { + auth_message: _auth_message, + auth_message_type, + } => { + let response = match auth_message_type { + AuthMessageType::Visible => None, + AuthMessageType::Secret => { + if !password.is_empty() { + Some(password.to_string()) + } else { + None + } + } + AuthMessageType::Info => None, + AuthMessageType::Error => None, + }; + + next_request = Request::PostAuthMessageResponse { response }; + } + Response::Success => { + if starting { + return Ok(true); + } else { + starting = true; + next_request = Request::StartSession { + env: vec![], + cmd: vec![cmd.to_string()], + } + } + } + Response::Error { + error_type, + description, + } => { + Request::CancelSession.write_to(&mut stream).await?; + return match error_type { + ErrorType::AuthError => Ok(false), + ErrorType::Error => Err(anyhow::anyhow!("login error: {description:?}")), + }; + } + } + } + } +} +pub fn register_power_callback(_data: &GreeterData) { + #[cfg(not(debug_assertions))] + _data.on_loginctl(move | + command: SharedString + | { + slint::spawn_local(async move { + if let Err(err) = handle_power_command(command.as_str()).await { + eprintln!("Power command failed: {err}"); + } + }) + .unwrap(); + }); + + #[cfg(not(debug_assertions))] + async fn handle_power_command(command: &str) -> anyhow::Result<()> { + let connection = Connection::system().await?; + let proxy = PowerManagerProxy::new(&connection).await?; + + match command { + "suspend" => proxy.suspend(&false).await?, + "poweroff" => proxy.power_off(&false).await?, + "reboot" => proxy.reboot(&false).await?, + command => return Err(anyhow::anyhow!("Unknown power command: {command}")), + } + + Ok(()) + } + + #[cfg(not(debug_assertions))] + #[proxy( + interface = "org.freedesktop.login1.Manager", + default_service = "org.freedesktop.login1", + default_path = "/org/freedesktop/login1" + )] + trait PowerManager { + fn suspend(&self, interactive: &bool) -> zbus::Result<()>; + fn reboot(&self, interactive: &bool) -> zbus::Result<()>; + fn power_off(&self, interactive: &bool) -> zbus::Result<()>; + } +} +pub fn register_lock_state_callback(data: &GreeterData, data_weak: Weak>) { + data.on_check_lock_states({ + let data_weak = data_weak.clone(); + move || { + let data_weak = data_weak.clone(); + slint::spawn_local(async move { + let data = data_weak.unwrap(); + if let Some(states) = KeyboardLockStates::get_locks_state() { + data.set_caps_lock_state(states.caps_lock); + data.set_num_lock_state(states.num_lock); + data.set_scroll_lock_state(states.scroll_lock); + } + }) + .unwrap(); + } + }); +} +pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak, data_weak: Weak>, cache_dir: PathBuf, config: &Config) { //TODO: Seperate this out into seperate methods, cleanup!!! + const MAX_RECENT_WALLPAPERS: usize = 10; + + async fn read_lines

(filename: P) -> std::io::Result>> + where + P: AsRef, + { + let file = File::open(filename).await?; + Ok(BufReader::new(file).lines()) + } + + async fn write_lines

( + filename: P, + lines: VecDeque, + ) -> std::io::Result<()> + where + P: AsRef, + { + let mut contents = String::new(); + + for line in lines { + contents.push_str(&line); + contents.push('\n'); + } + + std::fs::write(filename, contents) + } + + fn set_background_image( + display: &GreeterDisplay, + data: &GreeterData, + pixels: &[u8], + w: u32, + h: u32, + ) { + if !display.get_background_toggle() { + let old = data.get_wallpaper_image(); + data.set_wallpaper_image(Image::from_rgba8(SharedPixelBuffer::< + Rgba8Pixel, + >::clone_from_slice( + pixels, w, h + ))); + drop(old); + } else { + let old = data.get_wallpaper_image_1(); + data.set_wallpaper_image_1(Image::from_rgba8(SharedPixelBuffer::< + Rgba8Pixel, + >::clone_from_slice( + pixels, w, h + ))); + drop(old); + } + } + + fn export_wand_rgba(wand: &MagickWand) -> anyhow::Result<(u32, u32, Vec)> { + let pixels = wand + .export_image_pixels( + 0, + 0, + wand.get_image_width(), + wand.get_image_height(), + "RGBA", + ) + .ok_or_else(|| anyhow!("Failed to export image pixels"))?; + + Ok(( + wand.get_image_width() as u32, + wand.get_image_height() as u32, + pixels, + )) + } + + let reqwest_client = match reqwest::ClientBuilder::new() + .tls_backend_rustls() + // .timeout(std::time::Duration::from_secs(60)) + .http2_prior_knowledge() + .http2_keep_alive_timeout(std::time::Duration::from_secs(10)) + .build() { + Ok(client) => client, + Err(err) => { + eprintln!("Failed to create reqwest client: {err}"); + return; + } + }; + + if let Some(pexels_api_key) = config.pexels_api_key.clone() { + let pexels_client = Arc::new(Pexels::new(pexels_api_key)); + let pexels_query = config + .pexels_query + .clone() + .unwrap_or_else(|| String::from("nature")); + data.on_get_new_wallpaper({ + let data_weak = data_weak.clone(); + let display_weak = display_weak.clone(); + move || { + let pexels_client = pexels_client.clone(); + let reqwest_client = reqwest_client.clone(); + let cache_dir = cache_dir.clone(); + let pexels_query = pexels_query.clone(); + + let display_weak = display_weak.clone(); + let data_weak = data_weak.clone(); + + let get_wallpaper = { + let data_weak = data_weak.clone(); + let display_weak = display_weak.clone(); + async move || -> anyhow::Result<()> { + let data = data_weak.unwrap(); + let display = display_weak.unwrap(); + + let last_used_log = cache_dir.join("last_used_log.txt"); + if !tokio::fs::try_exists(&last_used_log).await? { + File::create(&last_used_log).await?; + } + + let mut file_lines = tokio::time::timeout(std::time::Duration::from_secs(5), read_lines(&last_used_log)).await??; + let mut idx = 0; + let mut last_used_lines = VecDeque::with_capacity(MAX_RECENT_WALLPAPERS); + while let Some(line) = file_lines.next_line().await? { + last_used_lines.push_back(line); + idx += 1; + if idx >= MAX_RECENT_WALLPAPERS { + break; + } + } + drop(file_lines); + + match tokio::time::timeout( + std::time::Duration::from_secs(30), + pexels_client + .search_photos( + SearchBuilder::new() + .query(pexels_query.as_str()) + .page(1) + .per_page(11) + .size(pexels_api::Size::Medium) + .orientation(pexels_api::Orientation::Landscape), + ) + ).await? + { + Ok(photos) => { + if photos.total_results > 0 { + for photo in photos.photos { + let string_id = photo.id.to_string(); + if last_used_lines.contains(&string_id) { + continue; + } + + last_used_lines.push_back(string_id); + if last_used_lines.len() > MAX_RECENT_WALLPAPERS { + last_used_lines.pop_front(); + } + + let image_path = + cache_dir.join(format!("{}.png", photo.id)); + + let _ = if !tokio::fs::try_exists(&image_path).await? { + let response = reqwest_client + .get(photo.src.large2x.clone()) // TODO: determine which photo src size is best, maybe determine based on the monitor size to reduce memory pressure + .send() + .await?; + let image_bytes = response.bytes().await?; + + let size = display.window().size(); + + let (w, h, pixels) = tokio::task::spawn_blocking(move || { + let wand = MagickWand::new(); + + wand.read_image_blob(&image_bytes)?; + drop(image_bytes); + + if size.width >= size.height { + wand.fit( + size.width as usize, + size.width as usize, + ); + } else { + wand.fit( + size.height as usize, + size.height as usize, + ); + } + + let png_data = wand.write_image_blob("png")?; + std::fs::write(image_path, png_data)?; + + export_wand_rgba(&wand) + }).await??; + + set_background_image( + &display, + &data, + pixels.as_slice(), + w, + h, + ); + + drop(pixels); + + data.set_wallpaper_author(photo.photographer.into()); + data.set_wallpaper_alt(photo.alt.into()); + data.set_wallpaper_url(photo.url.into()); + + display.invoke_switch_background(); + } else { + let contents = tokio::fs::read(image_path).await?; + + let (w, h, pixels) = tokio::task::spawn_blocking(move || { + let wand = MagickWand::new(); + + wand.read_image_blob(contents.as_slice())?; + export_wand_rgba(&wand) + }).await??; + + set_background_image( + &display, + &data, + pixels.as_slice(), + w, + h, + ); + + drop(pixels); + + data.set_wallpaper_author(photo.photographer.into()); + data.set_wallpaper_alt(photo.alt.into()); + data.set_wallpaper_url(photo.url.into()); + + display.invoke_switch_background(); + }; + break; + } + + tokio::time::timeout( + std::time::Duration::from_secs(5), + write_lines(&last_used_log, last_used_lines), + ) + .await + .context("timed out while writing wallpaper log")??; + } + } + Err(err) => { + eprintln!("Failed to fetch online images, using cached. {err}"); + let mut photos = ReadDirStream::new(tokio::fs::read_dir(cache_dir).await?).filter(|de| match de { + Ok(de) => { + de.path().is_file() + && de.path().extension() + == Some(std::ffi::OsStr::new("png")) + } + Err(_) => false, + }); + + while let Some(photo) = photos.next().await { + match photo { + Ok(photo) => { + let photo_path = photo.path(); + let string_id = photo_path.file_prefix().context("failed to get filename")?.to_string_lossy().to_string(); + + if last_used_lines.front().is_some_and(|last| last == &string_id) { + continue; + } + + last_used_lines.push_back(string_id); + if last_used_lines.len() > MAX_RECENT_WALLPAPERS { + last_used_lines.pop_front(); + } + + let (w, h, pixels) = tokio::task::spawn_blocking(move || { + let wand = MagickWand::new(); + + wand.read_image(photo_path.to_str().context("failed to convert image path to UTF-8")?)?; + export_wand_rgba(&wand) + }).await??; + + set_background_image( + &display, + &data, + pixels.as_slice(), + w, + h, + ); + + drop(pixels); + + data.set_wallpaper_author("Unknown".into()); + data.set_wallpaper_alt("".into()); + data.set_wallpaper_url("Cached wallpaper".into()); + + display.invoke_switch_background(); + + + break; + } + Err(_err) => { + continue; + } + } + } + } + } + Ok(()) + } + }; + + match slint::spawn_local({ + let data_weak = data_weak.clone(); + async move { + match get_wallpaper().await { + Ok(_) => {} + Err(e) => { + let data = data_weak.unwrap(); + report_wallpaper_error(&data, e.to_string()); + } + }; + } + }) { + Ok(_) => {} + Err(e) => { + let data = data_weak.unwrap(); + report_wallpaper_error(&data, e.to_string()); + } + } + } + }); + + fn report_wallpaper_error(data: &GreeterData, error: String) { + data.set_wallpaper_author("Error".into()); + data.set_wallpaper_alt(error.into()); + data.set_wallpaper_url("Failed to get wallpaper".into()); + } + } +} +pub fn register_clean_inactive_wallpaper_callback(data: &GreeterData, display_weak: Weak, data_weak: Weak>) { + data.on_clear_inactive_wallpaper({ + let display_weak = display_weak.clone(); + let data_weak = data_weak.clone(); + + move || { + let display = display_weak.unwrap(); + let data = data_weak.unwrap(); + + if !display.get_background_toggle() { + let old = data.get_wallpaper_image(); + data.set_wallpaper_image(Image::default()); + drop(old); + } else { + let old = data.get_wallpaper_image_1(); + data.set_wallpaper_image_1(Image::default()); + drop(old); + } + } + }) +} + +pub fn register_callbacks( + display: &GreeterDisplay, + data: &GreeterData, + tiles: Rc>, + cache_dir: PathBuf, + config: &Config, +) { + let display_weak = display.as_weak(); + let data_weak = data.as_weak(); + + register_login_callback(data, display_weak.clone(), tiles); + register_power_callback(data); + register_lock_state_callback(data, data_weak.clone()); + register_wallpaper_callback(data, display_weak.clone(), data_weak.clone(), cache_dir, config); + register_clean_inactive_wallpaper_callback(data, display_weak.clone(), data_weak.clone()); +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0332731 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,83 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use anyhow::{anyhow, Context}; +use i_slint_core::api::{Image, ToSharedString}; +use serde::Deserialize; +use serde_aux::prelude::*; +use crate::LoginOptionTileData; + +#[derive(Deserialize)] +pub struct LoginOptionTileConfig { + pub(crate) image_path: PathBuf, + pub(crate) command: String, + #[serde(default = "default_i32::<-1>")] + pub(crate) index: i32, + #[serde(default)] + pub(crate) is_default: bool +} + +pub fn default_cache_dir() -> PathBuf { + #[cfg(debug_assertions)] + return "./cache/ckgreeter".into(); + #[cfg(not(debug_assertions))] + return "/var/cache/ckgreeter".into(); +} + +#[derive(Deserialize)] +pub struct Config { + pub(crate) default_username: Option, + pub(crate) environments: HashMap, + pub(crate) pexels_api_key: Option, + pub(crate) pexels_query: Option, + #[serde(default = "default_cache_dir", alias = "bg_cache_directory")] + pub(crate) background_cache_directory: PathBuf, + #[serde(default = "default_i32::<10>", alias = "bg_delay")] + pub(crate) background_delay: i32, +} + +impl Config { + pub fn get_tiles(&self) -> anyhow::Result<(Vec, Option)> { + let mut ordered = self + .environments + .iter() + .collect::>(); + + ordered.sort_by_key(|(name, env)| { + let index = if env.index >= 0 { env.index } else { i32::MAX }; + (index, name.as_str()) + }); + + let mut tiles: Vec = Vec::new(); + let mut default = None; + + let mut seen_indexes = std::collections::HashSet::new(); + + for (name, tile_config) in ordered { + if tile_config.index >= 0 && !seen_indexes.insert(tile_config.index) { + return Err(anyhow!( + "Duplicate login option index `{}` found near `{}`", + tile_config.index, + name + )); + } + if tile_config.is_default { + if default.is_some() { + return Err(anyhow!("There can only be one default login option...")); + } + default = Some(tiles.len()); + } + tiles.push(LoginOptionTileData { + command: tile_config.command.to_shared_string(), + image: Image::load_from_path(&tile_config.image_path).with_context(|| { + format!( + "Failed to load image for login option `{}` from `{}`", + name, tile_config.image_path.display() + ) + })?, + name: name.to_shared_string(), + }) + } + + Ok((tiles, default)) + } +} \ No newline at end of file diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..d3543d3 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,48 @@ +use std::path::Path; + +pub struct KeyboardLockStates { + pub(crate) caps_lock: bool, + pub(crate) num_lock: bool, + pub(crate) scroll_lock: bool, +} + +impl KeyboardLockStates { + pub(crate) fn get_locks_state() -> Option { + let leds_dir = Path::new("/sys/class/leds/"); + + let mut caps_lock = false; + let mut num_lock = false; + let mut scroll_lock = false; + + let entries = std::fs::read_dir(leds_dir).ok()?; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + + if !caps_lock && name.contains("capslock") { + caps_lock = is_led_enabled(&entry.path()); + } else if !num_lock && name.contains("numlock") { + num_lock = is_led_enabled(&entry.path()); + } else if !scroll_lock && name.contains("scrolllock") { + scroll_lock = is_led_enabled(&entry.path()); + } + + if caps_lock && num_lock && scroll_lock { + break; + } + } + + Some(KeyboardLockStates { + caps_lock, + num_lock, + scroll_lock, + }) + } +} + +fn is_led_enabled(led_path: &Path) -> bool { + let brightness_path = led_path.join("brightness"); + + std::fs::read_to_string(brightness_path) + .is_ok_and(|content| content.trim() != "0") +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..40f73a0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,99 @@ +mod callbacks; +mod config; +mod input; +mod timers; + +use crate::callbacks::register_callbacks; +use crate::config::Config; +use crate::timers::register_timers; + +use anyhow::Context; +use i_slint_core::model::VecModel; +use magick_rust::magick_wand_genesis; +use slint::ModelRc; +use std::rc::Rc; +use std::sync::Once; + +slint::include_modules!(); + +static START: Once = Once::new(); + +#[cfg(debug_assertions)] +fn start_debug_greetd_stub() { + use libgreetd_stub::SessionOptions; + + let opts = SessionOptions { + username: std::env::var("CKGREETER_STUB_USER") + .unwrap_or_else(|_| "debug-user".to_string()), + password: std::env::var("CKGREETER_STUB_PASSWORD") + .unwrap_or_else(|_| "debug-password".to_string()), + mfa: false, + }; + + let socket_path = std::env::var("CKGREETER_STUB_SOCKET") + .unwrap_or_else(|_| "/tmp/greetd-stub.sock".to_string()); + + let stub_socket_path = socket_path.clone(); + let _stub_task = tokio::task::spawn(async move { + libgreetd_stub::start(&stub_socket_path, &opts).await + }); + + unsafe {std::env::set_var("GREETD_SOCK", socket_path)}; +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + START.call_once(|| { + magick_wand_genesis(); + }); + + #[cfg(debug_assertions)] + start_debug_greetd_stub(); + + const CONFIG_PATH: &str = "/etc/ckgreeter/config.toml"; + + let config_data = std::fs::read(CONFIG_PATH) + .with_context(|| format!("Failed to read config file at {}", CONFIG_PATH))?; + let config: Config = toml::from_slice(&config_data) + .with_context(|| format!("Failed to parse config file at {}", CONFIG_PATH))?; + + let cache_dir = &config.background_cache_directory; + std::fs::create_dir_all(cache_dir) + .with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?; + + let display = GreeterDisplay::new()?; + let data = display.global::(); + + let (tiles, default_tile) = config.get_tiles()?; + + if let Some(default_tile) = default_tile { + data.set_selected_index(default_tile as i32); + } + + let tiles = Rc::new(VecModel::from(tiles)); + + register_callbacks(&display, &data, tiles.clone(), cache_dir.into(), &config); + let _timers = register_timers(&display, &data, &config); + + match config.default_username { + Some(username) => { + display.set_default_username(username.into()); + data.set_has_default_username(true); + } + None => { + data.set_has_default_username(false); + } + } + + display.set_tiles(ModelRc::from(tiles)); + + data.set_wallpaper_cooldown(config.background_delay.max(5)); + data.invoke_get_new_wallpaper(); + data.invoke_check_lock_states(); + + display.show()?; + tokio::task::block_in_place(slint::run_event_loop)?; + display.hide()?; + + Ok(()) +} \ No newline at end of file diff --git a/src/timers.rs b/src/timers.rs new file mode 100644 index 0000000..80f22ec --- /dev/null +++ b/src/timers.rs @@ -0,0 +1,32 @@ +use chrono::Datelike; +use i_slint_core::api::Global; +use crate::{GreeterData, GreeterDisplay}; +use crate::config::Config; + +pub fn register_timers( + _display: &GreeterDisplay, + data: &GreeterData, + _config: &Config, +) -> slint::Timer { + let data_weak = data.as_weak(); + + let timer = slint::Timer::default(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_secs(1), + { + let data_weak = data_weak.clone(); + move || { + let data = data_weak.unwrap(); + let now = chrono::Local::now(); + + data.set_current_time(now.format("%H:%M:%S").to_string().into()); + data.set_current_date( + format!("{} {}", now.weekday(), now.format("%d/%m/%Y")).into(), + ); + } + }, + ); + + timer +} \ No newline at end of file diff --git a/ui/data.slint b/ui/data.slint new file mode 100644 index 0000000..aa8871d --- /dev/null +++ b/ui/data.slint @@ -0,0 +1,33 @@ +export global Data { + in-out property selected_index; + + in property has-default-username; + + in-out property error_count; + + callback login(username: string, password: string); + callback loginctl(command: string); + callback check-lock-states(); + + callback get-new-wallpaper(); + callback clear-inactive-wallpaper(); + + in property wallpaper-url; + + in property wallpaper-cooldown: 1; + + in-out property wallpaper-image; + in-out property wallpaper-image-1; + + in property wallpaper-author; + in property wallpaper-alt; + + in-out property caps-lock-state; + in-out property num-lock-state; + in-out property scroll-lock-state; + + in-out property current-time; + in-out property current-date; +} + +export struct LoginOptionTileData { image: image, name: string, command: string} diff --git a/ui/envtile.slint b/ui/envtile.slint new file mode 100644 index 0000000..6c24768 --- /dev/null +++ b/ui/envtile.slint @@ -0,0 +1,59 @@ +import { Data } from "data.slint"; + +export component LoginOptionTile inherits Rectangle { + in property icon; + in property label; + in property index; + in property has_focus; + + callback on_click(); + + background: rgba(0, 0, 0, 0.5); + + Image { + width: Data.selected_index == index ? root.width * 0.65: root.width * 0.5; + height: Data.selected_index == index ? root.height * 0.65: root.height * 0.5; + + animate width, height { + duration: 150ms; + } + + source: icon; + } + + area := TouchArea { + width: parent.width; + height: parent.height; + z: 1; + + clicked => { + root.on_click() + } + } + + hover_rect := Rectangle { + width: parent.width - parent.width * 0.05; + height: parent.height - parent.width * 0.05; + background: rgba(0, 0, 0, area.has-hover || (has_focus && Data.selected_index == parent.index) ? 0.5 : 0.0); + + opacity: area.has-hover || Data.selected_index == parent.index ? 1.0 : 0.0; + animate opacity { duration: 250ms; } + animate background { duration: 250ms; } + Text { + height: parent.height; + vertical-alignment: TextVerticalAlignment.bottom; + color: rgba(240, 240, 240, 1); + text: label; + font-size: max(1rem, parent.height * 0.125); + font-family: "monospace"; + max-width: parent.width; + wrap: word-wrap; + } + } + + border-width: self.width * 0.025; + border-radius: self.width * 0.025; + border-color: Data.selected_index == self.index ? rgba(120, 120, 220, 0.5) : rgba(0, 0, 0, 0); + + animate border-color { duration: 250ms; } +} diff --git a/ui/iconbutton.slint b/ui/iconbutton.slint new file mode 100644 index 0000000..ea5adf5 --- /dev/null +++ b/ui/iconbutton.slint @@ -0,0 +1,70 @@ +import { Palette } from "std-widgets.slint"; + +export component IconButton inherits Rectangle { + in property icon; + in property icon-width: 20px; + in property icon-height: 20px; + in property icon-align: LayoutAlignment.end; + + in property text; + in property font-family: "monospace"; + in property font-italic: false; + in property font-size: 1rem; + in property font-weight: 1; + + callback clicked; + + forward-focus: focus; + + animate background, border-color { duration: 150ms; } + + // Fluent-style rectangular base + background: touch.pressed ? Palette.control-background.darker(0.5) : + touch.has-hover || focus.has-focus ? Palette.control-background.mix(#ffffff, 0.95) : + Palette.control-background; + border-radius: 4px; // Standard Fluent corner radius + border-width: 1px; + border-color: Palette.border; + min-width: 40px; + min-height: 32px; + + if (text != "") : Text { + text: root.text; + font-family: root.font-family; + font-italic: root.font-italic; + font-size: root.font-size; + font-weight: root.font-weight; + } + + VerticalLayout { + width: parent.width; + height: parent.height; + alignment: center; + HorizontalLayout { + padding-left: 1rem; + padding-right: 1rem; + width: parent.width; + alignment: root.icon-align; + Image { + source: root.icon; + width: root.icon-width; + height: root.icon-height; + } + } + } + + touch := TouchArea { + clicked => { root.clicked() } + } + + focus := FocusScope { + key-pressed(event) => { + if (event.text == Key.Return || event.text == Key.Space) { + root.clicked(); + accept + } else { + reject + } + } + } +} \ No newline at end of file diff --git a/ui/icons/link.svg b/ui/icons/link.svg new file mode 100644 index 0000000..a7f8e99 --- /dev/null +++ b/ui/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/person.svg b/ui/icons/person.svg new file mode 100644 index 0000000..9838e51 --- /dev/null +++ b/ui/icons/person.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/power.svg b/ui/icons/power.svg new file mode 100644 index 0000000..0cdbef3 --- /dev/null +++ b/ui/icons/power.svg @@ -0,0 +1 @@ + diff --git a/ui/icons/restart.svg b/ui/icons/restart.svg new file mode 100644 index 0000000..0360d48 --- /dev/null +++ b/ui/icons/restart.svg @@ -0,0 +1 @@ + diff --git a/ui/icons/right_arrow.svg b/ui/icons/right_arrow.svg new file mode 100644 index 0000000..0585fa8 --- /dev/null +++ b/ui/icons/right_arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/sleep.svg b/ui/icons/sleep.svg new file mode 100644 index 0000000..c9512da --- /dev/null +++ b/ui/icons/sleep.svg @@ -0,0 +1 @@ + diff --git a/ui/icons/text.svg b/ui/icons/text.svg new file mode 100644 index 0000000..084339d --- /dev/null +++ b/ui/icons/text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/widgets/common/lineedit-base.slint b/ui/widgets/common/lineedit-base.slint new file mode 100644 index 0000000..39c19f6 --- /dev/null +++ b/ui/widgets/common/lineedit-base.slint @@ -0,0 +1,177 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { LineEditInterface } from "std-widget-interfaces.slint"; + +export component LineEditBase implements LineEditInterface inherits Rectangle { + font-size <=> text-input.font-size; + font-family <=> text-input.font-family; + font-italic <=> text-input.font-italic; + text <=> text-input.text; + enabled <=> text-input.enabled; + has-focus: text-input.has-focus; + // When true, the password input shows as plain text. + // Cleared automatically on focus loss. + in-out property password-revealed: false; + horizontal-alignment <=> text-input.horizontal-alignment; + read-only <=> text-input.read-only; + + changed has-focus => { + if !self.has-focus { + self.password-revealed = false; + } + } + + in-out property placeholder-color; + in property font-weight <=> text-input.font-weight; + in property text-color; + in property selection-background-color <=> text-input.selection-background-color; + in property selection-foreground-color <=> text-input.selection-foreground-color; + in property margin; + + public function set-selection-offsets(start: int, end: int) { + text-input.set-selection-offsets(start, end); + } + + public function select-all() { + text-input.select-all(); + } + + public function clear-selection() { + text-input.clear-selection(); + } + + public function cut() { + text-input.cut(); + } + + public function copy() { + text-input.copy(); + } + + public function paste() { + text-input.paste(); + } + + // on width < 1px or if the `TextInput` is clipped it cannot be focused therefore min-width 1px + min-width: 1px; + min-height: text-input.preferred-height; + clip: true; + forward-focus: text-input; + + placeholder := Text { + width: 100%; + height: 100%; + vertical-alignment: center; + text: (root.text == "" && text-input.preedit-text == "") ? root.placeholder-text : ""; + font-size: text-input.font-size; + font-italic: text-input.font-italic; + font-weight: text-input.font-weight; + font-family: text-input.font-family; + color: root.placeholder-color; + horizontal-alignment: root.horizontal-alignment; + // `accessible-placeholder-text` is set on LineEdit already + accessible-role: none; + } + + ContextMenuArea { + enabled: root.enabled; + Menu { + MenuItem { + title: @tr("Cut"); + enabled: !root.read-only && root.enabled; + activated => { + text-input.cut(); + } + } + + MenuItem { + title: @tr("Copy"); + enabled: !root.text.is-empty; + activated => { + text-input.copy(); + } + } + + MenuItem { + title: @tr("Paste"); + enabled: !root.read-only && root.enabled; + activated => { + text-input.paste(); + } + } + + MenuItem { + title: @tr("Select All"); + enabled: !root.text.is-empty; + activated => { + text-input.select-all(); + } + } + } + + text-input := TextInput { + property computed-x; + + x: min(0px, max(parent.width - self.width - self.text-cursor-width, self.computed-x)); + width: max(parent.width - self.text-cursor-width, self.preferred-width); + height: 100%; + vertical-alignment: center; + single-line: true; + color: root.text-color; + input-type: root.password-revealed ? InputType.text : root.input-type; + // Disable TextInput's built-in accessibility support as the widget takes care of that. + accessible-role: none; + + cursor-position-changed(cursor-position) => { + if cursor-position.x + self.computed_x < root.margin { + self.computed_x = - cursor-position.x + root.margin; + } else if cursor-position.x + self.computed_x > parent.width - root.margin - self.text-cursor-width { + self.computed_x = parent.width - cursor-position.x - root.margin - self.text-cursor-width; + } + } + + accepted => { + root.accepted(self.text); + } + + edited => { + root.edited(self.text); + } + + key-pressed(event) => { + root.key-pressed(event) + } + + key-released(event) => { + root.key-released(event) + } + } + } +} + +export component LineEditClearIcon inherits Image { + in-out property text; + callback clear(); + + vertical-alignment: center; + TouchArea { + clicked => { root.clear(); } + } +} + +export component LineEditPasswordIcon inherits Image { + in-out property show-password; + in property show-password-image; + in property hide-password-image; + callback clicked(); + + source: show-password ? hide-password-image : show-password-image; + vertical-alignment: center; + TouchArea { + clicked => { + root.show-password = !root.show-password; + root.clicked(); + } + } +} \ No newline at end of file diff --git a/ui/widgets/common/std-widget-interfaces.slint b/ui/widgets/common/std-widget-interfaces.slint new file mode 100644 index 0000000..7e7e959 --- /dev/null +++ b/ui/widgets/common/std-widget-interfaces.slint @@ -0,0 +1,4 @@ +// Copyright © 2026 Klarälvdalens Datakonsult AB, a KDAB Group company , author Nathan Collins +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export { LineEdit as LineEditInterface } from "../interfaces/lineedit.slint"; \ No newline at end of file diff --git a/ui/widgets/fluent/_dismiss.svg b/ui/widgets/fluent/_dismiss.svg new file mode 100644 index 0000000..f3447df --- /dev/null +++ b/ui/widgets/fluent/_dismiss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/widgets/fluent/_eye_hide.svg b/ui/widgets/fluent/_eye_hide.svg new file mode 100644 index 0000000..daa1801 --- /dev/null +++ b/ui/widgets/fluent/_eye_hide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/widgets/fluent/_eye_show.svg b/ui/widgets/fluent/_eye_show.svg new file mode 100644 index 0000000..a601310 --- /dev/null +++ b/ui/widgets/fluent/_eye_show.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/widgets/fluent/caps.svg b/ui/widgets/fluent/caps.svg new file mode 100644 index 0000000..b524ee5 --- /dev/null +++ b/ui/widgets/fluent/caps.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/widgets/fluent/color-scheme.slint b/ui/widgets/fluent/color-scheme.slint new file mode 100644 index 0000000..446a43d --- /dev/null +++ b/ui/widgets/fluent/color-scheme.slint @@ -0,0 +1,7 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { Palette } from "std-widgets.slint"; +export global ColorSchemeSelector { + in property color-scheme: Palette.color-scheme; +} \ No newline at end of file diff --git a/ui/widgets/fluent/lineedit.slint b/ui/widgets/fluent/lineedit.slint new file mode 100644 index 0000000..d430952 --- /dev/null +++ b/ui/widgets/fluent/lineedit.slint @@ -0,0 +1,110 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { FluentFontSettings, FluentPalette } from "styling.slint"; +import { LineEditBase, LineEditClearIcon, LineEditPasswordIcon } from "../common/lineedit-base.slint"; + +import { LineEditInterface } from "../common/std-widget-interfaces.slint"; +import { Palette } from "std-widgets.slint"; +import { Data } from "../../data.slint"; + +export component LineEdit uses { LineEditInterface from base } { + + in property border-color; + callback on-focus-change(); + + accessible-role: text-input; + accessible-enabled: root.enabled; + accessible-value <=> text; + accessible-placeholder-text: placeholder-text; + accessible-read-only: root.read-only; + accessible-action-set-value(v) => { text = v; edited(v); } + + vertical-stretch: 0; + horizontal-stretch: 1; + min-width: max(160px, layout.min-width); + min-height: max(32px, layout.min-height); + forward-focus: base; + + states [ + disabled when !root.enabled : { + background.background: FluentPalette.control-disabled; + base.text-color: FluentPalette.text-disabled; + base.selection-foreground-color: FluentPalette.text-accent-foreground-disabled; + base.placeholder-color: FluentPalette.text-disabled; + } + focused when root.has-focus : { + background.background: FluentPalette.control-input-active; + focus-border.background: FluentPalette.accent-background; + } + ] + + background := Rectangle { + border-radius: 4px; + background: FluentPalette.control-background; + border-width: 1px; + border-color: root.has-focus ? FluentPalette.text-control-border.mix(root.border-color, 0.65) : FluentPalette.text-control-border; + + animate border-color { + duration: 150ms; + } + + drop-shadow-blur: 5px; + drop-shadow-color: rgba(10, 10, 10, 0.25); + drop-shadow-offset-y: -1px; + + layout := HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + + base := LineEditBase { + changed has-focus => { + root.on-focus-change(); + } + font-size: FluentFontSettings.body.font-size; + font-weight: FluentFontSettings.body.font-weight; + selection-background-color: FluentPalette.selection-background; + selection-foreground-color: FluentPalette.accent-foreground; + text-color: FluentPalette.foreground; + placeholder-color: FluentPalette.text-secondary; + margin: layout.padding-left + layout.padding-right; + horizontal-stretch: 1; + } + + if !root.text.is-empty && root.input-type != InputType.password && root.enabled && !root.read-only && root.has-focus: LineEditClearIcon { + width: 16px; + text: base.text; + source: @image-url("_dismiss.svg"); + colorize: base.text-color; + clear => { + base.text = ""; + root.edited(""); + base.focus(); + } + } + + if root.input-type == InputType.password && !root.text.is-empty && root.has-focus: LineEditPasswordIcon { + width: self.source.width * 1px; + show-password-image: @image-url("_eye_show.svg"); + hide-password-image: @image-url("_eye_hide.svg"); + colorize: base.text-color; + show-password <=> base.password-revealed; + } + + Image { + source: @image-url("caps.svg"); + width: self.source.width * 1px; + colorize: base.text-color; + visible: Data.caps-lock-state && root.has-focus; + } + } + + focus-border := Rectangle { + x: parent.border-radius; + y: parent.height - self.height; + width: parent.width - 2 * parent.border-radius; + height: 2px; + border-radius: 1px; + } + } +} \ No newline at end of file diff --git a/ui/widgets/fluent/styling.slint b/ui/widgets/fluent/styling.slint new file mode 100644 index 0000000..cee9d5c --- /dev/null +++ b/ui/widgets/fluent/styling.slint @@ -0,0 +1,107 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { ColorSchemeSelector } from "color-scheme.slint"; +import { Palette } from "std-widgets.slint"; + +export struct TextStyle { + font-size: relative-font-size, + font-weight: int, +} + +export global FluentFontSettings { + out property body: { font-size: 14 * 0.0769rem, font-weight: FontWeight.normal }; + out property body-strong: { font-size: 14 * 0.0769rem, font-weight: FontWeight.semi-bold }; + out property title: { font-size: 28 * 0.0769rem, font-weight: FontWeight.semi-bold }; +} + +export global FluentPalette { + in-out property color-scheme: ColorSchemeSelector.color-scheme; + property dark-color-scheme: color-scheme == ColorScheme.unknown ? Palette.color-scheme == ColorScheme.dark : color-scheme == ColorScheme.dark; + + pure function accentify(default-color: color) -> color { + let accent-color = Palette.accent-foreground; + if (accent-color.alpha > 0) { + let default-lch = default-color.to-oklch(); + let accent-lch = accent-color.to-oklch(); + return oklch(default-lch.lightness, accent-lch.chroma, accent-lch.hue); + } + return default-color; + } + + // base palette + out property background: dark-color-scheme ? #1C1C1C : #FAFAFA; + out property foreground: dark-color-scheme ? #FFFFFF : #000000E6; + out property alternate-background: dark-color-scheme ? #2C2C2C : #f0f0f0; + out property alternate-foreground: dark-color-scheme ? #FFFFFF : #000000E6; + out property control-background: dark-color-scheme ? #FFFFFF0F : #FFFFFFB3; + out property control-foreground: dark-color-scheme ? #FFFFFF : #000000E6; + out property accent-background: dark-color-scheme ? accentify(#60CDFF) : accentify(#005FB8); + out property accent-foreground: dark-color-scheme ? #000000 : #FFFFFF; + out property selection-background: accentify(#0078D4); + out property selection-foreground: dark-color-scheme ? #000000 : #FFFFFF; + out property border: dark-color-scheme ? #FFFFFF14 : #00000073; + + // additional palette + out property secondary-accent-background: accent-background.with-alpha(0.9); + out property tertiary-accent-background: accent-background.with-alpha(0.8); + out property accent-disabled: dark-color-scheme ? #FFFFFF29 : #00000038; + out property accent-control-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF14 90.67%, #00000024 100%) : @linear-gradient(180deg, #FFFFFF14 90.67%, #00000066 100%); + out property control-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF17 0%, #00000012 8.33%) : @linear-gradient(180deg, #0000000F 90.58%, #00000029 100%); + out property text-accent-foreground-secondary: dark-color-scheme ? #00000080 : #FFFFFFB3; + out property text-accent-foreground-disabled: dark-color-scheme ? #FFFFFF87 : #FFFFFF; + out property text-secondary: dark-color-scheme ? #FFFFFFC9 : #00000099; + out property text-tertiary: dark-color-scheme ? #FFFFFF8A : #00000073; + out property text-disabled: dark-color-scheme ? #FFFFFF5E : #0000005E; + out property text-control-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF14 99.98%, #FFFFFF8A 100%, #FFFFFF8A 100%) : @linear-gradient(180deg, #0000000F 99.99%, #00000073 100%, #00000073 100%); + out property control-secondary: dark-color-scheme ? #FFFFFF14 : #F9F9F980; + out property control-tertiary: dark-color-scheme ? #FFFFFF08 : #F9F9F94D; + out property control-disabled: dark-color-scheme ? #FFFFFF0A : #F9F9F94D; + out property control-alt-secondary: dark-color-scheme ? #0000001A : #00000005; + out property control-alt-tertiary: dark-color-scheme ? #FFFFFF0A : #0000000F; + out property control-alt-quartiary: dark-color-scheme ? #FFFFFF12 : #00000017; + out property control-alt-disabled: transparent; + out property control-strong-stroke: dark-color-scheme ? #FFFFFF99 : #00000099; + out property control-strong-stroke-disabled: dark-color-scheme ? #FFFFFF29 : #00000038; + out property control-solid: dark-color-scheme ? #454545 : #FFFFFF; + out property circle-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF17 0%, #FFFFFF12 100%) : @linear-gradient(180deg, #0000000F 0%, #00000029 100%); + out property control-input-active: dark-color-scheme ? #1E1E1EB3 : #FFFFFF; + out property focus-stroke-inner: dark-color-scheme ? #000000B3 : #FFFFFF; + out property focus-stroke-outer: dark-color-scheme ? #FFFFFF : #000000E6; + out property control-background-stroke-flyout: dark-color-scheme ? #00000033 : #0000000F; + out property sub-title-secondary: dark-color-scheme ? #FFFFFF0F : #0000000A; + out property sub-title-tertiary: dark-color-scheme ? #FFFFFF0A : #00000005; + out property shadow: dark-color-scheme ? #00000042 : #00000024; + out property subtle: dark-color-scheme ? #FFFFFF0F : #0000000A; + out property subtle-secondary: dark-color-scheme ? #FFFFFF0F : #0000000A; + out property subtle-tertiary: dark-color-scheme ? #FFFFFF0A : #00000005; + out property divider: dark-color-scheme ? #FFFFFF14 : #00000014; + out property layer-on-mica-base-alt: dark-color-scheme ? #3A3A3A73 : #FFFFFFB3; + out property layer-on-mica-base-alt-secondary: dark-color-scheme ? #FFFFFF0F : #0000000A; + out property card-stroke: dark-color-scheme ? #0000001A : #0000000F; + out property state: dark-color-scheme ? #ffffff : #000000; + out property state-secondary: dark-color-scheme ? #000000 : #ffffff; +} + +export global Icons { + out property arrow-down: @image-url("_arrow-down.svg"); + out property arrow-up: @image-url("_arrow-up.svg"); + out property check-mark: @image-url("_check-mark.svg"); + out property chevron-down: @image-url("_chevron-down.svg"); + out property chevron-up: @image-url("_chevron-up.svg"); + out property down: @image-url("_down.svg"); + out property dropdown: @image-url("_dropdown.svg"); + out property left: @image-url("_left.svg"); + out property right: @image-url("_right.svg"); + out property up: @image-url("_up.svg"); + out property keyboard: @image-url("_keyboard.svg"); + out property clock: @image-url("_clock.svg"); + out property arrow-back: @image-url("_arrow_back.svg"); + out property arrow-forward: @image-url("_arrow_forward.svg"); + out property edit: @image-url("_edit.svg"); + out property calendar: @image-url("_calendar.svg"); +} + +export global FluentSizeSettings { + out property item-height: 40px; +} \ No newline at end of file diff --git a/ui/widgets/interfaces/lineedit.slint b/ui/widgets/interfaces/lineedit.slint new file mode 100644 index 0000000..e95c7a8 --- /dev/null +++ b/ui/widgets/interfaces/lineedit.slint @@ -0,0 +1,29 @@ +// Copyright © SixtyFPS GmbH , Copyright © 2025 Klarälvdalens Datakonsult AB, a KDAB Group company , author Nathan Collins +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export interface LineEdit { + in property enabled: true; + in property font-family: ""; + in property font-italic: false; + in property font-size: 0px; + in property horizontal-alignment: TextHorizontalAlignment.left; + in property input-type: InputType.text; + in property placeholder-text: ""; + in property read-only: false; + + out property has-focus: false; + + in-out property text: ""; + + callback accepted(text: string); + callback edited(text: string); + callback key-pressed(event: KeyEvent) -> EventResult; + callback key-released(event: KeyEvent) -> EventResult; + + public function set-selection-offsets(start: int, end: int); + public function select-all(); + public function clear-selection(); + public function cut(); + public function copy(); + public function paste(); +} \ No newline at end of file diff --git a/ui/window.slint b/ui/window.slint new file mode 100644 index 0000000..63314fd --- /dev/null +++ b/ui/window.slint @@ -0,0 +1,513 @@ +import { ScrollView, Button } from "std-widgets.slint"; +import { Data, LoginOptionTileData } from "data.slint"; +import { LoginOptionTile } from "envtile.slint"; + +import { LineEdit } from "widgets/fluent/lineedit.slint"; + +import { IconButton } from "iconbutton.slint"; + +export { Data as GreeterData } from "data.slint"; + +export component GreeterDisplay inherits Window { + default-font-size: root.width < root.height ? root.width * 0.01 : root.height * 0.01; + + in-out property selected_index <=> Data.selected_index; + + in-out property error_count <=> Data.error_count; + + in-out property error_content <=> error_popup.content; + + in property <[LoginOptionTileData]> tiles; + in property default_username; + + out property background-toggle; + + public function set_error(message: string) { + error_popup.content = message; + error_count += 1; + error_popup.opacity = 1.0; + error_popup_timer.start(); + error_popup_timer.restart(); + } + + public function switch_background() { + background-toggle = !background-toggle; + background_timer.start(); + clear_inactive_wallpaper_timer.start(); + background_timer.restart(); + clear_inactive_wallpaper_timer.restart(); + } + + full-screen: true; + min-width: 640px; + min-height: 480px; + + TouchArea { + clicked => { + confirm_prompt.visible = false; + } + + backdrop := Rectangle { // Almost black backdrop, in case background can't load + background: rgba(20, 20, 40); + z: -3; + } + + background := Image { // Image background + z: -2; + animate opacity { + duration: 1000ms; + easing: ease-in-out; + } + + source: Data.wallpaper-image; + image-fit: ImageFit.cover; + + width: parent.width; + height: parent.height; + + opacity: background-toggle && self.source.width > 0 ? 1.0 : 0.0; + background_timer := Timer { + interval: Data.wallpaper-cooldown * 1s; + running: true; + triggered() => { + Data.get-new-wallpaper(); + self.running = false; + } + } + + clear_inactive_wallpaper_timer := Timer { + interval: 1200ms; + running: false; + triggered() => { + Data.clear-inactive-wallpaper(); + self.running = false; + } + } + } + + background_1 := Image { // Background (secondary), needed for smooth opacity transitions + z: -2; + animate opacity { + duration: 1000ms; + easing: ease-in-out; + } + + source: Data.wallpaper-image-1; + image-fit: ImageFit.cover; + + width: parent.width; + height: parent.height; + + opacity: !background-toggle && self.source.width > 0 ? 1.0 : 0.0; + } + + background_overlay := Rectangle { // Background overlay + background: rgba(0, 0, 0, 0.5); + opacity: background.opacity > 0 || background_1.opacity > 0 ? 1.0 : 0.0; + z: -1; + } + + image_info := Rectangle { + opacity: 0.6; + x: 1rem; + y: root.height - 7.5rem; + height: 6.5rem; + width: parent.width * 0.33; + + private property show_extended; + VerticalLayout { + alignment: end; + spacing: 1rem; + img_link_info := HorizontalLayout { + visible: show_extended; + spacing: 0.5rem; + + Image { + source: @image-url("icons/link.svg"); + height: 1.25rem; + width: 1.25rem; + } + + Text { + text: Data.wallpaper-url; + font-size: 1.25rem; + font-family: "monospace"; + color: rgba(240, 240, 240, 1); + } + } + + img_text_info := HorizontalLayout { + visible: show_extended; + spacing: 0.5rem; + + Image { + source: @image-url("icons/text.svg"); + height: 1.25rem; + width: 1.25rem; + } + + Text { + text: Data.wallpaper-alt; + font-size: 1.25rem; + font-family: "monospace"; + color: rgba(240, 240, 240, 1); + } + } + + img_author_info := HorizontalLayout { + visible: true; + spacing: 0.5rem; + + Image { + source: @image-url("icons/person.svg"); + height: 1.25rem; + width: 1.25rem; + } + + Text { + text: Data.wallpaper-author; + font-size: 1.25rem; + font-family: "monospace"; + color: Data.wallpaper-author == "Error" ? rgba(255, 0, 0, 1) : rgba(240, 240, 240, 1); + } + } + } + + TouchArea { + height: parent.height; + width: parent.width; + clicked => { + show_extended = !show_extended + } + } + } + + login_manager_underlay := Rectangle { + background: rgba(0, 0, 0, 0.5); + drop-shadow-blur: 4rem; + height: parent.height; + z: -1; + width: max(parent.width * 0.33, 640px); + + VerticalLayout { + height: parent.height; + width: parent.width; + + clock := VerticalLayout { + alignment: center; + height: parent.height * 0.15; + HorizontalLayout { + width: parent.width; + alignment: center; + Rectangle { + y: 10rem; + + width: 32rem; + height: 12rem; + + VerticalLayout { + padding-top: 1rem; + Rectangle { + Text { + x: time_text.x - 0.1rem; // Offset + y: time_text.y + 0.1rem; // Offset + text: Data.current-time; + font-size: 6.4rem; + horizontal-alignment: center; + color: rgba(0, 0, 0, 1); + } + time_text := Text { + text: Data.current-time; + font-size: 6.4rem; + horizontal-alignment: center; + } + } + Rectangle { + Text { + x: date_text.x - 0.1rem; // Offset + y: date_text.y + 0.1rem; // Offset + text: date_text.text; + font-size: date_text.font-size; + horizontal-alignment: date_text.horizontal-alignment; + color: rgba(0, 0, 0, 1); + } + date_text := Text { + text: Data.current-date; + font-size: 1.6rem; + horizontal-alignment: center; + } + } + } + } + } + } + login_model := VerticalLayout { + alignment: center; + height: parent.height * 0.70; + width: parent.width; + + spacing: 1rem; + + init_timer := Timer { + interval: 0s; + running: true; + triggered => { + if (!Data.has-default-username) { + username_box.focus(); + } else { + password_box.focus(); + } + self.running = false; + } + } + + environment_selector := FocusScope { + height: max(parent.width * 0.2, parent.height * 0.2); + width: parent.width; + key-pressed(event) => { + if (event.text == Key.LeftArrow || event.text == "a" || event.text == "A") { + if (Data.selected_index == 0) { + Data.selected_index = tiles.length - 1; + } else { + Data.selected_index -= 1; + } + accept + } else if (event.text == Key.RightArrow || event.text == "d" || event.text == "D") { + if (Data.selected_index + 1 == tiles.length) { + Data.selected_index = 0; + } else { + Data.selected_index += 1; + } + accept + } + reject + } + + scroll_view := ScrollView { + height: parent.height; + width: min(((self.height + 1rem) * tiles.length) - 1rem, parent.width); + + animate viewport-x { duration: 250ms; } + + viewport-width: ((self.height + 1rem) * tiles.length) - 1rem; +// viewport-x: -(Data.selected_index * self.height) + (self.height / 2); + viewport-x: (self.viewport-width - self.height) / 2 - (Data.selected_index * (self.height)); + + //x: self.viewport-width < self.width ? ((self.width - self.viewport-width) / 2) : 0; + + scroll_layout := HorizontalLayout { + height: parent.height; + for tile[i] in tiles: LoginOptionTile { + width: parent.height; + height: parent.height; + icon: tile.image; + label: tile.name; + index: i; + has_focus: environment_selector.has-focus; + on_click => { + Data.selected_index = self.index; + } + } + } + } + } + // Username field + HorizontalLayout { + width: parent.width; + height: 2.5rem; + alignment: center; + username_box := LineEdit { + font-size: 2rem; + font-family: "monospace"; + width: parent.width * 0.45; + placeholder-text: "Username"; + text: default_username; + border-color: rgba(185, 15, 220, 0.85); + + on-focus-change => { + Data.check-lock-states() + } + key-released => { + Data.check-lock-states(); + return EventResult.accept; + } + accepted(text) => { + Data.login(username_box.text, password_box.text) + } + } + } + // Password field + HorizontalLayout { + width: parent.width; + height: 2.5rem; + alignment: center; + password_box := LineEdit { + font-size: 2rem; + font-family: "monospace"; + width: parent.width * 0.45; + placeholder-text: "Password"; + input-type: password; + border-color: rgba(185, 15, 220, 0.85); + on-focus-change => { + Data.check-lock-states() + } + key-released => { + Data.check-lock-states(); + return EventResult.accept; + } + accepted(text) => { + Data.login(username_box.text, password_box.text) + } + } + } + // Submit button + HorizontalLayout { + width: parent.width; + height: 2.5rem; + alignment: center; + IconButton { + width: parent.width * 0.45; //min(parent.width * 0.85, 32rem); + icon: @image-url("icons/right_arrow.svg"); + icon-width: 4rem; + icon-height: self.icon-width/3; + icon-align: LayoutAlignment.end; + text: "Login"; + font-size: 1.5rem; + clicked => { + Data.login(username_box.text, password_box.text); + } + } + } + } + power_model := VerticalLayout { + height: parent.height * 0.15; + width: parent.width; + + confirm_prompt := Text { + horizontal-alignment: center; + font-size: 1.5rem; + text: "Press again to confirm "; + visible: false; + vertical-alignment: bottom; + + confirm_prompt_timer := Timer { + interval: 5s; + running: false; + triggered() => { + confirm_prompt.visible = false; + self.running = false; + } + } + } + + HorizontalLayout { + width: parent.width; + alignment: center; + + Rectangle { + background: rgba(0, 0, 0, 0.6); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + width: 16.3rem; + height: 5.8rem; + // Power action buttons + HorizontalLayout { + private property lastaction; + + padding-top: 0.4rem; + width: parent.width; + alignment: center; + spacing: 0.5rem; + + function power_action(action: string, command: string) { + if (lastaction != action || confirm_prompt.visible == false) { + lastaction = action; + confirm_prompt.text = "Press again to confirm " + action; + confirm_prompt.visible = true; + confirm_prompt_timer.start(); + confirm_prompt_timer.restart(); + } else { + if (lastaction == action) { + Data.loginctl(command) + } else { + confirm_prompt.visible = false; + } + } + } + + IconButton { + width: 4.8rem; + height: 4.8rem; + icon: @image-url("icons/power.svg"); + icon-width: self.width * 0.5; + icon-height: self.width * 0.5; + clicked => { + power_action("poweroff", "poweroff") + } + } + + IconButton { + width: 4.8rem; + height: 4.8rem; + icon: @image-url("icons/restart.svg"); + icon-width: self.width * 0.5; + icon-height: self.width * 0.5; + clicked => { + power_action("restart", "reboot") + } + } + + IconButton { + width: 4.8rem; + height: 4.8rem; + icon: @image-url("icons/sleep.svg"); + icon-width: self.width * 0.5; + icon-height: self.width * 0.5; + clicked => { + power_action("sleep", "suspend") + } + } + } + } + } + } + } + } + + error_popup := Rectangle { + background: rgba(220, 50, 50, 0.85); + width: min(parent.width * 0.5, 64rem); + height: max(parent.height * 0.125, 6rem); + y: (parent.height * 0.025); + + opacity: 0.0; + + animate opacity { duration: 250ms; } + + in-out property content; + + Text { + color: rgba(240, 240, 240, 1); + width: parent.width * 0.85; + height: parent.height * 0.85; + vertical-alignment: center; + horizontal-alignment: center; + + font-family: "monospace"; + font-size: max(1rem, scroll_view.height * 0.125); + + text: "[" + Data.error_count + "] " + content; + } + } + + error_popup_timer := Timer { + interval: 5s; + running: false; + triggered() => { + error_popup.opacity = 0.0; + self.running = false; + } + } + } +}