diff --git a/kb-app-demo-desktop/Cargo.toml b/kb-app-demo-desktop/Cargo.toml index 564488c..845b103 100644 --- a/kb-app-demo-desktop/Cargo.toml +++ b/kb-app-demo-desktop/Cargo.toml @@ -9,8 +9,9 @@ license.workspace = true publish.workspace = true [lib] -name = "kb_app_demo_desktop" +name = "kb_app_demo_desktop_lib" path = "src/lib.rs" +crate-type = ["staticlib", "cdylib", "rlib"] [[bin]] name = "kb-app-demo-desktop" diff --git a/kb-app-demo-desktop/frontend/sass/splash.scss b/kb-app-demo-desktop/frontend/sass/splash.scss index a593bb0..a499c73 100644 --- a/kb-app-demo-desktop/frontend/sass/splash.scss +++ b/kb-app-demo-desktop/frontend/sass/splash.scss @@ -1,8 +1,117 @@ -/* file: kb-app-demo-desktop/frontend/sass/splash.scss */ -/* version: 1 */ -html, body { width: 100%; height: 100%; margin: 0; background: transparent; overflow: hidden; } -body { display: grid; place-items: center; font-family: sans-serif; } -#splash-container { width: 100%; height: 100%; display: grid; place-content: center; justify-items: center; gap: 1rem; opacity: 0; background: radial-gradient(circle, #1f2937, #030712); color: #f9fafb; } -#splash-container img { width: min(55vw, 420px); max-height: 55vh; object-fit: contain; } -#splash-container h1 { margin: 0; letter-spacing: .08em; } -#messages-container { min-height: 1.5rem; color: #d1d5db; } +// file: kb-app-demo-desktop/frontend/sass/splash.scss +// version: 2 + +@font-face { + font-family: 'Dos Amazigh'; + src: url('../fonts/DOS_Amazigh.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + background-color: transparent; + overflow: hidden; +} + +body { + display: flex; + justify-content: center; + align-items: center; + font-family: Arial, sans-serif; +} + +#splash-container { + position: relative; + width: 960px; + height: 637px; + opacity: 0; + will-change: opacity; +} + +#splash-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; +} + +#app-name { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Dos Amazigh', sans-serif; + font-size: 96px; + font-weight: bold; + color: #fff; + text-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + z-index: 2; + text-align: center; +} + +#debug-info, +#messages-container { + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +#debug-info::-webkit-scrollbar, +#messages-container::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +#debug-info { + position: absolute; + top: 0; + left: 0; + width: 50%; + max-height: 30%; + color: white; + font-family: monospace; + font-size: 12px; + background-color: rgba(0, 0, 0, 0.1); + padding: 8px; + z-index: 3; + box-sizing: border-box; +} + +#messages-container { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + max-height: 30%; + background-color: rgba(0, 0, 0, 0.1); + padding: 10px 10px 40px; + z-index: 2; + box-sizing: border-box; +} + +.splash-message { + margin-bottom: 8px; + padding: 6px 10px; + border-radius: 4px; + animation: fadeIn 0.3s ease-in-out; + font-weight: bold; + color: #fff; +} + +.splash-message.info { background-color: rgba(33, 150, 243, 0.2); } +.splash-message.warning { background-color: rgba(255, 152, 0, 0.2); } +.splash-message.error { background-color: rgba(244, 67, 54, 0.2); } +.splash-message.success { background-color: rgba(76, 175, 80, 0.2); } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/kb-app-demo-desktop/frontend/splash.html b/kb-app-demo-desktop/frontend/splash.html index 70c476a..90b34df 100644 --- a/kb-app-demo-desktop/frontend/splash.html +++ b/kb-app-demo-desktop/frontend/splash.html @@ -1,19 +1,23 @@ - - + + - - - - Chargement — Khadhroony Bot3 - - - -
- Khadhroony Bot3 -

Khadhroony Bot3

