mirror of
https://github.com/edera-dev/sprout.git
synced 2026-11-04 21:12:21 +00:00
feat(menu): add a simple boot menu similar to systemd-boot (#100)
The boot menu is now a BootMenu trait with two styles. The basic menu is the existing one that prints entries and selects them by number. The new simple menu is full-screen: entries are centered and highlighted, the arrow keys, home, end, page up and page down move the selection, enter boots it, and any key stops the countdown. The style is picked with --menu-style or menu-style in [options], and defaults to simple. The timer code used to wait for a key is shared between both menus in menu.rs.
This commit is contained in:
7 files changed
+512
-123
No files matched your search
@@ -120,6 +120,8 @@ $ sprout.efi --config=\path\to\config.toml
|
|||||||
$ sprout.efi --boot="Boot Xen"
|
$ sprout.efi --boot="Boot Xen"
|
||||||
# Autoconfigure Sprout, without loading a configuration file.
|
# Autoconfigure Sprout, without loading a configuration file.
|
||||||
$ sprout.efi --autoconfigure
|
$ sprout.efi --autoconfigure
|
||||||
|
# Use the basic boot menu instead of the simple one.
|
||||||
|
$ sprout.efi --menu-style=basic
|
||||||
```
|
```
|
||||||
|
|
||||||
### Boot Linux from ESP
|
### Boot Linux from ESP
|
||||||
|
|||||||
@@ -338,13 +338,21 @@ fn run() -> Result<()> {
|
|||||||
// Convert the menu timeout to a duration.
|
// Convert the menu timeout to a duration.
|
||||||
let menu_timeout = Duration::from_secs(menu_timeout);
|
let menu_timeout = Duration::from_secs(menu_timeout);
|
||||||
|
|
||||||
|
// Determine the menu style based on the options or configuration.
|
||||||
|
// We prefer the options over the configuration to allow for overriding.
|
||||||
|
let menu_style = context
|
||||||
|
.root()
|
||||||
|
.options()
|
||||||
|
.menu_style
|
||||||
|
.unwrap_or(config.options.menu_style);
|
||||||
|
|
||||||
// Use the forced boot entry if possible, otherwise pick the first entry using a boot menu.
|
// Use the forced boot entry if possible, otherwise pick the first entry using a boot menu.
|
||||||
let entry = if !force_boot_menu && let Some(ref force_boot_entry) = force_boot_entry {
|
let entry = if !force_boot_menu && let Some(ref force_boot_entry) = force_boot_entry {
|
||||||
BootableEntry::find(force_boot_entry, entries.iter())
|
BootableEntry::find(force_boot_entry, entries.iter())
|
||||||
.context(format!("unable to find entry: {force_boot_entry}"))?
|
.context(format!("unable to find entry: {force_boot_entry}"))?
|
||||||
} else {
|
} else {
|
||||||
// Delegate to the menu to select an entry to boot.
|
// Delegate to the menu to select an entry to boot.
|
||||||
menu::select(&timer, menu_timeout, &entries)
|
menu::select(&timer, menu_timeout, menu_style, &entries)
|
||||||
.context("unable to select entry via boot menu")?
|
.context("unable to select entry via boot menu")?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+36
-121
@@ -1,36 +1,40 @@
|
|||||||
use crate::entries::BootableEntry;
|
use crate::entries::BootableEntry;
|
||||||
|
use crate::menu::basic::BasicMenu;
|
||||||
|
use crate::menu::simple::SimpleMenu;
|
||||||
use alloc::vec;
|
use alloc::vec;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use core::time::Duration;
|
use core::time::Duration;
|
||||||
|
use edera_sprout_config::MenuStyle;
|
||||||
use eficore::bootloader_interface::BootloaderInterface;
|
use eficore::bootloader_interface::BootloaderInterface;
|
||||||
use eficore::platform::timer::PlatformTimer;
|
use eficore::platform::timer::PlatformTimer;
|
||||||
use log::{info, warn};
|
use log::warn;
|
||||||
use uefi::ResultExt;
|
use uefi::ResultExt;
|
||||||
use uefi::boot::TimerTrigger;
|
use uefi::boot::TimerTrigger;
|
||||||
use uefi::proto::console::text::{Input, Key, ScanCode};
|
use uefi::proto::console::text::Input;
|
||||||
use uefi_raw::table::boot::{EventType, Tpl};
|
use uefi_raw::table::boot::{EventType, Tpl};
|
||||||
|
|
||||||
/// The characters that can be used to select an entry from keys.
|
/// basic: A menu that prints the entries and selects them by number.
|
||||||
const ENTRY_NUMBER_TABLE: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
pub mod basic;
|
||||||
|
|
||||||
/// Represents the operation that can be performed by the boot menu.
|
/// simple: A full-screen menu that selects entries with the arrow keys.
|
||||||
#[derive(PartialEq, Eq)]
|
pub mod simple;
|
||||||
enum MenuOperation {
|
|
||||||
/// The user selected a numbered entry.
|
/// A boot menu that can be shown to select an entry to boot.
|
||||||
Number(usize),
|
pub trait BootMenu {
|
||||||
/// The user selected the escape key to exit the boot menu.
|
/// Select an entry from `entries` to boot. If no entry is chosen before `timeout` passes,
|
||||||
Exit,
|
/// the default entry is selected.
|
||||||
/// The user selected the enter key to display the entries again.
|
fn select<'a>(
|
||||||
Continue,
|
&self,
|
||||||
/// Timeout occurred.
|
timeout: Duration,
|
||||||
Timeout,
|
entries: &'a [BootableEntry],
|
||||||
/// No operation should be performed.
|
) -> Result<&'a BootableEntry>;
|
||||||
Nop,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a key from the input device with a duration, returning the [MenuOperation] that was
|
/// Wait for the key event of `input` to be signaled, giving up once `timeout` passes.
|
||||||
/// performed.
|
/// Returns true if the key event was signaled, or false if the timeout passed.
|
||||||
fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
|
/// Some firmware signals the key event without a key being available, so reading
|
||||||
|
/// the key afterward may still produce no key.
|
||||||
|
pub fn wait_for_key(input: &mut Input, timeout: Duration) -> Result<bool> {
|
||||||
// The event to wait for a key press.
|
// The event to wait for a key press.
|
||||||
let key_event = input
|
let key_event = input
|
||||||
.wait_for_key_event()
|
.wait_for_key_event()
|
||||||
@@ -45,7 +49,7 @@ fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set a timer to trigger after the specified duration.
|
// Set a timer to trigger after the specified duration.
|
||||||
let trigger = TimerTrigger::Relative(*timeout);
|
let trigger = TimerTrigger::Relative(timeout);
|
||||||
uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?;
|
uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?;
|
||||||
|
|
||||||
let events = vec![timer_event, key_event];
|
let events = vec![timer_event, key_event];
|
||||||
@@ -79,115 +83,26 @@ fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
|
|||||||
// Acquire the event that triggered.
|
// Acquire the event that triggered.
|
||||||
let event = event_result?;
|
let event = event_result?;
|
||||||
|
|
||||||
// The first event is the timer event.
|
// The first event is the timer event, so any other event is the key event.
|
||||||
// If it has triggered, the user did not select a numbered entry.
|
Ok(event != 0)
|
||||||
if event == 0 {
|
|
||||||
return Ok(MenuOperation::Timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach here, there is a key event.
|
|
||||||
// Some firmware signals the key event without a key being available,
|
|
||||||
// in which case there is nothing to do.
|
|
||||||
let Some(key) = input.read_key().context("unable to read key")? else {
|
|
||||||
return Ok(MenuOperation::Nop);
|
|
||||||
};
|
|
||||||
|
|
||||||
match key {
|
|
||||||
Key::Printable(c) => {
|
|
||||||
// If the key is not ascii, we can't process it.
|
|
||||||
if !c.is_ascii() {
|
|
||||||
return Ok(MenuOperation::Continue);
|
|
||||||
}
|
|
||||||
// Convert the key to a char.
|
|
||||||
let c: char = c.into();
|
|
||||||
// Find the key pressed in the entry number table or continue.
|
|
||||||
Ok(ENTRY_NUMBER_TABLE
|
|
||||||
.iter()
|
|
||||||
.position(|&x| x == c)
|
|
||||||
.map(MenuOperation::Number)
|
|
||||||
.unwrap_or(MenuOperation::Continue))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The escape key is used to exit the boot menu.
|
|
||||||
Key::Special(ScanCode::ESCAPE) => Ok(MenuOperation::Exit),
|
|
||||||
|
|
||||||
// If the special key is unknown, do nothing.
|
|
||||||
Key::Special(_) => Ok(MenuOperation::Nop),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects an entry from the list of entries using the boot menu.
|
/// Shows a boot menu of the specified `style` to select a bootable entry to boot.
|
||||||
fn select_with_input<'a>(
|
|
||||||
input: &mut Input,
|
|
||||||
timeout: Duration,
|
|
||||||
entries: &'a [BootableEntry],
|
|
||||||
) -> Result<&'a BootableEntry> {
|
|
||||||
loop {
|
|
||||||
// If the timeout is not zero, let's display the boot menu.
|
|
||||||
if !timeout.is_zero() {
|
|
||||||
// Until a pretty menu is available, we just print all the entries.
|
|
||||||
info!("Boot Menu:");
|
|
||||||
for (index, entry) in entries.iter().enumerate() {
|
|
||||||
let title = entry.context().stamp(&entry.declaration().title);
|
|
||||||
info!(" [{}] {}", index, title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read from input until a valid operation is selected.
|
|
||||||
let operation = loop {
|
|
||||||
// If the timeout is zero, we can exit immediately because there is nothing to do.
|
|
||||||
if timeout.is_zero() {
|
|
||||||
break MenuOperation::Exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Select a boot entry using the number keys.");
|
|
||||||
info!("Press Escape to exit and enter to display the entries again.");
|
|
||||||
|
|
||||||
let operation = read(input, &timeout)?;
|
|
||||||
if operation != MenuOperation::Nop {
|
|
||||||
break operation;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match operation {
|
|
||||||
// Entry was selected by number. If the number is invalid, we continue.
|
|
||||||
MenuOperation::Number(index) => {
|
|
||||||
let Some(entry) = entries.get(index) else {
|
|
||||||
info!("invalid entry number");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
return Ok(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the user exits the boot menu or a timeout occurs, we should
|
|
||||||
// boot the default entry, if any.
|
|
||||||
MenuOperation::Exit | MenuOperation::Timeout => {
|
|
||||||
return entries
|
|
||||||
.iter()
|
|
||||||
.find(|item| item.is_default())
|
|
||||||
.context("no default entry available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the operation is to continue or nop, we can just run the loop again.
|
|
||||||
MenuOperation::Continue | MenuOperation::Nop => {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows a boot menu to select a bootable entry to boot.
|
|
||||||
/// The actual work is done internally in [select_with_input] which is called
|
|
||||||
/// within the context of the standard input device.
|
|
||||||
pub fn select<'live>(
|
pub fn select<'live>(
|
||||||
timer: &'live PlatformTimer,
|
timer: &'live PlatformTimer,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
style: MenuStyle,
|
||||||
entries: &'live [BootableEntry],
|
entries: &'live [BootableEntry],
|
||||||
) -> Result<&'live BootableEntry> {
|
) -> Result<&'live BootableEntry> {
|
||||||
// Notify the bootloader interface that we are about to display the menu.
|
// Notify the bootloader interface that we are about to display the menu.
|
||||||
BootloaderInterface::mark_menu(timer)
|
BootloaderInterface::mark_menu(timer)
|
||||||
.context("unable to mark menu display in bootloader interface")?;
|
.context("unable to mark menu display in bootloader interface")?;
|
||||||
|
|
||||||
// Acquire the standard input device and run the boot menu.
|
// Pick the menu that implements the requested style.
|
||||||
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
|
let menu: &dyn BootMenu = match style {
|
||||||
|
MenuStyle::Basic => &BasicMenu,
|
||||||
|
MenuStyle::Simple => &SimpleMenu,
|
||||||
|
};
|
||||||
|
|
||||||
|
menu.select(timeout, entries)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use crate::entries::BootableEntry;
|
||||||
|
use crate::menu::{BootMenu, wait_for_key};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use core::time::Duration;
|
||||||
|
use log::info;
|
||||||
|
use uefi::proto::console::text::{Input, Key, ScanCode};
|
||||||
|
|
||||||
|
/// The characters that can be used to select an entry from keys.
|
||||||
|
const ENTRY_NUMBER_TABLE: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
|
||||||
|
/// A boot menu that prints the entries and selects them by number.
|
||||||
|
pub struct BasicMenu;
|
||||||
|
|
||||||
|
/// Represents the operation that can be performed by the boot menu.
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
enum MenuOperation {
|
||||||
|
/// The user selected a numbered entry.
|
||||||
|
Number(usize),
|
||||||
|
/// The user selected the escape key to exit the boot menu.
|
||||||
|
Exit,
|
||||||
|
/// The user selected the enter key to display the entries again.
|
||||||
|
Continue,
|
||||||
|
/// Timeout occurred.
|
||||||
|
Timeout,
|
||||||
|
/// No operation should be performed.
|
||||||
|
Nop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a key from the input device with a duration, returning the [MenuOperation] that was
|
||||||
|
/// performed.
|
||||||
|
fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
|
||||||
|
// If the timer triggered, the user did not select a numbered entry.
|
||||||
|
if !wait_for_key(input, *timeout)? {
|
||||||
|
return Ok(MenuOperation::Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, there is a key event.
|
||||||
|
// Some firmware signals the key event without a key being available,
|
||||||
|
// in which case there is nothing to do.
|
||||||
|
let Some(key) = input.read_key().context("unable to read key")? else {
|
||||||
|
return Ok(MenuOperation::Nop);
|
||||||
|
};
|
||||||
|
|
||||||
|
match key {
|
||||||
|
Key::Printable(c) => {
|
||||||
|
// If the key is not ascii, we can't process it.
|
||||||
|
if !c.is_ascii() {
|
||||||
|
return Ok(MenuOperation::Continue);
|
||||||
|
}
|
||||||
|
// Convert the key to a char.
|
||||||
|
let c: char = c.into();
|
||||||
|
// Find the key pressed in the entry number table or continue.
|
||||||
|
Ok(ENTRY_NUMBER_TABLE
|
||||||
|
.iter()
|
||||||
|
.position(|&x| x == c)
|
||||||
|
.map(MenuOperation::Number)
|
||||||
|
.unwrap_or(MenuOperation::Continue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The escape key is used to exit the boot menu.
|
||||||
|
Key::Special(ScanCode::ESCAPE) => Ok(MenuOperation::Exit),
|
||||||
|
|
||||||
|
// If the special key is unknown, do nothing.
|
||||||
|
Key::Special(_) => Ok(MenuOperation::Nop),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects an entry from the list of entries using the boot menu.
|
||||||
|
fn select_with_input<'a>(
|
||||||
|
input: &mut Input,
|
||||||
|
timeout: Duration,
|
||||||
|
entries: &'a [BootableEntry],
|
||||||
|
) -> Result<&'a BootableEntry> {
|
||||||
|
loop {
|
||||||
|
// If the timeout is not zero, let's display the boot menu.
|
||||||
|
if !timeout.is_zero() {
|
||||||
|
// Print all the entries with the number used to select them.
|
||||||
|
info!("Boot Menu:");
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
let title = entry.context().stamp(&entry.declaration().title);
|
||||||
|
info!(" [{}] {}", index, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from input until a valid operation is selected.
|
||||||
|
let operation = loop {
|
||||||
|
// If the timeout is zero, we can exit immediately because there is nothing to do.
|
||||||
|
if timeout.is_zero() {
|
||||||
|
break MenuOperation::Exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Select a boot entry using the number keys.");
|
||||||
|
info!("Press Escape to exit and enter to display the entries again.");
|
||||||
|
|
||||||
|
let operation = read(input, &timeout)?;
|
||||||
|
if operation != MenuOperation::Nop {
|
||||||
|
break operation;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match operation {
|
||||||
|
// Entry was selected by number. If the number is invalid, we continue.
|
||||||
|
MenuOperation::Number(index) => {
|
||||||
|
let Some(entry) = entries.get(index) else {
|
||||||
|
info!("invalid entry number");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the user exits the boot menu or a timeout occurs, we should
|
||||||
|
// boot the default entry, if any.
|
||||||
|
MenuOperation::Exit | MenuOperation::Timeout => {
|
||||||
|
return entries
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.is_default())
|
||||||
|
.context("no default entry available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the operation is to continue or nop, we can just run the loop again.
|
||||||
|
MenuOperation::Continue | MenuOperation::Nop => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootMenu for BasicMenu {
|
||||||
|
/// Selects an entry using the basic menu.
|
||||||
|
/// The actual work is done internally in [select_with_input] which is called
|
||||||
|
/// within the context of the standard input device.
|
||||||
|
fn select<'a>(
|
||||||
|
&self,
|
||||||
|
timeout: Duration,
|
||||||
|
entries: &'a [BootableEntry],
|
||||||
|
) -> Result<&'a BootableEntry> {
|
||||||
|
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
use crate::entries::BootableEntry;
|
||||||
|
use crate::menu::{BootMenu, wait_for_key};
|
||||||
|
use alloc::format;
|
||||||
|
use alloc::string::String;
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use core::fmt::Write;
|
||||||
|
use core::iter::repeat;
|
||||||
|
use core::time::Duration;
|
||||||
|
use uefi::proto::console::text::{Color, Input, Key, Output, ScanCode};
|
||||||
|
|
||||||
|
/// The number of columns to assume if the console does not report its mode.
|
||||||
|
const FALLBACK_COLUMNS: usize = 80;
|
||||||
|
|
||||||
|
/// The number of rows to assume if the console does not report its mode.
|
||||||
|
const FALLBACK_ROWS: usize = 25;
|
||||||
|
|
||||||
|
/// How often the countdown is updated.
|
||||||
|
const TICK: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// The number of spaces on either side of an entry title.
|
||||||
|
const ENTRY_PADDING: usize = 2;
|
||||||
|
|
||||||
|
/// The hint shown once the countdown has been stopped.
|
||||||
|
const HINT: &str = "Use the arrow keys to select an entry and press enter to boot.";
|
||||||
|
|
||||||
|
/// A full-screen boot menu that selects entries with the arrow keys, similar to systemd-boot.
|
||||||
|
pub struct SimpleMenu;
|
||||||
|
|
||||||
|
/// The position and size of the menu on the console.
|
||||||
|
struct Layout {
|
||||||
|
/// The number of rows on the console.
|
||||||
|
rows: usize,
|
||||||
|
/// The number of columns that can be drawn to.
|
||||||
|
columns: usize,
|
||||||
|
/// The column that entries start at.
|
||||||
|
x: usize,
|
||||||
|
/// The row that entries start at.
|
||||||
|
y: usize,
|
||||||
|
/// The width of each entry, including padding.
|
||||||
|
width: usize,
|
||||||
|
/// The number of entries that fit on the console.
|
||||||
|
visible: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
/// Calculate the layout of `entries` on the console `output`.
|
||||||
|
fn new(output: &Output, entries: &[BootableEntry]) -> Self {
|
||||||
|
// Some consoles do not report a mode, so we assume a standard size for them.
|
||||||
|
let (columns, rows) = output
|
||||||
|
.current_mode()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|mode| (mode.columns(), mode.rows()))
|
||||||
|
.unwrap_or((FALLBACK_COLUMNS, FALLBACK_ROWS));
|
||||||
|
|
||||||
|
// Leave the last column empty, as writing to it makes some consoles scroll.
|
||||||
|
let columns = columns.saturating_sub(1);
|
||||||
|
|
||||||
|
// All entries are the width of the longest title so the highlight lines up.
|
||||||
|
let longest = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.title().chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let width = (longest + ENTRY_PADDING * 2).min(columns);
|
||||||
|
|
||||||
|
// Keep the last two rows for the status line and a blank row above it.
|
||||||
|
let space = rows.saturating_sub(2).max(1);
|
||||||
|
let visible = entries.len().min(space);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
x: (columns - width) / 2,
|
||||||
|
y: (space - visible) / 2,
|
||||||
|
width,
|
||||||
|
visible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw `text` at `column` and `row`, padded with spaces or cut to fit `width`.
|
||||||
|
fn draw(
|
||||||
|
output: &mut Output,
|
||||||
|
column: usize,
|
||||||
|
row: usize,
|
||||||
|
width: usize,
|
||||||
|
text: &str,
|
||||||
|
colors: (Color, Color),
|
||||||
|
) -> Result<()> {
|
||||||
|
// Replace anything that isn't printable ASCII, as not all consoles can draw it.
|
||||||
|
let line: String = text
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c == ' ' || c.is_ascii_graphic() {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'?'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.chain(repeat(' '))
|
||||||
|
.take(width)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
output
|
||||||
|
.set_color(colors.0, colors.1)
|
||||||
|
.context("unable to set console color")?;
|
||||||
|
output
|
||||||
|
.set_cursor_position(column, row)
|
||||||
|
.context("unable to set console cursor position")?;
|
||||||
|
output
|
||||||
|
.write_str(&line)
|
||||||
|
.map_err(|_| anyhow!("unable to write to console"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the visible `entries` starting at `offset` with `selected` highlighted,
|
||||||
|
/// and the `status` line at the bottom of the console.
|
||||||
|
fn render(
|
||||||
|
output: &mut Output,
|
||||||
|
layout: &Layout,
|
||||||
|
entries: &[BootableEntry],
|
||||||
|
selected: usize,
|
||||||
|
offset: usize,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
for (row, (index, entry)) in entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(offset)
|
||||||
|
.take(layout.visible)
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
// The selected entry is drawn in inverted colors.
|
||||||
|
let colors = if index == selected {
|
||||||
|
(Color::Black, Color::LightGray)
|
||||||
|
} else {
|
||||||
|
(Color::LightGray, Color::Black)
|
||||||
|
};
|
||||||
|
let text = format!("{:padding$}{}", "", entry.title(), padding = ENTRY_PADDING);
|
||||||
|
draw(
|
||||||
|
output,
|
||||||
|
layout.x,
|
||||||
|
layout.y + row,
|
||||||
|
layout.width,
|
||||||
|
&text,
|
||||||
|
colors,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the status line across the console.
|
||||||
|
let padding = layout.columns.saturating_sub(status.len()) / 2;
|
||||||
|
let text = format!("{:padding$}{}", "", status);
|
||||||
|
draw(
|
||||||
|
output,
|
||||||
|
0,
|
||||||
|
layout.rows.saturating_sub(1),
|
||||||
|
layout.columns,
|
||||||
|
&text,
|
||||||
|
(Color::LightGray, Color::Black),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the menu until an entry is chosen, returning the index of that entry.
|
||||||
|
/// The `selected` entry is booted if no key is pressed before `timeout` passes.
|
||||||
|
fn run(
|
||||||
|
input: &mut Input,
|
||||||
|
output: &mut Output,
|
||||||
|
layout: &Layout,
|
||||||
|
timeout: Duration,
|
||||||
|
entries: &[BootableEntry],
|
||||||
|
mut selected: usize,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let last = entries.len() - 1;
|
||||||
|
// The first visible entry, which changes when scrolling through a long list.
|
||||||
|
let mut offset = 0;
|
||||||
|
// The time left before booting, or None once a key stops the countdown.
|
||||||
|
let mut remaining = Some(timeout);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Scroll so that the selected entry is visible.
|
||||||
|
if selected < offset {
|
||||||
|
offset = selected;
|
||||||
|
} else if selected >= offset + layout.visible {
|
||||||
|
offset = selected + 1 - layout.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = match remaining {
|
||||||
|
Some(remaining) => format!("Boot in {} s.", remaining.as_secs()),
|
||||||
|
None => String::from(HINT),
|
||||||
|
};
|
||||||
|
render(output, layout, entries, selected, offset, &status)?;
|
||||||
|
|
||||||
|
// Wait for a key, waking up every tick to update the countdown.
|
||||||
|
let tick = remaining.map_or(TICK, |remaining| remaining.min(TICK));
|
||||||
|
if !wait_for_key(input, tick)? {
|
||||||
|
// Without a countdown there's nothing to do until a key is pressed.
|
||||||
|
let Some(left) = remaining else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let left = left.saturating_sub(tick);
|
||||||
|
if left.is_zero() {
|
||||||
|
return Ok(selected);
|
||||||
|
}
|
||||||
|
remaining = Some(left);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some firmware signals the key event without a key being available,
|
||||||
|
// in which case there is nothing to do.
|
||||||
|
let Some(key) = input.read_key().context("unable to read key")? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Any key stops the countdown, which leaves escape with nothing else to do.
|
||||||
|
remaining = None;
|
||||||
|
|
||||||
|
match key {
|
||||||
|
Key::Special(ScanCode::UP) => selected = selected.saturating_sub(1),
|
||||||
|
Key::Special(ScanCode::DOWN) => selected = (selected + 1).min(last),
|
||||||
|
Key::Special(ScanCode::HOME) => selected = 0,
|
||||||
|
Key::Special(ScanCode::END) => selected = last,
|
||||||
|
Key::Special(ScanCode::PAGE_UP) => selected = selected.saturating_sub(layout.visible),
|
||||||
|
Key::Special(ScanCode::PAGE_DOWN) => selected = (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(selected),
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects an entry from `entries` using the console `input` and `output`.
|
||||||
|
fn select_with_console<'a>(
|
||||||
|
input: &mut Input,
|
||||||
|
output: &mut Output,
|
||||||
|
timeout: 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_zero() {
|
||||||
|
return Ok(&entries[default]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = Layout::new(output, entries);
|
||||||
|
let cursor_visible = output.cursor_visible();
|
||||||
|
|
||||||
|
output
|
||||||
|
.set_color(Color::LightGray, Color::Black)
|
||||||
|
.context("unable to set console color")?;
|
||||||
|
output.clear().context("unable to clear console")?;
|
||||||
|
// Not all consoles can hide the cursor, so this is allowed to fail.
|
||||||
|
let _ = output.enable_cursor(false);
|
||||||
|
|
||||||
|
let result = run(input, output, &layout, timeout, entries, default);
|
||||||
|
|
||||||
|
// Put the console back so that anything printed after the menu is readable.
|
||||||
|
let _ = output.set_color(Color::LightGray, Color::Black);
|
||||||
|
let _ = output.clear();
|
||||||
|
let _ = output.enable_cursor(cursor_visible);
|
||||||
|
|
||||||
|
Ok(&entries[result?])
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootMenu for SimpleMenu {
|
||||||
|
/// Selects an entry using the simple menu.
|
||||||
|
/// The actual work is done internally in [select_with_console] which is called
|
||||||
|
/// within the context of the standard input and output devices.
|
||||||
|
fn select<'a>(
|
||||||
|
&self,
|
||||||
|
timeout: Duration,
|
||||||
|
entries: &'a [BootableEntry],
|
||||||
|
) -> Result<&'a BootableEntry> {
|
||||||
|
uefi::system::with_stdin(|input| {
|
||||||
|
uefi::system::with_stdout(|output| select_with_console(input, output, timeout, entries))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
use alloc::string::{String, ToString};
|
use alloc::string::{String, ToString};
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use core::ptr::null_mut;
|
use core::ptr::null_mut;
|
||||||
|
use edera_sprout_config::MenuStyle;
|
||||||
use jaarg::{
|
use jaarg::{
|
||||||
ErrorUsageWriter, ErrorUsageWriterContext, HelpWriter, HelpWriterContext, Opt, Opts,
|
ErrorUsageWriter, ErrorUsageWriterContext, HelpWriter, HelpWriterContext, Opt, Opts,
|
||||||
ParseControl, ParseResult, StandardErrorUsageWriter, StandardFullHelpWriter,
|
ParseControl, ParseError, ParseErrorKind, ParseResult, StandardErrorUsageWriter,
|
||||||
|
StandardFullHelpWriter,
|
||||||
};
|
};
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
use uefi_raw::Status;
|
use uefi_raw::Status;
|
||||||
@@ -24,6 +26,8 @@ pub struct SproutOptions {
|
|||||||
pub force_menu: bool,
|
pub force_menu: bool,
|
||||||
/// The timeout for the boot menu in seconds.
|
/// The timeout for the boot menu in seconds.
|
||||||
pub menu_timeout: Option<u64>,
|
pub menu_timeout: Option<u64>,
|
||||||
|
/// The style of boot menu to display.
|
||||||
|
pub menu_style: Option<MenuStyle>,
|
||||||
/// Retains the boot console before boot.
|
/// Retains the boot console before boot.
|
||||||
pub retain_boot_console: bool,
|
pub retain_boot_console: bool,
|
||||||
}
|
}
|
||||||
@@ -37,6 +41,7 @@ impl Default for SproutOptions {
|
|||||||
boot: None,
|
boot: None,
|
||||||
force_menu: false,
|
force_menu: false,
|
||||||
menu_timeout: None,
|
menu_timeout: None,
|
||||||
|
menu_style: None,
|
||||||
retain_boot_console: false,
|
retain_boot_console: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,7 @@ impl SproutOptions {
|
|||||||
Boot,
|
Boot,
|
||||||
ForceMenu,
|
ForceMenu,
|
||||||
MenuTimeout,
|
MenuTimeout,
|
||||||
|
MenuStyle,
|
||||||
RetainBootConsole,
|
RetainBootConsole,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +75,8 @@ impl SproutOptions {
|
|||||||
Opt::flag(ArgID::ForceMenu, &["--force-menu"]).help_text("Force showing the boot menu"),
|
Opt::flag(ArgID::ForceMenu, &["--force-menu"]).help_text("Force showing the boot menu"),
|
||||||
Opt::value(ArgID::MenuTimeout, &["--menu-timeout"], "TIMEOUT")
|
Opt::value(ArgID::MenuTimeout, &["--menu-timeout"], "TIMEOUT")
|
||||||
.help_text("Boot menu timeout, in seconds"),
|
.help_text("Boot menu timeout, in seconds"),
|
||||||
|
Opt::value(ArgID::MenuStyle, &["--menu-style"], "STYLE")
|
||||||
|
.help_text("Boot menu style, basic or simple"),
|
||||||
Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"])
|
Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"])
|
||||||
.help_text("Retain boot console before boot"),
|
.help_text("Retain boot console before boot"),
|
||||||
]);
|
]);
|
||||||
@@ -105,6 +113,23 @@ impl SproutOptions {
|
|||||||
// The timeout for the boot menu in seconds.
|
// The timeout for the boot menu in seconds.
|
||||||
result.menu_timeout = Some(value.parse::<u64>()?);
|
result.menu_timeout = Some(value.parse::<u64>()?);
|
||||||
}
|
}
|
||||||
|
ArgID::MenuStyle => {
|
||||||
|
// The style of boot menu to display.
|
||||||
|
result.menu_style = Some(match value {
|
||||||
|
"basic" => MenuStyle::Basic,
|
||||||
|
"simple" => MenuStyle::Simple,
|
||||||
|
// jaarg has no error kind for an unknown choice, but this one is
|
||||||
|
// printed as an invalid argument. The parser fills in the option
|
||||||
|
// and value of the error.
|
||||||
|
_ => {
|
||||||
|
return Err(ParseError::ArgumentError(
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
ParseErrorKind::InvalidInteger,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
ArgID::RetainBootConsole => {
|
ArgID::RetainBootConsole => {
|
||||||
// Retain the boot console before booting.
|
// Retain the boot console before booting.
|
||||||
result.retain_boot_console = true;
|
result.retain_boot_console = true;
|
||||||
|
|||||||
@@ -84,11 +84,26 @@ pub struct OptionsConfiguration {
|
|||||||
/// The timeout of the boot menu.
|
/// The timeout of the boot menu.
|
||||||
#[serde(rename = "menu-timeout", default = "default_menu_timeout")]
|
#[serde(rename = "menu-timeout", default = "default_menu_timeout")]
|
||||||
pub menu_timeout: u64,
|
pub menu_timeout: u64,
|
||||||
|
/// The style of boot menu to display.
|
||||||
|
#[serde(rename = "menu-style", default)]
|
||||||
|
pub menu_style: MenuStyle,
|
||||||
/// Enables autoconfiguration of Sprout based on the environment.
|
/// Enables autoconfiguration of Sprout based on the environment.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub autoconfigure: bool,
|
pub autoconfigure: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The style of boot menu to display.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MenuStyle {
|
||||||
|
/// A basic menu that prints the entries and selects them by number.
|
||||||
|
#[serde(rename = "basic")]
|
||||||
|
Basic,
|
||||||
|
/// A simple full-screen menu that selects entries with the arrow keys.
|
||||||
|
#[default]
|
||||||
|
#[serde(rename = "simple")]
|
||||||
|
Simple,
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the latest version of the Sprout configuration format.
|
/// Get the latest version of the Sprout configuration format.
|
||||||
pub fn latest_version() -> u32 {
|
pub fn latest_version() -> u32 {
|
||||||
LATEST_VERSION
|
LATEST_VERSION
|
||||||
|
|||||||
Reference in new issue
Block a user