Initial Commit

This commit is contained in:
CanadianBaconBoi 2026-05-08 20:11:51 +02:00
commit defe26acc3
33 changed files with 2023 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[env]
SLINT_ENABLE_EXPERIMENTAL_FEATURES = "1"

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/cache
Cargo.lock

8
.idea/.gitignore vendored Normal file
View File

@ -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

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

40
Cargo.toml Normal file
View File

@ -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" }

9
build.rs Normal file
View File

@ -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();
}

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "stable"

571
src/callbacks.rs Normal file
View File

@ -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<GreeterDisplay>, tiles: Rc<VecModel<LoginOptionTileData>>) {
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<bool> {
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<GreeterData<'static>>) {
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<GreeterDisplay>, data_weak: Weak<GreeterData<'static>>, cache_dir: PathBuf, config: &Config) { //TODO: Seperate this out into seperate methods, cleanup!!!
const MAX_RECENT_WALLPAPERS: usize = 10;
async fn read_lines<P>(filename: P) -> std::io::Result<Lines<BufReader<File>>>
where
P: AsRef<Path>,
{
let file = File::open(filename).await?;
Ok(BufReader::new(file).lines())
}
async fn write_lines<P>(
filename: P,
lines: VecDeque<String>,
) -> std::io::Result<()>
where
P: AsRef<Path>,
{
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<u8>)> {
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<GreeterDisplay>, data_weak: Weak<GreeterData<'static>>) {
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<VecModel<LoginOptionTileData>>,
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());
}

83
src/config.rs Normal file
View File

@ -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<String>,
pub(crate) environments: HashMap<String, LoginOptionTileConfig>,
pub(crate) pexels_api_key: Option<String>,
pub(crate) pexels_query: Option<String>,
#[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<LoginOptionTileData>, Option<usize>)> {
let mut ordered = self
.environments
.iter()
.collect::<Vec<(&String, &LoginOptionTileConfig)>>();
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<LoginOptionTileData> = 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))
}
}

48
src/input.rs Normal file
View File

@ -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<KeyboardLockStates> {
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")
}

99
src/main.rs Normal file
View File

@ -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::<GreeterData>();
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(())
}

32
src/timers.rs Normal file
View File

@ -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
}

33
ui/data.slint Normal file
View File

@ -0,0 +1,33 @@
export global Data {
in-out property <int> selected_index;
in property <bool> has-default-username;
in-out property <int> 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 <string> wallpaper-url;
in property <int> wallpaper-cooldown: 1;
in-out property <image> wallpaper-image;
in-out property <image> wallpaper-image-1;
in property <string> wallpaper-author;
in property <string> wallpaper-alt;
in-out property <bool> caps-lock-state;
in-out property <bool> num-lock-state;
in-out property <bool> scroll-lock-state;
in-out property <string> current-time;
in-out property <string> current-date;
}
export struct LoginOptionTileData { image: image, name: string, command: string}

59
ui/envtile.slint Normal file
View File

@ -0,0 +1,59 @@
import { Data } from "data.slint";
export component LoginOptionTile inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <int> index;
in property <bool> 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; }
}

70
ui/iconbutton.slint Normal file
View File

@ -0,0 +1,70 @@
import { Palette } from "std-widgets.slint";
export component IconButton inherits Rectangle {
in property <image> icon;
in property <length> icon-width: 20px;
in property <length> icon-height: 20px;
in property <LayoutAlignment> icon-align: LayoutAlignment.end;
in property <string> text;
in property <string> font-family: "monospace";
in property <bool> font-italic: false;
in property <length> font-size: 1rem;
in property <int> 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
}
}
}
}

