use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_f32, expect_string};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const BAR_HEIGHT: u32 = 4;
const BAR_MARGIN: u32 = 24;
const TITLE_GAP: u32 = 24;
pub struct SplashScreen {
base: BaseWidget,
title: String,
subtitle: String,
progress: Option<f32>,
skippable: bool,
pub finished: Signal1<String>,
pub skipped: Signal1<String>,
}
impl SplashScreen {
pub fn new(geometry: Rect, title: impl Into<String>) -> Self {
Self {
base: BaseWidget::new(WidgetKind::SplashScreen, geometry, "SplashScreen"),
title: title.into(),
subtitle: String::new(),
progress: None,
skippable: false,
finished: Signal1::new(),
skipped: Signal1::new(),
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
self.base.request_redraw();
}
pub fn subtitle(&self) -> &str {
&self.subtitle
}
pub fn set_subtitle(&mut self, subtitle: impl Into<String>) {
self.subtitle = subtitle.into();
self.base.request_redraw();
}
pub fn progress(&self) -> Option<f32> {
self.progress
}
pub fn set_progress(&mut self, progress: Option<f32>) {
self.progress =
progress.map(|value| if value.is_nan() { 0.0 } else { value.clamp(0.0, 1.0) });
self.base.request_redraw();
}
pub fn is_skippable(&self) -> bool {
self.skippable
}
pub fn set_skippable(&mut self, skippable: bool) {
self.skippable = skippable;
self.base.request_redraw();
}
pub fn finish(&mut self) {
self.finished.emit(self.title.clone());
}
fn skip_rect(&self) -> Option<Rect> {
if !self.skippable {
return None;
}
let rect = self.geometry();
let width = 64.min(rect.width);
let height = 28.min(rect.height);
Some(Rect::new(
rect.x + rect.width as i32 - width as i32 - BAR_MARGIN as i32,
rect.y + rect.height as i32 - height as i32 - BAR_MARGIN as i32 - BAR_HEIGHT as i32 - 8,
width,
height,
))
}
fn is_over_skip(&self, pos: Point) -> bool {
self.skip_rect().is_some_and(|skip| {
pos.x >= skip.x
&& pos.x < skip.x + skip.width as i32
&& pos.y >= skip.y
&& pos.y < skip.y + skip.height as i32
})
}
fn bar_rect(&self) -> Option<Rect> {
let _ = self.progress?;
let rect = self.geometry();
if rect.width <= BAR_MARGIN * 2 {
return None;
}
Some(Rect::new(
rect.x + BAR_MARGIN as i32,
rect.y + rect.height as i32 - BAR_MARGIN as i32 - BAR_HEIGHT as i32,
rect.width - BAR_MARGIN * 2,
BAR_HEIGHT,
))
}
}
impl Widget for SplashScreen {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(480, 320)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for SplashScreen {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title.clone())),
"subtitle" => Ok(CapabilityValue::String(self.subtitle.clone())),
"progress" => match self.progress {
Some(value) => Ok(CapabilityValue::Float(f64::from(value))),
None => Ok(CapabilityValue::Null),
},
"skippable" => Ok(CapabilityValue::Bool(self.skippable)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
"subtitle" => {
self.set_subtitle(expect_string(value)?);
Ok(())
}
"progress" => match value {
CapabilityValue::Null => {
self.set_progress(None);
Ok(())
}
other => {
self.set_progress(Some(expect_f32(other)?));
Ok(())
}
},
"skippable" => {
self.set_skippable(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["title", "subtitle", "progress", "skippable", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"finish" => {
self.finish();
Ok(())
}
"set_progress" | "set_title" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for SplashScreen {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1 } if self.is_over_skip(*pos) => {
self.skipped.emit(self.title.clone());
}
Event::KeyPress { key: 27, modifiers: _ } if self.skippable => {
self.skipped.emit(self.title.clone());
}
_ => {}
}
}
}
impl Draw for SplashScreen {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
context.fill_rect(rect, Color::rgb(28, 34, 45));
let centre_x = rect.x + (rect.width / 2) as i32;
let mut text_y = rect.y + (rect.height as i32 / 2) - 24;
let logo = 48.min(rect.width).min(rect.height / 3);
if logo >= 16 {
context.fill_rect(
Rect::new(centre_x - logo as i32 / 2, text_y - logo as i32 - 16, logo, logo),
Color::rgb(66, 133, 214),
);
text_y += 8;
}
context.draw_text(
Point::new(centre_x, text_y),
&self.title,
&Font::default(),
Color::rgb(238, 242, 248),
HorizontalAlignment::Center,
);
if !self.subtitle.is_empty() {
context.draw_text(
Point::new(centre_x, text_y + TITLE_GAP as i32 - 2),
&self.subtitle,
&Font::default(),
Color::rgb(148, 160, 178),
HorizontalAlignment::Center,
);
}
if let (Some(bar), Some(progress)) = (self.bar_rect(), self.progress) {
context.fill_rect(bar, Color::rgb(52, 60, 74));
let filled = (bar.width as f32 * progress).round() as u32;
if filled > 0 {
context.fill_rect(
Rect::new(bar.x, bar.y, filled.min(bar.width), bar.height),
Color::rgb(66, 133, 214),
);
}
}
if let Some(skip) = self.skip_rect() {
context.draw_rect(skip, Color::rgb(92, 103, 120));
context.draw_text(
Point::new(skip.x + skip.width as i32 / 2, skip.y + (skip.height as i32 + 12) / 2),
"Skip",
&Font::default(),
Color::rgb(180, 190, 205),
HorizontalAlignment::Center,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn screen(width: u32, height: u32) -> SplashScreen {
SplashScreen::new(Rect::new(0, 0, width, height), "Booting")
}
#[test]
fn new_screen_is_indeterminate() {
let splash = screen(320, 240);
assert_eq!(splash.progress(), None);
assert_eq!(splash.bar_rect(), None, "no progress means no bar is reserved");
}
#[test]
fn setting_progress_draws_a_bar_and_clamps() {
let mut splash = screen(320, 240);
splash.set_progress(Some(0.5));
assert_eq!(splash.progress(), Some(0.5));
assert!(splash.bar_rect().is_some(), "a determinate screen reserves the bar");
splash.set_progress(Some(4.0));
assert_eq!(splash.progress(), Some(1.0), "over-unity progress clamps");
splash.set_progress(Some(-2.0));
assert_eq!(splash.progress(), Some(0.0), "negative progress clamps");
}
#[test]
fn nan_progress_is_treated_as_zero_not_as_an_arbitrary_end() {
let mut splash = screen(320, 240);
splash.set_progress(Some(f32::NAN));
assert_eq!(splash.progress(), Some(0.0));
}
#[test]
fn progress_can_return_to_indeterminate() {
let mut splash = screen(320, 240);
splash.set_progress(Some(0.75));
splash.set_progress(None);
assert_eq!(splash.progress(), None);
assert_eq!(splash.bar_rect(), None);
}
#[test]
fn finish_emits_with_the_title_and_does_not_hide() {
let mut splash = screen(320, 240);
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
splash.finished.connect(move |title| {
sink.lock().expect("signal sink poisoned").push(title.as_ref().clone());
});
splash.finish();
assert_eq!(seen.lock().expect("lock").as_slice(), ["Booting"]);
assert!(splash.base.is_visible(), "finishing must not hide the control itself");
}
#[test]
fn skip_is_off_by_default_and_escape_does_nothing() {
let mut splash = screen(320, 240);
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
splash.skipped.connect(move |title| {
sink.lock().expect("signal sink poisoned").push(title.as_ref().clone());
});
assert!(!splash.is_skippable());
assert!(splash.skip_rect().is_none(), "no affordance is reserved when unskippable");
splash.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert!(seen.lock().expect("lock").is_empty(), "Escape must not skip");
splash.set_skippable(true);
splash.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert_eq!(seen.lock().expect("lock").as_slice(), ["Booting"]);
}
#[test]
fn clicking_the_skip_area_emits_but_clicking_elsewhere_does_not() {
let mut splash = screen(400, 300);
splash.set_skippable(true);
let skip = splash.skip_rect().expect("skippable screens reserve the area");
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
splash.skipped.connect(move |title| {
sink.lock().expect("signal sink poisoned").push(title.as_ref().clone());
});
splash.handle_event(&Event::MousePress {
pos: Point::new(skip.x + skip.width as i32 / 2, skip.y + skip.height as i32 / 2),
button: 1,
});
assert_eq!(seen.lock().expect("lock").as_slice(), ["Booting"]);
splash.handle_event(&Event::MousePress { pos: Point::new(5, 5), button: 1 });
assert_eq!(seen.lock().expect("lock").len(), 1, "a click away from Skip is ignored");
}
#[test]
fn properties_round_trip_through_the_contract() {
let mut splash = screen(320, 240);
assert!(splash.set("title", CapabilityValue::String("Init".to_string())).is_ok());
assert_eq!(splash.get("title").expect("readable"), CapabilityValue::String("Init".into()));
assert!(splash.set("subtitle", CapabilityValue::String("loading".to_string())).is_ok());
assert_eq!(splash.subtitle(), "loading");
assert!(splash.set("progress", CapabilityValue::Float(0.25)).is_ok());
assert_eq!(splash.get("progress").expect("readable"), CapabilityValue::Float(0.25));
assert!(splash.set("progress", CapabilityValue::Null).is_ok());
assert_eq!(splash.get("progress").expect("readable"), CapabilityValue::Null);
assert!(splash.set("skippable", CapabilityValue::Bool(true)).is_ok());
assert!(splash.is_skippable());
}
#[test]
fn a_mismatched_value_kind_is_refused_rather_than_coerced() {
let mut splash = screen(320, 240);
assert_eq!(
splash.set("title", CapabilityValue::Bool(true)),
Err(CapabilityAccessError::TypeMismatch)
);
assert_eq!(
splash.set("skippable", CapabilityValue::String("yes".to_string())),
Err(CapabilityAccessError::TypeMismatch)
);
}
#[test]
fn the_declared_property_names_match_what_the_contract_answers() {
let splash = screen(320, 240);
for name in splash.property_names() {
assert!(
splash.get(name).is_ok(),
"{name} is declared but the contract refuses to read it"
);
}
}
}