Initial Commit
2
.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[env]
|
||||||
|
SLINT_ENABLE_EXPERIMENTAL_FEATURES = "1"
|
||||||
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/cache
|
||||||
|
Cargo.lock
|
||||||
8
.idea/.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
571
src/callbacks.rs
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 |
177
ui/widgets/common/lineedit-base.slint
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ui/widgets/common/std-widget-interfaces.slint
Normal 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";
|
||||||
1
ui/widgets/fluent/_dismiss.svg
Normal 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 |
1
ui/widgets/fluent/_eye_hide.svg
Normal 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 |
1
ui/widgets/fluent/_eye_show.svg
Normal 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 |
1
ui/widgets/fluent/caps.svg
Normal 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 |
7
ui/widgets/fluent/color-scheme.slint
Normal 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;
|
||||||
|
}
|
||||||
110
ui/widgets/fluent/lineedit.slint
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
ui/widgets/fluent/styling.slint
Normal 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;
|
||||||
|
}
|
||||||
29
ui/widgets/interfaces/lineedit.slint
Normal 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
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||