1
ui/icons/link.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="482.136" height="482.135"><path d="M455.482 198.184 326.829 326.832c-35.535 35.54-93.108 35.54-128.646 0l-42.881-42.886 42.881-42.876 42.884 42.876c11.845 11.822 31.064 11.846 42.886 0l128.644-128.643c11.816-11.831 11.816-31.066 0-42.9l-42.881-42.881c-11.822-11.814-31.064-11.814-42.887 0l-45.928 45.936c-21.292-12.531-45.491-17.905-69.449-16.291l72.501-72.526c35.535-35.521 93.136-35.521 128.644 0l42.886 42.881c35.535 35.523 35.535 93.141-.001 128.662M201.206 366.698l-45.903 45.9c-11.845 11.846-31.064 11.817-42.881 0l-42.884-42.881c-11.845-11.821-11.845-31.041 0-42.886l128.646-128.648c11.819-11.814 31.069-11.814 42.884 0l42.886 42.886 42.876-42.886-42.876-42.881c-35.54-35.521-93.113-35.521-128.65 0L26.655 283.946c-35.538 35.545-35.538 93.146 0 128.652l42.883 42.882c35.51 35.54 93.11 35.54 128.646 0l72.496-72.499c-23.956 1.597-48.092-3.784-69.474-16.283" fill="#aaa"/></svg>

After

Width:  |  Height:  |  Size: 951 B

1
ui/icons/person.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" data-name="Layer 1" viewBox="0 0 32 32"><path d="m22.82 20.55-.63-.18c-1.06-.29-1.79-.51-1.91-1.75 2.83-3 2.79-5.67 2.73-8.47V9a7.1 7.1 0 0 0-7-7A7.1 7.1 0 0 0 9 9v1.15c-.06 2.8-.1 5.45 2.73 8.47-.12 1.24-.85 1.46-1.91 1.75l-.63.18C5.61 21.74 2 25 2 29a1 1 0 0 0 2 0c0-3 3-5.61 5.82-6.55.16-.06.34-.1.52-.15a4.11 4.11 0 0 0 3.11-2.3 5.4 5.4 0 0 0 5.1 0 4.11 4.11 0 0 0 3.11 2.35c.18.05.36.09.52.15C25 23.39 28 26 28 29a1 1 0 0 0 2 0c0-4-3.61-7.26-7.18-8.45m-9.36-3C10.9 15 10.94 12.86 11 10.18V9a5 5 0 0 1 10 0v1.18c0 2.68.09 4.8-2.47 7.36a3.58 3.58 0 0 1-5.07 0Z" style="fill:#aaa"/></svg>

After

Width:  |  Height:  |  Size: 655 B

1
ui/icons/power.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="104" height="104" fill="none" stroke="#aaa" stroke-linecap="round" viewBox="0 0 27.517 27.517"><path stroke-width="3" d="M6.59 4.072c-9.953 9.488-2.62 21.355 7.176 21.513 10.452.168 16.639-12.574 7.49-21.37"/><path stroke-width="4" d="m13.71 2.025.105 10.999"/></svg>

After

Width:  |  Height:  |  Size: 336 B

1
ui/icons/restart.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104"><path d="M52 8a43.7 43.7 0 0 0-23.52 6.848l-9.199-9.2v25.778H45.06l-10.235-10.23A34.9 34.9 0 0 1 52 16.68c19.473 0 35.316 15.843 35.316 35.32 0 4.637-.921 9.063-2.558 13.125l7.582 4.418A43.7 43.7 0 0 0 96 52C96 27.738 76.262 8 52 8m17.164 74.793c-5.086 2.86-10.922 4.523-17.164 4.523-19.473 0-35.316-15.843-35.316-35.316 0-4.383.84-8.559 2.304-12.434l-7.57-4.562A43.7 43.7 0 0 0 8 52c0 24.262 19.738 44 44 44 8.648 0 16.695-2.535 23.504-6.863l9.215 9.215V72.574H58.94Zm0 0" style="stroke:none;fill-rule:nonzero;fill:#aaa;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 610 B

1
ui/icons/right_arrow.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="213" height="71" viewBox="-0.709 -0.235 213 71"><path d="M0 26.488v17.656h167.747v26.487l44.143-35.315L167.747 0v26.488z" fill="#aaa"/></svg>

After