-
-
- - + + + + + Chargement — Khadhroony Bot3 + + + + +
+ Chargement de Khadhroony Bot3 +
Khadhroony
+
+
+
+ + + diff --git a/kb-app-demo-desktop/frontend/ts/splash.ts b/kb-app-demo-desktop/frontend/ts/splash.ts index 40089d9..103f35a 100644 --- a/kb-app-demo-desktop/frontend/ts/splash.ts +++ b/kb-app-demo-desktop/frontend/ts/splash.ts @@ -1,40 +1,112 @@ // file: kb-app-demo-desktop/frontend/ts/splash.ts -// version: 1 +// version: 2 import { listen } from "@tauri-apps/api/event"; +import type { SplashOrder } from "./bindings/kb_app_demo_desktop/splash/SplashOrder.ts"; -type SplashOrder = { - order: string; - msg: string | null; - status: string | null; - duration_ms: number | null; -}; +const defaultFadeDurationMs = 500; +let activeOpacityFrame: number | null = null; -const container = document.querySelector("#splash-container"); -const messages = document.querySelector("#messages-container"); - -function duration(order: SplashOrder): number { - if (typeof order.duration_ms !== "number" || !Number.isFinite(order.duration_ms) || order.duration_ms <= 0) { - return 500; +function normalizeDurationMs(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return defaultFadeDurationMs; } - return order.duration_ms; + return value; } -async function applyOrder(order: SplashOrder): Promise { - if (!container) { +function easeInOut(progress: number): number { + if (progress < 0.5) { + return 2 * progress * progress; + } + return 1 - Math.pow(-2 * progress + 2, 2) / 2; +} + +async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise { + if (activeOpacityFrame !== null) { + cancelAnimationFrame(activeOpacityFrame); + activeOpacityFrame = null; + } + element.style.opacity = fromOpacity.toString(); + element.style.willChange = "opacity"; + await new Promise(resolve => requestAnimationFrame(() => resolve())); + await new Promise(resolve => { + const startedAt = performance.now(); + const opacityDelta = toOpacity - fromOpacity; + const updateOpacity = (currentTime: number): void => { + const elapsedMs = currentTime - startedAt; + const rawProgress = Math.min(elapsedMs / durationMs, 1); + const nextOpacity = fromOpacity + opacityDelta * easeInOut(rawProgress); + element.style.opacity = nextOpacity.toString(); + if (rawProgress >= 1) { + element.style.opacity = toOpacity.toString(); + element.style.willChange = "auto"; + activeOpacityFrame = null; + resolve(); + return; + } + activeOpacityFrame = requestAnimationFrame(updateOpacity); + }; + activeOpacityFrame = requestAnimationFrame(updateOpacity); + }); +} + +function scrollToLatest(element: HTMLElement): void { + element.scrollTop = element.scrollHeight; +} + +function addLogMessage(message: string): void { + const debugInfo = document.getElementById("debug-info"); + if (!debugInfo) { return; } - if (order.order === "add_msg" && messages && order.msg) { - messages.textContent = order.msg; - messages.dataset.status = order.status ?? "info"; + const line = document.createElement("div"); + line.textContent = `${new Date().toLocaleTimeString()}: ${message}`; + debugInfo.appendChild(line); + scrollToLatest(debugInfo); +} + +function addMessage(message: string, status: string): void { + const messagesContainer = document.getElementById("messages-container"); + if (!messagesContainer) { return; } - if (order.order === "fadein" || order.order === "fadeout") { - container.style.transition = `opacity ${duration(order)}ms ease-in-out`; - container.style.opacity = order.order === "fadein" ? "1" : "0"; + const messageElement = document.createElement("div"); + messageElement.className = `splash-message ${status}`; + messageElement.textContent = message; + messagesContainer.appendChild(messageElement); + scrollToLatest(messagesContainer); +} + +async function handleSplashOrder(order: SplashOrder): Promise { + const container = document.getElementById("splash-container"); + if (order.order === "fadein" && container) { + await animateOpacity(container, 0, 1, normalizeDurationMs(order.duration_ms)); + return; + } + if (order.order === "fadeout" && container) { + await animateOpacity(container, 1, 0, normalizeDurationMs(order.duration_ms)); + return; + } + if (order.order === "add_msg" && order.msg && order.status) { + addMessage(order.msg, order.status); + return; + } + if (order.order === "add_log" && order.msg) { + addLogMessage(order.msg); } } -void listen("splash", event => { - void applyOrder(event.payload); +async function initializeSplash(): Promise { + const container = document.getElementById("splash-container"); + if (container) { + container.style.opacity = "0"; + container.style.willChange = "opacity"; + } + await listen("splash", event => { + void handleSplashOrder(event.payload); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + void initializeSplash(); }); diff --git a/kb-app-demo-desktop/src/lib.rs b/kb-app-demo-desktop/src/lib.rs index 9cdafa8..bebd90e 100644 --- a/kb-app-demo-desktop/src/lib.rs +++ b/kb-app-demo-desktop/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/lib.rs -// version: 1 +// version: 2 //! Tauri desktop demo application for `khadhroony-bot3`. @@ -9,85 +9,23 @@ mod constants; mod splash; - -use tauri::Manager; // rust-rules: trait-import +mod tauri; /// Runs the desktop demo application. -pub async fn run() -> kb_core::Result<()> { - let builder = tauri::Builder::default().setup(|app| { - let splash_window = match app.get_webview_window("splash") { - std::option::Option::Some(window) => window, - std::option::Option::None => { - return std::result::Result::Err(std::boxed::Box::new(std::io::Error::new( - std::io::ErrorKind::NotFound, - "splash window is missing", - ))); - }, - }; - let main_window = match app.get_webview_window("main") { - std::option::Option::Some(window) => window, - std::option::Option::None => { - return std::result::Result::Err(std::boxed::Box::new(std::io::Error::new( - std::io::ErrorKind::NotFound, - "main window is missing", - ))); - }, - }; - tauri::async_runtime::spawn(async move { - let started_at = tokio::time::Instant::now(); - crate::emit_splash_order( - &splash_window, - "fadein", - std::option::Option::None, - std::option::Option::None, - std::option::Option::Some(crate::SPLASH_FADE_MS), - ); - crate::emit_splash_order( - &splash_window, - "add_msg", - std::option::Option::Some("Initialisation de Khadhroony Bot3..."), - std::option::Option::Some("info"), - std::option::Option::None, - ); - crate::wait_until_minimum(started_at, crate::SPLASH_MINIMUM_MS).await; - crate::emit_splash_order( - &splash_window, - "fadeout", - std::option::Option::None, - std::option::Option::None, - std::option::Option::Some(crate::SPLASH_FADE_MS), - ); - tokio::time::sleep(std::time::Duration::from_millis(crate::SPLASH_CLOSE_WAIT_MS)).await; - if let std::result::Result::Err(error) = splash_window.destroy() { - tracing::error!(target: crate::TRACING_TARGET, "cannot destroy splash window: {error:?}"); - } - if let std::result::Result::Err(error) = main_window.show() { - tracing::error!(target: crate::TRACING_TARGET, "cannot show main window: {error:?}"); - } - if let std::result::Result::Err(error) = main_window.set_focus() { - tracing::error!(target: crate::TRACING_TARGET, "cannot focus main window: {error:?}"); - } - }); - return std::result::Result::Ok(()); - }); - let run_result = builder.run(tauri::generate_context!()); - return match run_result { - std::result::Result::Ok(()) => std::result::Result::Ok(()), - std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::tauri(format!( - "cannot run desktop demo application: {error:?}" - ))), - }; -} +pub use self::tauri::run; -/// Minimum startup splash duration before fade-out begins. -pub(crate) use self::splash::SPLASH_MINIMUM_MS; -/// Splash fade duration. -pub(crate) use self::splash::SPLASH_FADE_MS; /// Delay after fade-out before destroying the splash window. pub(crate) use self::splash::SPLASH_CLOSE_WAIT_MS; +/// Splash fade duration. +pub(crate) use self::splash::SPLASH_FADE_MS; +/// Minimum startup splash duration before fade-out begins. +pub(crate) use self::splash::SPLASH_MINIMUM_MS; +/// Command sent from Rust to the splash frontend. +pub(crate) use self::splash::SplashOrder; /// Emits one order to the splash frontend. pub(crate) use self::splash::emit_splash_order; /// Waits for the minimum splash duration. pub(crate) use self::splash::wait_until_minimum; +// Keep the canonical tracing target as the final facade export. /// Canonical tracing target for the desktop demo application. pub(crate) use self::constants::TRACING_TARGET; diff --git a/kb-app-demo-desktop/src/main.rs b/kb-app-demo-desktop/src/main.rs index 49be447..b440caa 100644 --- a/kb-app-demo-desktop/src/main.rs +++ b/kb-app-demo-desktop/src/main.rs @@ -31,7 +31,7 @@ async fn main() -> std::process::ExitCode { return std::process::ExitCode::FAILURE; } let _lock_file = lock_file; - let run_result = kb_app_demo_desktop::run().await; + let run_result = kb_app_demo_desktop_lib::run(); return match run_result { std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS, std::result::Result::Err(error) => { diff --git a/kb-app-demo-desktop/src/splash.rs b/kb-app-demo-desktop/src/splash.rs index 1ae2209..b69a608 100644 --- a/kb-app-demo-desktop/src/splash.rs +++ b/kb-app-demo-desktop/src/splash.rs @@ -7,11 +7,11 @@ use tauri::Emitter; // rust-rules: trait-import use ts_rs::TS; // rust-rules: derive-import /// Minimum splash duration before the main window is shown. -pub(crate) const SPLASH_MINIMUM_MS: u64 = 900; +pub(crate) const SPLASH_MINIMUM_MS: u64 = 3100; /// Splash fade duration. -pub(crate) const SPLASH_FADE_MS: u32 = 500; +pub(crate) const SPLASH_FADE_MS: u32 = 3000; /// Delay after fade-out before destroying the splash window. -pub(crate) const SPLASH_CLOSE_WAIT_MS: u64 = 550; +pub(crate) const SPLASH_CLOSE_WAIT_MS: u64 = 3100; /// Command sent from Rust to the splash frontend. #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)] @@ -35,7 +35,7 @@ pub(crate) fn emit_splash_order( status: std::option::Option<&str>, duration_ms: std::option::Option, ) { - let payload = SplashOrder { + let payload = crate::SplashOrder { order: order.to_string(), msg: msg.map(std::string::ToString::to_string), status: status.map(std::string::ToString::to_string), @@ -61,7 +61,7 @@ pub(crate) async fn wait_until_minimum(start: tokio::time::Instant, minimum_ms: mod tests { #[test] fn splash_order_serializes_duration_ms() { - let order = super::SplashOrder { + let order = crate::SplashOrder { order: "fadein".to_string(), msg: std::option::Option::None, status: std::option::Option::None, diff --git a/kb-app-demo-desktop/src/tauri.rs b/kb-app-demo-desktop/src/tauri.rs new file mode 100644 index 0000000..3297abd --- /dev/null +++ b/kb-app-demo-desktop/src/tauri.rs @@ -0,0 +1,73 @@ +// file: kb-app-demo-desktop/src/tauri.rs +// version: 1 + +//! Tauri runtime assembly and private command wrappers. + +use tauri::Manager; // rust-rules: trait-import + +/// Runs the desktop demo application. +pub fn run() -> kb_core::Result<()> { + let builder = tauri::Builder::default().setup(|app| { + let splash_window = match app.get_webview_window("splash") { + std::option::Option::Some(window) => window, + std::option::Option::None => { + return std::result::Result::Err(std::boxed::Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "splash window is missing", + ))); + }, + }; + let main_window = match app.get_webview_window("main") { + std::option::Option::Some(window) => window, + std::option::Option::None => { + return std::result::Result::Err(std::boxed::Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "main window is missing", + ))); + }, + }; + tauri::async_runtime::spawn(async move { + let started_at = tokio::time::Instant::now(); + crate::emit_splash_order( + &splash_window, + "fadein", + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(crate::SPLASH_FADE_MS), + ); + crate::emit_splash_order( + &splash_window, + "add_msg", + std::option::Option::Some("Initialisation de Khadhroony Bot3..."), + std::option::Option::Some("info"), + std::option::Option::None, + ); + crate::wait_until_minimum(started_at, crate::SPLASH_MINIMUM_MS).await; + crate::emit_splash_order( + &splash_window, + "fadeout", + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(crate::SPLASH_FADE_MS), + ); + tokio::time::sleep(std::time::Duration::from_millis(crate::SPLASH_CLOSE_WAIT_MS)).await; + if let std::result::Result::Err(error) = splash_window.destroy() { + tracing::error!(target: crate::TRACING_TARGET, "cannot destroy splash window: {error:?}"); + } + if let std::result::Result::Err(error) = main_window.show() { + tracing::error!(target: crate::TRACING_TARGET, "cannot show main window: {error:?}"); + } + if let std::result::Result::Err(error) = main_window.set_focus() { + tracing::error!(target: crate::TRACING_TARGET, "cannot focus main window: {error:?}"); + } + }); + return std::result::Result::Ok(()); + }); + let run_result = builder.run(tauri::generate_context!()); + return match run_result { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::tauri(format!( + "cannot run desktop demo application: {error:?}" + ))), + }; +} diff --git a/kb-lib/src/model/solana.rs b/kb-lib/src/model/solana.rs index 2615cdd..f22917c 100644 --- a/kb-lib/src/model/solana.rs +++ b/kb-lib/src/model/solana.rs @@ -7,7 +7,7 @@ use ts_rs::TS; // rust-rules: derive-import /// Solana transaction signature. #[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)] -#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/Signature.ts")] +#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/MdSignature.ts")] pub struct MdSignature(pub std::string::String); /// Solana slot. @@ -24,23 +24,23 @@ pub struct MdSignature(pub std::string::String); serde::Serialize, TS, )] -#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/Slot.ts")] +#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/MdSlot.ts")] pub struct MdSlot(pub u64); /// Solana program id. #[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)] -#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/ProgramId.ts")] +#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/MdProgramId.ts")] pub struct MdProgramId(pub std::string::String); /// Solana public key. #[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)] -#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/Pubkey.ts")] +#[ts(export, export_to = "../frontend/ts/bindings/kb_lib/model/solana/MdPubkey.ts")] pub struct MdPubkey(pub std::string::String); /// Stable top-level or inner instruction path. #[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)] #[ts( export, - export_to = "../frontend/ts/bindings/kb_lib/model/solana/InstructionPath.ts" + export_to = "../frontend/ts/bindings/kb_lib/model/solana/MdInstructionPath.ts" )] pub struct MdInstructionPath(pub std::string::String);