diff --git a/Cargo.lock b/Cargo.lock index 138b837..e847e33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anyhow" version = "1.0.104" @@ -44,6 +50,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -93,6 +108,9 @@ dependencies = [ [[package]] name = "edera-sprout-build" version = "0.0.30" +dependencies = [ + "png", +] [[package]] name = "edera-sprout-config" @@ -122,6 +140,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "hex" version = "0.4.3" @@ -164,6 +202,39 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -274,6 +345,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "spin" version = "0.12.3" @@ -394,3 +471,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/Cargo.toml b/Cargo.toml index 003e258..5fc915b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ edition = "2024" [workspace.dependencies] bitflags = "2.13.2" log = "0.4.34" +png = "0.18.1" spin = "0.12.3" uefi-raw = "0.17.0" diff --git a/README.md b/README.md index 06ee5c6..17c71bf 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ $ sprout.efi --boot="Boot Xen" $ sprout.efi --autoconfigure # Use the basic boot menu instead of the simple one. $ sprout.efi --menu-style=basic +# Use the graphical boot menu, which can be used with the mouse. +$ sprout.efi --menu-style=graphical ``` ### Boot Linux from ESP diff --git a/crates/boot/build.rs b/crates/boot/build.rs index 8684da7..e4e28a9 100644 --- a/crates/boot/build.rs +++ b/crates/boot/build.rs @@ -1,7 +1,10 @@ -use edera_sprout_build::generate_sbat_module; +use edera_sprout_build::{generate_logo_module, generate_sbat_module}; /// Build script entry point for Sprout. fn main() { // Generate the sbat.generated.rs file. generate_sbat_module(); + + // Generate the logo.generated.rs file. + generate_logo_module(); } diff --git a/crates/boot/src/menu.rs b/crates/boot/src/menu.rs index 1e1dab1..519f273 100644 --- a/crates/boot/src/menu.rs +++ b/crates/boot/src/menu.rs @@ -1,13 +1,16 @@ use crate::entries::BootableEntry; use crate::menu::basic::BasicMenu; +use crate::menu::graphical::GraphicalMenu; use crate::menu::simple::SimpleMenu; use alloc::vec; +use alloc::vec::Vec; use anyhow::{Context, Result}; use core::time::Duration; use edera_sprout_config::MenuStyle; use eficore::bootloader_interface::BootloaderInterface; use eficore::platform::timer::PlatformTimer; use log::warn; +use uefi::Event; use uefi::ResultExt; use uefi::boot::TimerTrigger; use uefi::proto::console::text::{Input, Key}; @@ -16,6 +19,15 @@ use uefi_raw::table::boot::{EventType, Tpl}; /// basic: A menu that prints the entries and selects them by number. pub mod basic; +/// font: The bitmap font that the graphical menu draws text with. +mod font; + +/// graphical: A menu drawn on the graphics output that is used with the keyboard or the mouse. +pub mod graphical; + +/// logo: The Sprout logo that the graphical menu draws in a corner. +mod logo; + /// simple: A full-screen menu that selects entries with the arrow keys. pub mod simple; @@ -108,6 +120,52 @@ pub fn read_key(input: &mut Input, timeout: Option) -> Result) -> Result> { + // Timer event for timeout. + // SAFETY: The timer event creation allocated a timer pointer on the UEFI heap. + // This is validated safe as long as we are in boot services. + let timer_event = unsafe { + uefi::boot::create_event_ex(EventType::TIMER, Tpl::CALLBACK, None, None, None) + .context("unable to create timer event")? + }; + + // The timer is the last event so the indexes of the events that were passed are kept. + // The events are only borrowed, so the timer is the only one that is closed. + // SAFETY: The cloned event is only used to wait, and is not closed. + let mut all: Vec = events + .iter() + .map(|event| unsafe { event.unsafe_clone() }) + .collect(); + all.push(unsafe { timer_event.unsafe_clone() }); + + let result = (|| { + // Without a timeout, the timer is never set, so it never triggers. + if let Some(timeout) = timeout { + let trigger = TimerTrigger::Relative(timeout.min(MAX_TIMER_DURATION)); + uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?; + } + + let index = uefi::boot::wait_for_event(&all) + .discard_errdata() + .context("unable to wait for event")?; + Ok((index != events.len()).then_some(index)) + })(); + + // Close the timer event that we acquired, without masking an error from waiting. + if let Err(error) = uefi::boot::close_event(timer_event) { + if result.is_err() { + warn!("unable to close timer event: {}", error); + } else { + return Err(error).context("unable to close timer event"); + } + } + + result +} + /// Shows a boot menu of the specified `style` to select a bootable entry to boot. /// See [BootMenu::select] for how `timeout` is handled. pub fn select<'live>( @@ -129,14 +187,30 @@ pub fn select<'live>( match style { MenuStyle::Basic => BasicMenu.select(timeout, entries), - // The simple menu needs a console that can move the cursor and set colors, which - // not every console supports. If it fails, the basic menu is used instead. - MenuStyle::Simple => SimpleMenu.select(timeout, entries).or_else(|error| { + // The graphical menu needs a graphics output, which not every machine has. + // If it fails, the simple menu is used instead, which falls back to the basic menu. + MenuStyle::Graphical => GraphicalMenu.select(timeout, entries).or_else(|error| { warn!( - "unable to show the simple boot menu, using the basic menu: {:#}", + "unable to show the graphical boot menu, using the simple menu: {:#}", error ); - BasicMenu.select(timeout, entries) + select_simple(timeout, entries) }), + + // The simple menu needs a console that can move the cursor and set colors, which + // not every console supports. If it fails, the basic menu is used instead. + MenuStyle::Simple => select_simple(timeout, entries), } } + +/// Shows the simple boot menu, or the basic boot menu if the console does not support it. +/// See [BootMenu::select] for how `timeout` is handled. +fn select_simple(timeout: Option, entries: &[BootableEntry]) -> Result<&BootableEntry> { + SimpleMenu.select(timeout, entries).or_else(|error| { + warn!( + "unable to show the simple boot menu, using the basic menu: {:#}", + error + ); + BasicMenu.select(timeout, entries) + }) +} diff --git a/crates/boot/src/menu/font.rs b/crates/boot/src/menu/font.rs new file mode 100644 index 0000000..d0d4dbf --- /dev/null +++ b/crates/boot/src/menu/font.rs @@ -0,0 +1,118 @@ +/// The width and height of a glyph in pixels. +pub const GLYPH_SIZE: usize = 8; + +/// A glyph as eight rows from top to bottom, where the lowest bit of a row is its leftmost pixel. +pub type Glyph = [u8; GLYPH_SIZE]; + +/// A heart, which is drawn next to the selected entry. +pub const HEART: Glyph = [0x00, 0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18]; + +/// The glyphs for the printable ASCII characters from space to tilde. +/// This is the public domain 8x8 font that is commonly used by hobby operating systems. +const GLYPHS: [Glyph; 95] = [ + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00], + [0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00], + [0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00], + [0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00], + [0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00], + [0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00], + [0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00], + [0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00], + [0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06], + [0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00], + [0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00], + [0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00], + [0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00], + [0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00], + [0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00], + [0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00], + [0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00], + [0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00], + [0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00], + [0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00], + [0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00], + [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00], + [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06], + [0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00], + [0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00], + [0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00], + [0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00], + [0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00], + [0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00], + [0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00], + [0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00], + [0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00], + [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00], + [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00], + [0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00], + [0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00], + [0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00], + [0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00], + [0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00], + [0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00], + [0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00], + [0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00], + [0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00], + [0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00], + [0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00], + [0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00], + [0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00], + [0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00], + [0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00], + [0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00], + [0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00], + [0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00], + [0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00], + [0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00], + [0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00], + [0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00], + [0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00], + [0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF], + [0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00], + [0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00], + [0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00], + [0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00], + [0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00], + [0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00], + [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F], + [0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00], + [0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00], + [0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E], + [0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00], + [0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00], + [0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00], + [0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00], + [0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00], + [0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F], + [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78], + [0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00], + [0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00], + [0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00], + [0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00], + [0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00], + [0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00], + [0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00], + [0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F], + [0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00], + [0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00], + [0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00], + [0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00], + [0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], +]; + +/// Get the glyph for `c`, or a question mark for anything that isn't printable ASCII. +pub fn glyph(c: char) -> &'static Glyph { + let index = if c.is_ascii() && (' '..='~').contains(&c) { + c as usize - ' ' as usize + } else { + '?' as usize - ' ' as usize + }; + &GLYPHS[index] +} diff --git a/crates/boot/src/menu/graphical.rs b/crates/boot/src/menu/graphical.rs new file mode 100644 index 0000000..88d63cc --- /dev/null +++ b/crates/boot/src/menu/graphical.rs @@ -0,0 +1,621 @@ +use crate::entries::BootableEntry; +use crate::menu::font::{self, GLYPH_SIZE, Glyph, HEART}; +use crate::menu::logo::{LOGO, LOGO_HEIGHT, LOGO_WIDTH}; +use crate::menu::{BootMenu, wait_for_events}; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use anyhow::{Context, Result}; +use core::time::Duration; +use eficore::framebuffer::Framebuffer; +use uefi::Event; +use uefi::boot::ScopedProtocol; +use uefi::proto::console::gop::{BltPixel, GraphicsOutput}; +use uefi::proto::console::pointer::{AbsolutePointer, Pointer}; +use uefi::proto::console::text::{Input, Key, ScanCode}; + +/// How often the countdown and the logo are updated. +const TICK: Duration = Duration::from_millis(125); + +/// The number of ticks in one bounce of the logo. +const BOUNCE_TICKS: usize = 16; + +/// How many pixels the cursor moves for each millimeter that a relative pointer reports. +const PIXELS_PER_MILLIMETER: i64 = 8; + +/// The title shown above the entries. +const TITLE: &str = "sprout"; + +/// The hint shown once the countdown has been stopped. +const HINT: &str = "Pick an entry with the arrow keys or the mouse."; + +/// The color of the top of the background, which fades into [BACKGROUND_BOTTOM]. +const BACKGROUND_TOP: (u8, u8, u8) = (0x3a, 0x1c, 0x4e); + +/// The color of the bottom of the background. +const BACKGROUND_BOTTOM: (u8, u8, u8) = (0x16, 0x0c, 0x24); + +/// The color of the pill behind each entry that isn't selected. +const PILL: BltPixel = BltPixel::new(0x2c, 0x1a, 0x40); + +/// The color of the pill behind the selected entry, taken from the Sprout logo. +const PILL_SELECTED: BltPixel = BltPixel::new(0xff, 0x7a, 0xb8); + +/// The color of the title and the heart. +const ACCENT: BltPixel = BltPixel::new(0xff, 0xa6, 0xd0); + +/// The color of entries that aren't selected. +const TEXT: BltPixel = BltPixel::new(0xf3, 0xd9, 0xea); + +/// The color of the selected entry. +const TEXT_SELECTED: BltPixel = BltPixel::new(0x3a, 0x10, 0x2c); + +/// The color of the status line. +const TEXT_MUTED: BltPixel = BltPixel::new(0xb9, 0x9a, 0xc9); + +/// The color that the cursor is filled with. +const CURSOR_FILL: BltPixel = BltPixel::new(0xff, 0xff, 0xff); + +/// The color of the outline of the cursor. +const CURSOR_OUTLINE: BltPixel = BltPixel::new(0x3a, 0x10, 0x2c); + +/// The shape of the cursor, where `X` is the outline and `.` is the fill. +const CURSOR: [&str; 16] = [ + "X ", + "XX ", + "X.X ", + "X..X ", + "X...X ", + "X....X ", + "X.....X ", + "X......X ", + "X.......X ", + "X........X ", + "X.....XXXXX", + "X..X..X ", + "X.X X..X ", + "XX X..X ", + "X X..X ", + " XXXX ", +]; + +/// A graphical boot menu that selects entries with the keyboard or the mouse. +pub struct GraphicalMenu; + +/// The pointing devices of the firmware. +/// Firmware commonly has both kinds of protocol, even if one has no device behind it. +struct Mouse { + /// A device that reports how far it has moved, like a mouse. + relative: Option>, + /// A device that reports where it is, like a touch screen or the tablet of a virtual machine. + absolute: Option>, +} + +impl Mouse { + /// Open the pointing devices, if the firmware has any. + fn open() -> Option { + let relative = uefi::boot::get_handle_for_protocol::() + .and_then(uefi::boot::open_protocol_exclusive::) + .ok(); + let absolute = uefi::boot::get_handle_for_protocol::() + .and_then(uefi::boot::open_protocol_exclusive::) + .ok(); + (relative.is_some() || absolute.is_some()).then_some(Self { relative, absolute }) + } + + /// The events that are signaled when a device has input. + fn events(&self) -> Result> { + let mut events = Vec::new(); + if let Some(pointer) = &self.relative { + events.push(pointer.wait_for_input_event()); + } + if let Some(pointer) = &self.absolute { + events.push(pointer.wait_for_input_event()); + } + events + .into_iter() + .collect::>() + .context("unable to acquire pointer event") + } + + /// Move `cursor` within `bounds` to where the devices say it is. + /// Returns whether the button is held, or [None] if the devices have no new input. + fn poll( + &mut self, + cursor: &mut (usize, usize), + bounds: (usize, usize), + ) -> Result> { + let mut button = None; + let max = ( + bounds.0.saturating_sub(1) as i64, + bounds.1.saturating_sub(1) as i64, + ); + + if let Some(pointer) = &mut self.relative { + // The movement is in counts, and the device reports how many are in a millimeter. + let mode = *pointer.mode(); + if let Some(state) = pointer.read_state().context("unable to read pointer")? { + let step = |movement: i32, resolution: u64| { + movement as i64 * PIXELS_PER_MILLIMETER / resolution.max(1) as i64 + }; + let x = cursor.0 as i64 + step(state.relative_movement_x, mode.resolution_x); + let y = cursor.1 as i64 + step(state.relative_movement_y, mode.resolution_y); + *cursor = (x.clamp(0, max.0) as usize, y.clamp(0, max.1) as usize); + button = Some(state.left_button.into()); + } + } + + if let Some(pointer) = &mut self.absolute { + // The position is somewhere between the minimum and maximum of the device. + let mode = *pointer.mode(); + if let Some(state) = pointer.read_state().context("unable to read pointer")? { + let scale = |value: u64, min: u64, device_max: u64, bound: i64| { + let span = device_max.saturating_sub(min).max(1) as i128; + (value.saturating_sub(min) as i128 * bound as i128 / span).min(bound as i128) + as usize + }; + *cursor = ( + scale( + state.current_x, + mode.absolute_min_x, + mode.absolute_max_x, + max.0, + ), + scale( + state.current_y, + mode.absolute_min_y, + mode.absolute_max_y, + max.1, + ), + ); + button = Some(state.active_buttons & 1 != 0); + } + } + + Ok(button) + } +} + +/// The position and size of the entries on the framebuffer. +struct Layout { + /// How many pixels each pixel of the font is drawn as. + scale: usize, + /// The size of a character in pixels. + cell: usize, + /// The column that entries start at. + x: usize, + /// The row that the first entry starts at. + y: usize, + /// The width of each entry. + width: usize, + /// The height of each entry. + height: usize, + /// The space between entries. + gap: usize, + /// The number of entries that fit on the framebuffer. + visible: usize, +} + +impl Layout { + /// Calculate the layout of `entries` on a framebuffer of `screen` width and height. + fn new(screen: (usize, usize), entries: &[BootableEntry]) -> Self { + // Large screens get larger text so that it stays readable. + let scale = (screen.1 / 360).clamp(1, 4); + let cell = GLYPH_SIZE * scale; + let (height, gap) = (cell * 2, cell / 2); + + // All entries are the width of the longest title, with room for the heart and padding. + let longest = entries + .iter() + .map(|entry| entry.title().chars().count()) + .max() + .unwrap_or(0); + let width = ((longest + 4) * cell).min(screen.0.saturating_sub(cell * 2)); + + // The title is above the entries, and the status line is below. + let top = cell * 4; + let space = screen.1.saturating_sub(top + cell * 3); + let visible = entries.len().min(((space + gap) / (height + gap)).max(1)); + let list = visible * (height + gap) - gap; + + Self { + scale, + cell, + x: (screen.0 - width) / 2, + y: top + space.saturating_sub(list) / 2, + width, + height, + gap, + visible, + } + } + + /// The row that the entry at `slot` on the screen starts at. + fn row(&self, slot: usize) -> usize { + self.y + slot * (self.height + self.gap) + } + + /// The slot of the entry that is at `position`, if there is one. + fn slot_at(&self, position: (usize, usize)) -> Option { + let (x, y) = ( + position.0.checked_sub(self.x)?, + position.1.checked_sub(self.y)?, + ); + let slot = y / (self.height + self.gap); + (x < self.width && y % (self.height + self.gap) < self.height && slot < self.visible) + .then_some(slot) + } +} + +/// What is shown on the framebuffer. +struct Scene<'a> { + /// The entries that can be chosen. + entries: &'a [BootableEntry], + /// The index of the selected entry. + selected: usize, + /// The index of the first entry that is on the screen. + offset: usize, + /// The status line at the bottom of the screen. + status: String, + /// Where the cursor is, if there is a pointing device. + cursor: Option<(usize, usize)>, + /// The number of ticks since the menu was shown, which moves the logo. + ticks: usize, +} + +/// Fill `rows` with a gradient from `top` to `bottom`. +fn draw_background(fb: &mut Framebuffer, top: (u8, u8, u8), bottom: (u8, u8, u8)) { + let (width, height) = (fb.width(), fb.height()); + let mix = |from: u8, to: u8, row: usize| { + (from as i64 + (to as i64 - from as i64) * row as i64 / height.max(1) as i64) as u8 + }; + for row in 0..height { + let color = BltPixel::new( + mix(top.0, bottom.0, row), + mix(top.1, bottom.1, row), + mix(top.2, bottom.2, row), + ); + fb.fill_rect(0, row, width, 1, color); + } +} + +/// Fill a rectangle with the shape of a pill, which has fully rounded ends. +fn draw_pill( + fb: &mut Framebuffer, + x: usize, + y: usize, + width: usize, + height: usize, + color: BltPixel, +) { + let radius = height / 2; + for row in 0..height { + // How far the row is from the edge, where the ends curve in. + let edge = row.min(height - 1 - row); + let inset = if edge >= radius { + 0 + } else { + let rise = radius - edge; + (0..radius) + .find(|inset| (radius - inset).pow(2) + rise.pow(2) <= radius.pow(2)) + .unwrap_or(radius) + }; + fb.fill_rect( + x + inset, + y + row, + width.saturating_sub(inset * 2), + 1, + color, + ); + } +} + +/// Draw `glyph` at `x` and `y`, with each pixel of it drawn as `scale` by `scale` pixels. +fn draw_glyph( + fb: &mut Framebuffer, + x: usize, + y: usize, + scale: usize, + glyph: &Glyph, + color: BltPixel, +) { + for (row, bits) in glyph.iter().enumerate() { + for column in (0..GLYPH_SIZE).filter(|column| bits >> column & 1 != 0) { + fb.fill_rect(x + column * scale, y + row * scale, scale, scale, color); + } + } +} + +/// Draw `text` starting at `x` and `y`, cut off to at most `columns` characters. +fn draw_text( + fb: &mut Framebuffer, + x: usize, + y: usize, + scale: usize, + columns: usize, + text: &str, + color: BltPixel, +) { + for (index, c) in text.chars().take(columns).enumerate() { + draw_glyph( + fb, + x + index * GLYPH_SIZE * scale, + y, + scale, + font::glyph(c), + color, + ); + } +} + +/// Draw `text` in the middle of the framebuffer at `y`. +fn draw_centered(fb: &mut Framebuffer, y: usize, scale: usize, text: &str, color: BltPixel) { + let columns = fb.width() / (GLYPH_SIZE * scale); + let width = text.chars().count().min(columns) * GLYPH_SIZE * scale; + draw_text(fb, (fb.width() - width) / 2, y, scale, columns, text, color); +} + +/// Draw the logo in the bottom right corner, bouncing a little with each of `ticks`. +/// Nothing is drawn if the logo would be in the way of the entries. +fn draw_logo(fb: &mut Framebuffer, layout: &Layout, ticks: usize) { + let factor = layout.scale.div_ceil(2); + let (width, height) = (LOGO_WIDTH * factor, LOGO_HEIGHT * factor); + let (Some(x), Some(floor)) = ( + fb.width().checked_sub(width + layout.cell), + fb.height().checked_sub(height + layout.cell), + ) else { + return; + }; + + // The entries are in the way if the logo reaches over them, even while it is bouncing up. + let reach = layout.row(layout.visible); + if x < layout.x + layout.width && floor.saturating_sub(layout.cell) < reach { + return; + } + + // The logo rises and falls by up to four logo pixels. + let phase = ticks % BOUNCE_TICKS; + let y = floor - phase.min(BOUNCE_TICKS - phase) / 2 * factor; + + for row in 0..height { + for column in 0..width { + let source = ((row / factor) * LOGO_WIDTH + column / factor) * 4; + let [red, green, blue, alpha] = LOGO[source..source + 4] else { + continue; + }; + // The channels are premultiplied, so the color is the logo plus what shows through. + if let Some(pixel) = fb.pixel(x + column, y + row) { + let through = 255 - alpha as u32; + let blend = + |logo: u8, behind: u8| (logo as u32 + behind as u32 * through / 255) as u8; + *pixel = BltPixel::new( + blend(red, pixel.red), + blend(green, pixel.green), + blend(blue, pixel.blue), + ); + } + } + } +} + +/// Draw the cursor with its tip at `position`. +fn draw_cursor(fb: &mut Framebuffer, scale: usize, position: (usize, usize)) { + let scale = scale.div_ceil(2); + for (row, line) in CURSOR.iter().enumerate() { + for (column, shape) in line.bytes().enumerate() { + let color = match shape { + b'X' => CURSOR_OUTLINE, + b'.' => CURSOR_FILL, + _ => continue, + }; + fb.fill_rect( + position.0 + column * scale, + position.1 + row * scale, + scale, + scale, + color, + ); + } + } +} + +/// Draw the whole menu to the framebuffer. +fn render(fb: &mut Framebuffer, layout: &Layout, scene: &Scene) { + draw_background(fb, BACKGROUND_TOP, BACKGROUND_BOTTOM); + draw_centered(fb, layout.cell * 2, layout.scale * 2, TITLE, ACCENT); + + for (slot, (index, entry)) in scene + .entries + .iter() + .enumerate() + .skip(scene.offset) + .take(layout.visible) + .enumerate() + { + let selected = index == scene.selected; + let row = layout.row(slot); + let (pill, text) = if selected { + (PILL_SELECTED, TEXT_SELECTED) + } else { + (PILL, TEXT) + }; + draw_pill(fb, layout.x, row, layout.width, layout.height, pill); + + let text_row = row + (layout.height - layout.cell) / 2; + if selected { + draw_glyph( + fb, + layout.x + layout.cell, + text_row, + layout.scale, + &HEART, + text, + ); + } + draw_text( + fb, + layout.x + layout.cell * 5 / 2 + layout.cell / 2, + text_row, + layout.scale, + (layout.width / layout.cell).saturating_sub(4), + entry.title(), + text, + ); + } + + let status_row = fb.height().saturating_sub(layout.cell * 2); + draw_centered(fb, status_row, layout.scale, &scene.status, TEXT_MUTED); + draw_logo(fb, layout, scene.ticks); + if let Some(position) = scene.cursor { + draw_cursor(fb, layout.scale, position); + } +} + +/// Run the menu until an entry is chosen, returning the index of that entry. +/// The `selected` entry is booted if there is no input before `timeout` passes. +/// Without a timeout, the menu waits for the user. +fn run( + input: &mut Input, + mut mouse: Option, + gop: &mut GraphicsOutput, + fb: &mut Framebuffer, + timeout: Option, + entries: &[BootableEntry], + selected: usize, +) -> Result { + let layout = Layout::new((fb.width(), fb.height()), entries); + let last = entries.len() - 1; + + // The events that wake the menu up, which are keys and pointer input. + let mut events = Vec::from([input + .wait_for_key_event() + .context("unable to acquire key event")?]); + if let Some(mouse) = &mouse { + events.extend(mouse.events()?); + } + + let mut scene = Scene { + entries, + selected, + offset: 0, + status: String::new(), + cursor: mouse.as_ref().map(|_| (fb.width() / 2, fb.height() / 2)), + ticks: 0, + }; + // The time left before booting, or None once input stops the countdown. + let mut remaining = timeout; + let mut button_down = false; + + loop { + // Scroll so that the selected entry is visible. + if scene.selected < scene.offset { + scene.offset = scene.selected; + } else if scene.selected >= scene.offset + layout.visible { + scene.offset = scene.selected + 1 - layout.visible; + } + + scene.status = match remaining { + Some(remaining) => format!("Boot in {} s.", remaining.as_millis().div_ceil(1000)), + None => String::from(HINT), + }; + render(fb, &layout, &scene); + fb.blit(gop)?; + + // Wake up every tick to move the logo and update the countdown. + if wait_for_events(&events, Some(TICK))?.is_none() { + scene.ticks += 1; + if let Some(left) = remaining { + let left = left.saturating_sub(TICK); + if left.is_zero() { + return Ok(scene.selected); + } + remaining = Some(left); + } + continue; + } + + // Some firmware signals an event without any input being available. + if let Some(key) = input.read_key().context("unable to read key")? { + remaining = None; + match key { + Key::Special(ScanCode::UP) => scene.selected = scene.selected.saturating_sub(1), + Key::Special(ScanCode::DOWN) => scene.selected = (scene.selected + 1).min(last), + Key::Special(ScanCode::HOME) => scene.selected = 0, + Key::Special(ScanCode::END) => scene.selected = last, + Key::Special(ScanCode::PAGE_UP) => { + scene.selected = scene.selected.saturating_sub(layout.visible) + } + Key::Special(ScanCode::PAGE_DOWN) => { + scene.selected = (scene.selected + layout.visible).min(last) + } + + // Serial consoles may send a line feed instead of a carriage return. + Key::Printable(c) if matches!(char::from(c), '\r' | '\n') => { + return Ok(scene.selected); + } + + _ => {} + } + } + + if let (Some(mouse), Some(cursor)) = (mouse.as_mut(), scene.cursor.as_mut()) + && let Some(down) = mouse.poll(cursor, (fb.width(), fb.height()))? + { + remaining = None; + + // Hovering over an entry selects it, and pressing the button boots it. + if let Some(slot) = layout.slot_at(*cursor) { + scene.selected = scene.offset + slot; + if down && !button_down { + return Ok(scene.selected); + } + } + button_down = down; + } + } +} + +impl BootMenu for GraphicalMenu { + /// Selects an entry using the graphical menu. + /// The menu is drawn to the first graphics output, and fails if the firmware has none. + fn select<'a>( + &self, + timeout: Option, + entries: &'a [BootableEntry], + ) -> Result<&'a BootableEntry> { + let default = entries + .iter() + .position(|entry| entry.is_default()) + .context("no default entry available")?; + + // If the timeout is zero, boot the default entry without showing the menu. + if timeout.is_some_and(|timeout| timeout.is_zero()) { + return Ok(&entries[default]); + } + + let handle = uefi::boot::get_handle_for_protocol::() + .context("unable to find a graphics output")?; + let mut gop = uefi::boot::open_protocol_exclusive::(handle) + .context("unable to open the graphics output")?; + let (width, height) = gop.current_mode_info().resolution(); + let mut fb = Framebuffer::new(width, height)?; + let mut mouse = Mouse::open(); + + let result = uefi::system::with_stdin(|input| { + run( + input, + mouse.take(), + &mut gop, + &mut fb, + timeout, + entries, + default, + ) + }); + + // Clear the console so that anything printed after the menu is readable. + let _ = uefi::system::with_stdout(|output| output.clear()); + + Ok(&entries[result?]) + } +} diff --git a/crates/boot/src/menu/logo.rs b/crates/boot/src/menu/logo.rs new file mode 100644 index 0000000..094ff3b --- /dev/null +++ b/crates/boot/src/menu/logo.rs @@ -0,0 +1,2 @@ +// Include the generated logo in this file. +include!(concat!(env!("OUT_DIR"), "/logo.generated.rs")); diff --git a/crates/boot/src/options.rs b/crates/boot/src/options.rs index 1caabd9..794c71c 100644 --- a/crates/boot/src/options.rs +++ b/crates/boot/src/options.rs @@ -75,7 +75,7 @@ impl SproutOptions { Opt::value(ArgID::MenuTimeout, &["--menu-timeout"], "TIMEOUT") .help_text("Boot menu timeout, in seconds"), Opt::value(ArgID::MenuStyle, &["--menu-style"], "STYLE") - .help_text("Boot menu style, basic or simple"), + .help_text("Boot menu style, basic, simple or graphical"), Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"]) .help_text("Retain boot console before boot"), ]); @@ -130,8 +130,9 @@ impl SproutOptions { match value { "basic" => result.menu_style = Some(MenuStyle::Basic), "simple" => result.menu_style = Some(MenuStyle::Simple), + "graphical" => result.menu_style = Some(MenuStyle::Graphical), _ => warn!( - "ignoring invalid {} value '{}': expected basic or simple", + "ignoring invalid {} value '{}': expected basic, simple or graphical", name, value ), } diff --git a/crates/build/Cargo.toml b/crates/build/Cargo.toml index b9a9e78..c54cab5 100644 --- a/crates/build/Cargo.toml +++ b/crates/build/Cargo.toml @@ -10,3 +10,7 @@ edition.workspace = true [lib] name = "edera_sprout_build" path = "src/lib.rs" + +# The logo is only converted on the host, and png does not build for UEFI. +[target.'cfg(not(target_os = "uefi"))'.dependencies] +png.workspace = true diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index 30700bb..acc6db9 100644 --- a/crates/build/src/lib.rs +++ b/crates/build/src/lib.rs @@ -7,6 +7,14 @@ const SBAT_BLOCK_SIZE: usize = 512; /// Template contents for the sbat.generated.rs file. const SBAT_RS_TEMPLATE: &str = include_str!("sbat.template.rs"); +/// Template contents for the logo.generated.rs file. +#[cfg(not(target_os = "uefi"))] +const LOGO_RS_TEMPLATE: &str = include_str!("logo.template.rs"); + +/// The factor that the logo is shrunk by, as the source image is larger than the menu needs. +#[cfg(not(target_os = "uefi"))] +const LOGO_DOWNSCALE: usize = 2; + /// Pad with zeros the given `data` to a multiple of `block_size`. fn block_pad(data: &mut Vec, block_size: usize) { let needed = data.len().div_ceil(block_size).max(1) * block_size; @@ -74,3 +82,81 @@ pub fn generate_sbat_module() { // Write the sbat.generated.rs file to the output directory. fs::write(&rs_file, sbat_rs).expect("unable to write sbat.generated.rs"); } + +/// Generate a logo module from the Sprout logo in the assets directory of the workspace. +/// The logo is shrunk by averaging blocks of pixels and is stored as RGBA with premultiplied +/// alpha, which keeps the work done at boot to blending the pixels onto the framebuffer. +/// The output is included by a generated logo.generated.rs file. +#[cfg(not(target_os = "uefi"))] +pub fn generate_logo_module() { + use std::fs::File; + use std::io::BufReader; + + // The output directory to place the logo files into. + let output_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + + // The path to the root of the crate. + let crate_root = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set")); + + // The logo is in the assets directory at the root of the workspace. + let logo_file = crate_root.join("../../assets/logo-small.png"); + + // Notify Cargo that if the logo changes, we need to regenerate the logo files. + println!( + "cargo:rerun-if-changed={}", + logo_file + .to_str() + .expect("unable to convert logo path file to a string") + ); + + // Decode the logo as 8-bit RGBA, which is how the logo is stored. + let decoder = png::Decoder::new(BufReader::new( + File::open(&logo_file).expect("unable to open logo"), + )); + let mut reader = decoder.read_info().expect("unable to read logo"); + let mut source = vec![0; reader.output_buffer_size().expect("logo is too large")]; + let info = reader + .next_frame(&mut source) + .expect("unable to decode logo"); + assert_eq!(info.color_type, png::ColorType::Rgba, "logo is not RGBA"); + assert_eq!(info.bit_depth, png::BitDepth::Eight, "logo is not 8-bit"); + + // Any pixels on the right and bottom that don't fill a block are dropped. + let width = info.width as usize / LOGO_DOWNSCALE; + let height = info.height as usize / LOGO_DOWNSCALE; + let block = (LOGO_DOWNSCALE * LOGO_DOWNSCALE) as u32; + + let mut pixels = Vec::with_capacity(width * height * 4); + for y in 0..height { + for x in 0..width { + // Sum the premultiplied channels of every pixel in the block. + let mut sum = [0u32; 4]; + for by in 0..LOGO_DOWNSCALE { + for bx in 0..LOGO_DOWNSCALE { + let index = + ((y * LOGO_DOWNSCALE + by) * info.width as usize + x * LOGO_DOWNSCALE + bx) + * 4; + let alpha = source[index + 3] as u32; + for channel in 0..3 { + sum[channel] += source[index + channel] as u32 * alpha / 255; + } + sum[3] += alpha; + } + } + pixels.extend(sum.map(|channel| (channel / block) as u8)); + } + } + + // Write the logo.rgba file to the output directory. + fs::write(output_dir.join("logo.rgba"), &pixels).expect("unable to write logo.rgba"); + + // Generate the contents of the logo.generated.rs file. + let logo_rs = LOGO_RS_TEMPLATE + .replace("{width}", &width.to_string()) + .replace("{height}", &height.to_string()); + + // Write the logo.generated.rs file to the output directory. + fs::write(output_dir.join("logo.generated.rs"), logo_rs) + .expect("unable to write logo.generated.rs"); +} diff --git a/crates/build/src/logo.template.rs b/crates/build/src/logo.template.rs new file mode 100644 index 0000000..2088107 --- /dev/null +++ b/crates/build/src/logo.template.rs @@ -0,0 +1,8 @@ +/// The width of the Sprout logo in pixels. +pub const LOGO_WIDTH: usize = {width}; + +/// The height of the Sprout logo in pixels. +pub const LOGO_HEIGHT: usize = {height}; + +/// The pixels of the Sprout logo as rows of RGBA bytes with premultiplied alpha. +pub static LOGO: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/logo.rgba")); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d34334b..5c773c6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -131,6 +131,9 @@ pub enum MenuStyle { #[default] #[serde(rename = "simple")] Simple, + /// A graphical menu that selects entries with the keyboard or the mouse. + #[serde(rename = "graphical")] + Graphical, } /// Get the latest version of the Sprout configuration format. diff --git a/crates/eficore/src/framebuffer.rs b/crates/eficore/src/framebuffer.rs index f5dc3ea..481881c 100644 --- a/crates/eficore/src/framebuffer.rs +++ b/crates/eficore/src/framebuffer.rs @@ -31,6 +31,27 @@ impl Framebuffer { }) } + /// The width of the framebuffer in pixels. + pub fn width(&self) -> usize { + self.width + } + + /// The height of the framebuffer in pixels. + pub fn height(&self) -> usize { + self.height + } + + /// Fill the rectangle at `x` and `y` of `width` and `height` with `color`. + /// The parts of the rectangle outside of the framebuffer are not drawn. + pub fn fill_rect(&mut self, x: usize, y: usize, width: usize, height: usize, color: BltPixel) { + let end_x = x.saturating_add(width).min(self.width); + let end_y = y.saturating_add(height).min(self.height); + for row in y..end_y { + // The row is in bounds, so its range is within the pixels. + self.pixels[row * self.width + x..row * self.width + end_x].fill(color); + } + } + /// Mutably acquires a pixel of the framebuffer at the specified `x` and `y` coordinate. pub fn pixel(&mut self, x: usize, y: usize) -> Option<&mut BltPixel> { // Verify that the coordinates are within the bounds of the framebuffer.