Width:  |  Height:  |  Size: 209 B

1
ui/icons/sleep.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104"><path d="M51.797 13C30.422 13.129 13 30.598 13 52c0 21.48 17.52 39 39 39 16.926 0 31.398-10.883 36.766-26l3.25-9.547-9.547 3.25c-2.551.867-5.043 1.422-7.719 1.422A24.3 24.3 0 0 1 50.375 35.75c0-5.562 2.031-10.637 5.281-14.828L61.75 13Zm-9.14 11.781c-1.235 3.457-2.032 7.074-2.032 10.969 0 18.36 14.707 33.242 32.906 33.922C68.195 76.227 61.145 81.25 52 81.25A29.18 29.18 0 0 1 22.75 52c0-12.902 8.363-23.309 19.906-27.219m0 0" style="stroke:none;fill-rule:nonzero;fill:#aaa;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 563 B

1
ui/icons/text.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="-80 60 385 300"><path d="M3.593 317.308c0 11.1-3.9 20.4-11.6 28.2s-17.1 11.6-28.2 11.6-20.4-3.9-28.2-11.6-11.6-17.1-11.6-28.2 3.9-20.4 11.6-28.2c7.7-7.7 17.1-11.6 28.2-11.6s20.4 3.9 28.2 11.6c7.7 7.8 11.6 17.2 11.6 28.2m0-106.1c0 11.1-3.9 20.4-11.6 28.2s-17.1 11.6-28.2 11.6-20.4-3.9-28.2-11.6-11.6-17.1-11.6-28.2 3.9-20.4 11.6-28.2 17.1-11.6 28.2-11.6 20.4 3.9 28.2 11.6 11.6 17.2 11.6 28.2m291.8 86.3v39.8c0 1.8-.7 3.4-2 4.7s-2.9 2-4.7 2h-252c-1.8 0-3.4-.7-4.7-2s-2-2.9-2-4.7v-39.8c0-1.8.7-3.4 2-4.7s2.9-2 4.7-2h252c1.8 0 3.4.7 4.7 2s2 2.9 2 4.7m-291.8-192.4c0 11.1-3.9 20.4-11.6 28.2s-17.1 11.6-28.2 11.6-20.4-3.9-28.2-11.6-11.6-17.1-11.6-28.2 3.9-20.4 11.6-28.2 17.1-11.6 28.2-11.6 20.4 3.9 28.2 11.6 11.6 17.2 11.6 28.2m291.8 86.2v39.8c0 1.8-.7 3.4-2 4.7s-2.9 2-4.7 2h-252c-1.8 0-3.4-.7-4.7-2s-2-2.9-2-4.7v-39.8c0-1.8.7-3.4 2-4.7s2.9-2 4.7-2h252c1.8 0 3.4.7 4.7 2s2 2.9 2 4.7m0-106.1v39.8c0 1.8-.7 3.4-2 4.7s-2.9 2-4.7 2h-252c-1.8 0-3.4-.7-4.7-2s-2-2.9-2-4.7v-39.8c0-1.8.7-3.4 2-4.7s2.9-2 4.7-2h252c1.8 0 3.4.7 4.7 2s2 2.9 2 4.7" fill="#aaa"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,177 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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 <bool> 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 <brush> placeholder-color;
in property <int> font-weight <=> text-input.font-weight;
in property <brush> text-color;
in property <color> selection-background-color <=> text-input.selection-background-color;
in property <color> selection-foreground-color <=> text-input.selection-foreground-color;
in property <length> 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 <length> 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 <string> text;
callback clear();
vertical-alignment: center;
TouchArea {
clicked => { root.clear(); }
}
}
export component LineEditPasswordIcon inherits Image {
in-out property <bool> show-password;
in property <image> show-password-image;
in property <image> 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();
}
}
}

View File

@ -0,0 +1,4 @@
// Copyright © 2026 Klarälvdalens Datakonsult AB, a KDAB Group company <info@kdab.com>, author Nathan Collins <nathan.collins@kdab.com>
// 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";

