mirror of
https://github.com/edera-dev/sprout.git
synced 2026-11-04 17:52:20 +00:00
feat(menu): add a graphical boot menu (#106)
* feat(menu): add a graphical boot menu Adds the graphical menu style, selected with menu-style = "graphical" or --menu-style=graphical. It draws to the graphics output with the existing framebuffer, using a built-in 8x8 font, and takes input from the keyboard and from relative or absolute pointers. The Sprout logo is drawn in the bottom right corner. If there is no graphics output, the simple menu is used, which still falls back to the basic menu. The logo is converted from assets/logo-small.png to raw pixels by the build crate, which adds the png crate as a build-time dependency only. The default menu style is still simple. Pointer input is untested, as OVMF has no mouse driver. * fix(build): only build the logo conversion on the host CI builds the whole workspace for the UEFI target, which also builds the build crate for UEFI. The png dependency pulls in simd-adler32, which crashes LLVM on that target. The logo conversion only runs in build scripts on the host, so png and the conversion are now left out when targeting UEFI.
This commit is contained in:
14 files changed
+1033
-6
No files matched your search
Generated
+83
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+77
-3
@@ -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<Duration>) -> Result<Option<K
|
||||
result
|
||||
}
|
||||
|
||||
/// Wait until one of `events` is signaled, giving up once `timeout` passes.
|
||||
/// If `timeout` is [None], this waits for an event indefinitely.
|
||||
/// Returns the index of the event that was signaled, or [None] if the timeout passed.
|
||||
pub fn wait_for_events(events: &[Event], timeout: Option<Duration>) -> Result<Option<usize>> {
|
||||
// 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<Event> = 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 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 graphical boot menu, using the simple menu: {:#}",
|
||||
error
|
||||
);
|
||||
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 => SimpleMenu.select(timeout, entries).or_else(|error| {
|
||||
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<Duration>, 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)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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<ScopedProtocol<Pointer>>,
|
||||
/// A device that reports where it is, like a touch screen or the tablet of a virtual machine.
|
||||
absolute: Option<ScopedProtocol<AbsolutePointer>>,
|
||||
}
|
||||
|
||||
impl Mouse {
|
||||
/// Open the pointing devices, if the firmware has any.
|
||||
fn open() -> Option<Self> {
|
||||
let relative = uefi::boot::get_handle_for_protocol::<Pointer>()
|
||||
.and_then(uefi::boot::open_protocol_exclusive::<Pointer>)
|
||||
.ok();
|
||||
let absolute = uefi::boot::get_handle_for_protocol::<AbsolutePointer>()
|
||||
.and_then(uefi::boot::open_protocol_exclusive::<AbsolutePointer>)
|
||||
.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<Vec<Event>> {
|
||||
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::<uefi::Result<_>>()
|
||||
.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<Option<bool>> {
|
||||
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<usize> {
|
||||
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<Mouse>,
|
||||
gop: &mut GraphicsOutput,
|
||||
fb: &mut Framebuffer,
|
||||
timeout: Option<Duration>,
|
||||
entries: &[BootableEntry],
|
||||
selected: usize,
|
||||
) -> Result<usize> {
|
||||
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<Duration>,
|
||||
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::<GraphicsOutput>()
|
||||
.context("unable to find a graphics output")?;
|
||||
let mut gop = uefi::boot::open_protocol_exclusive::<GraphicsOutput>(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?])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Include the generated logo in this file.
|
||||
include!(concat!(env!("OUT_DIR"), "/logo.generated.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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<u8>, 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");
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user