View File

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 670 B

View File

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M12 8.414 16.586 13 18 11.586l-6-6-6 6L7.414 13ZM6 18h12v-2H6Zm0 0" fill="#212121"/></svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@ -0,0 +1,7 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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 <ColorScheme> color-scheme: Palette.color-scheme;
}

View File

@ -0,0 +1,110 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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 <brush> 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;
}
}
}

View File

@ -0,0 +1,107 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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 <TextStyle> body: { font-size: 14 * 0.0769rem, font-weight: FontWeight.normal };
out property <TextStyle> body-strong: { font-size: 14 * 0.0769rem, font-weight: FontWeight.semi-bold };
out property <TextStyle> title: { font-size: 28 * 0.0769rem, font-weight: FontWeight.semi-bold };
}
export global FluentPalette {
in-out property <ColorScheme> color-scheme: ColorSchemeSelector.color-scheme;
property <bool> 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 <brush> background: dark-color-scheme ? #1C1C1C : #FAFAFA;
out property <brush> foreground: dark-color-scheme ? #FFFFFF : #000000E6;
out property <brush> alternate-background: dark-color-scheme ? #2C2C2C : #f0f0f0;
out property <brush> alternate-foreground: dark-color-scheme ? #FFFFFF : #000000E6;
out property <brush> control-background: dark-color-scheme ? #FFFFFF0F : #FFFFFFB3;
out property <brush> control-foreground: dark-color-scheme ? #FFFFFF : #000000E6;
out property <brush> accent-background: dark-color-scheme ? accentify(#60CDFF) : accentify(#005FB8);
out property <brush> accent-foreground: dark-color-scheme ? #000000 : #FFFFFF;
out property <brush> selection-background: accentify(#0078D4);
out property <brush> selection-foreground: dark-color-scheme ? #000000 : #FFFFFF;
out property <brush> border: dark-color-scheme ? #FFFFFF14 : #00000073;
// additional palette
out property <brush> secondary-accent-background: accent-background.with-alpha(0.9);
out property <brush> tertiary-accent-background: accent-background.with-alpha(0.8);
out property <brush> accent-disabled: dark-color-scheme ? #FFFFFF29 : #00000038;
out property <brush> accent-control-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF14 90.67%, #00000024 100%) : @linear-gradient(180deg, #FFFFFF14 90.67%, #00000066 100%);
out property <brush> control-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF17 0%, #00000012 8.33%) : @linear-gradient(180deg, #0000000F 90.58%, #00000029 100%);
out property <brush> text-accent-foreground-secondary: dark-color-scheme ? #00000080 : #FFFFFFB3;
out property <brush> text-accent-foreground-disabled: dark-color-scheme ? #FFFFFF87 : #FFFFFF;
out property <brush> text-secondary: dark-color-scheme ? #FFFFFFC9 : #00000099;
out property <brush> text-tertiary: dark-color-scheme ? #FFFFFF8A : #00000073;
out property <brush> text-disabled: dark-color-scheme ? #FFFFFF5E : #0000005E;
out property <brush> 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 <brush> control-secondary: dark-color-scheme ? #FFFFFF14 : #F9F9F980;
out property <brush> control-tertiary: dark-color-scheme ? #FFFFFF08 : #F9F9F94D;
out property <brush> control-disabled: dark-color-scheme ? #FFFFFF0A : #F9F9F94D;
out property <brush> control-alt-secondary: dark-color-scheme ? #0000001A : #00000005;
out property <brush> control-alt-tertiary: dark-color-scheme ? #FFFFFF0A : #0000000F;
out property <brush> control-alt-quartiary: dark-color-scheme ? #FFFFFF12 : #00000017;
out property <brush> control-alt-disabled: transparent;
out property <brush> control-strong-stroke: dark-color-scheme ? #FFFFFF99 : #00000099;
out property <brush> control-strong-stroke-disabled: dark-color-scheme ? #FFFFFF29 : #00000038;
out property <brush> control-solid: dark-color-scheme ? #454545 : #FFFFFF;
out property <brush> circle-border: dark-color-scheme ? @linear-gradient(180deg, #FFFFFF17 0%, #FFFFFF12 100%) : @linear-gradient(180deg, #0000000F 0%, #00000029 100%);
out property <brush> control-input-active: dark-color-scheme ? #1E1E1EB3 : #FFFFFF;
out property <brush> focus-stroke-inner: dark-color-scheme ? #000000B3 : #FFFFFF;
out property <brush> focus-stroke-outer: dark-color-scheme ? #FFFFFF : #000000E6;
out property <brush> control-background-stroke-flyout: dark-color-scheme ? #00000033 : #0000000F;
out property <brush> sub-title-secondary: dark-color-scheme ? #FFFFFF0F : #0000000A;
out property <brush> sub-title-tertiary: dark-color-scheme ? #FFFFFF0A : #00000005;
out property <brush> shadow: dark-color-scheme ? #00000042 : #00000024;
out property <brush> subtle: dark-color-scheme ? #FFFFFF0F : #0000000A;
out property <brush> subtle-secondary: dark-color-scheme ? #FFFFFF0F : #0000000A;
out property <brush> subtle-tertiary: dark-color-scheme ? #FFFFFF0A : #00000005;
out property <brush> divider: dark-color-scheme ? #FFFFFF14 : #00000014;
out property <brush> layer-on-mica-base-alt: dark-color-scheme ? #3A3A3A73 : #FFFFFFB3;
out property <brush> layer-on-mica-base-alt-secondary: dark-color-scheme ? #FFFFFF0F : #0000000A;
out property <brush> card-stroke: dark-color-scheme ? #0000001A : #0000000F;
out property <brush> state: dark-color-scheme ? #ffffff : #000000;
out property <brush> state-secondary: dark-color-scheme ? #000000 : #ffffff;
}
export global Icons {
out property <image> arrow-down: @image-url("_arrow-down.svg");
out property <image> arrow-up: @image-url("_arrow-up.svg");
out property <image> check-mark: @image-url("_check-mark.svg");
out property <image> chevron-down: @image-url("_chevron-down.svg");
out property <image> chevron-up: @image-url("_chevron-up.svg");
out property <image> down: @image-url("_down.svg");
out property <image> dropdown: @image-url("_dropdown.svg");
out property <image> left: @image-url("_left.svg");
out property <image> right: @image-url("_right.svg");
out property <image> up: @image-url("_up.svg");
out property <image> keyboard: @image-url("_keyboard.svg");
out property <image> clock: @image-url("_clock.svg");
out property <image> arrow-back: @image-url("_arrow_back.svg");
out property <image> arrow-forward: @image-url("_arrow_forward.svg");
out property <image> edit: @image-url("_edit.svg");
out property <image> calendar: @image-url("_calendar.svg");
}
export global FluentSizeSettings {
out property <length> item-height: 40px;
}

View File

@ -0,0 +1,29 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>, Copyright © 2025 Klarälvdalens Datakonsult AB, a KDAB Group company <info@kdab.com>, author Nathan Collins <nathan.collins@kdab.com>
// 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 <bool> enabled: true;
in property <string> font-family: "";
in property <bool> font-italic: false;
in property <length> font-size: 0px;
in property <TextHorizontalAlignment> horizontal-alignment: TextHorizontalAlignment.left;
in property <InputType> input-type: InputType.text;
in property <string> placeholder-text: "";
in property <bool> read-only: false;
out property <bool> has-focus: false;
in-out property <string> 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();
}

513
ui/window.slint Normal file
View File

@ -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 <int> selected_index <=> Data.selected_index;
in-out property <int> error_count <=> Data.error_count;
in-out property <string> error_content <=> error_popup.content;
in property <[LoginOptionTileData]> tiles;
in property <string> default_username;
out property <bool> 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 <bool> 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 <string> 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 <string> 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;
}
}
}
}