2026-10-03 17:01:48 -07:00
|
|
|
use crate::entries::BootableEntry;
|
2026-10-03 17:52:54 -07:00
|
|
|
use crate::menu::font::{self, ELLIPSIS, GLYPH_SIZE, Glyph, HEART};
|
2026-10-03 17:01:48 -07:00
|
|
|
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;
|
2026-10-03 17:48:38 -07:00
|
|
|
use anyhow::{Context, Result, bail};
|
2026-10-03 17:01:48 -07:00
|
|
|
use core::time::Duration;
|
|
|
|
|
use eficore::framebuffer::Framebuffer;
|
2026-10-03 19:50:45 -07:00
|
|
|
use uefi::boot::{OpenProtocolParams, ScopedProtocol};
|
2026-10-03 17:48:07 -07:00
|
|
|
use uefi::proto::ProtocolPointer;
|
2026-10-03 17:01:48 -07:00
|
|
|
use uefi::proto::console::gop::{BltPixel, GraphicsOutput};
|
|
|
|
|
use uefi::proto::console::pointer::{AbsolutePointer, Pointer};
|
|
|
|
|
use uefi::proto::console::text::{Input, Key, ScanCode};
|
2026-10-03 17:48:38 -07:00
|
|
|
use uefi::proto::device_path::DevicePath;
|
2026-10-03 17:48:07 -07:00
|
|
|
use uefi::{Event, Handle};
|
2026-10-03 17:01:48 -07:00
|
|
|
|
|
|
|
|
/// 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);
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// The widest that the status line gets, which the panel has room for.
|
|
|
|
|
const STATUS_WIDEST: &str = "Boot in 999 s.";
|
|
|
|
|
|
|
|
|
|
/// The color of the panel behind the entries.
|
|
|
|
|
const PANEL: BltPixel = BltPixel::new(0x22, 0x12, 0x34);
|
|
|
|
|
|
|
|
|
|
/// The color of the edge of the panel, the line below the entries and the scrollbar track.
|
|
|
|
|
const PANEL_EDGE: BltPixel = BltPixel::new(0x4a, 0x2e, 0x66);
|
2026-10-03 17:01:48 -07:00
|
|
|
|
|
|
|
|
/// 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>>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:48:07 -07:00
|
|
|
/// Open the pointer protocol `P`, preferring the one on the console input handle.
|
|
|
|
|
/// That one belongs to the console splitter, which combines every device and is never removed.
|
|
|
|
|
/// Otherwise, the first device that has the protocol is used.
|
|
|
|
|
fn open_pointer<P: ProtocolPointer + ?Sized>() -> Option<ScopedProtocol<P>> {
|
|
|
|
|
// SAFETY: The handle in the system table is either null or a valid handle.
|
|
|
|
|
let console = uefi::table::system_table_raw()
|
|
|
|
|
.and_then(|table| unsafe { Handle::from_ptr(table.as_ref().stdin_handle) });
|
|
|
|
|
console
|
2026-10-03 19:50:45 -07:00
|
|
|
.and_then(|handle| eficore::handle::open_shared::<P>(handle).ok())
|
2026-10-03 17:48:07 -07:00
|
|
|
.or_else(|| {
|
|
|
|
|
uefi::boot::get_handle_for_protocol::<P>()
|
2026-10-03 19:50:45 -07:00
|
|
|
.and_then(eficore::handle::open_shared::<P>)
|
2026-10-03 17:48:07 -07:00
|
|
|
.ok()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:01:48 -07:00
|
|
|
impl Mouse {
|
|
|
|
|
/// Open the pointing devices, if the firmware has any.
|
|
|
|
|
fn open() -> Option<Self> {
|
2026-10-03 17:48:07 -07:00
|
|
|
let relative = open_pointer::<Pointer>();
|
|
|
|
|
let absolute = open_pointer::<AbsolutePointer>();
|
2026-10-03 17:01:48 -07:00
|
|
|
(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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// The position and size of everything on the framebuffer.
|
|
|
|
|
/// The logo and the name are in a header, with a panel below it that holds the entries.
|
2026-10-03 17:01:48 -07:00
|
|
|
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,
|
2026-10-03 17:52:54 -07:00
|
|
|
/// The column, row, width and height of the panel behind the entries.
|
|
|
|
|
panel: (usize, usize, usize, usize),
|
|
|
|
|
/// How round the corners of the panel are.
|
|
|
|
|
radius: usize,
|
|
|
|
|
/// The column that the text of an entry starts at.
|
|
|
|
|
text_x: usize,
|
|
|
|
|
/// How many characters of a title fit in an entry.
|
|
|
|
|
columns: usize,
|
|
|
|
|
/// The row of the line between the entries and the status line.
|
|
|
|
|
footer: usize,
|
|
|
|
|
/// The column, row, width and height of the logo when it is on the ground.
|
|
|
|
|
logo: (usize, usize, usize, usize),
|
|
|
|
|
/// How many pixels the logo rises while it bounces.
|
|
|
|
|
bounce: usize,
|
|
|
|
|
/// The column, row and scale of the name, if there is room for it.
|
|
|
|
|
title: Option<(usize, usize, usize)>,
|
|
|
|
|
/// Whether there are more entries than fit, which adds a scrollbar.
|
|
|
|
|
scrolls: bool,
|
2026-10-03 17:01:48 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Layout {
|
|
|
|
|
/// Calculate the layout of `entries` on a framebuffer of `screen` width and height.
|
|
|
|
|
fn new(screen: (usize, usize), entries: &[BootableEntry]) -> Self {
|
2026-10-03 17:52:54 -07:00
|
|
|
let (width, height) = screen;
|
2026-10-03 17:01:48 -07:00
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
// Large screens get larger text so that it stays readable, but not so large that the
|
|
|
|
|
// titles take up the whole screen.
|
|
|
|
|
let scale = (height / 540).min(width / 640).clamp(1, 3);
|
|
|
|
|
let cell = GLYPH_SIZE * scale;
|
|
|
|
|
let margin = (cell * 2).max(height / 20).min(width / 8);
|
|
|
|
|
let padding = cell * 3 / 2;
|
|
|
|
|
let (row, gap) = (cell * 2, scale * 2);
|
|
|
|
|
let header_gap = cell * 2;
|
|
|
|
|
|
|
|
|
|
// The line between the entries and the status, and the status below it.
|
|
|
|
|
let footer_height = cell + scale + cell / 2 + cell;
|
|
|
|
|
|
|
|
|
|
// The panel is the width of the longest title, but never most of the screen.
|
2026-10-03 17:01:48 -07:00
|
|
|
let longest = entries
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|entry| entry.title().chars().count())
|
|
|
|
|
.max()
|
|
|
|
|
.unwrap_or(0);
|
2026-10-03 17:52:54 -07:00
|
|
|
let status = HINT.len().max(STATUS_WIDEST.len());
|
|
|
|
|
let panel_width = ((longest + 4) * cell + padding * 2)
|
|
|
|
|
.max(40 * cell)
|
|
|
|
|
.max((status + 8) * cell)
|
|
|
|
|
.min(width.saturating_sub(margin * 2).min(width * 3 / 5));
|
2026-10-03 17:01:48 -07:00
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
// The header is as tall as the logo, and the logo needs room to bounce.
|
|
|
|
|
let fit = |logo: usize| {
|
|
|
|
|
let header = logo + logo / 16;
|
|
|
|
|
let space = height
|
|
|
|
|
.saturating_sub(margin * 2 + header + header_gap + padding * 2 + footer_height);
|
|
|
|
|
let visible = ((space + gap) / (row + gap)).clamp(1, entries.len().max(1));
|
|
|
|
|
(header, visible)
|
|
|
|
|
};
|
|
|
|
|
let mut logo_height = (LOGO_HEIGHT * 2).min(height / 7).max(32);
|
|
|
|
|
let (mut header, mut visible) = fit(logo_height);
|
|
|
|
|
// A short screen gets a smaller logo before it loses entries.
|
|
|
|
|
if visible < entries.len().min(3) {
|
|
|
|
|
logo_height = (logo_height / 2).max(32);
|
|
|
|
|
(header, visible) = fit(logo_height);
|
|
|
|
|
}
|
|
|
|
|
let logo_width = logo_height * LOGO_WIDTH / LOGO_HEIGHT;
|
|
|
|
|
let bounce = logo_height / 16;
|
|
|
|
|
|
|
|
|
|
// The name is next to the logo, if the two fit together.
|
|
|
|
|
let title_scale = (logo_height / 32).clamp(scale, scale * 3);
|
|
|
|
|
let title_width = TITLE.len() * GLYPH_SIZE * title_scale;
|
|
|
|
|
let fits = logo_width + cell + title_width <= width.saturating_sub(margin * 2);
|
|
|
|
|
let group = if fits {
|
|
|
|
|
logo_width + cell + title_width
|
|
|
|
|
} else {
|
|
|
|
|
logo_width
|
|
|
|
|
};
|
|
|
|
|
let group_x = width.saturating_sub(group) / 2;
|
|
|
|
|
|
|
|
|
|
let list = visible * (row + gap) - gap;
|
|
|
|
|
let panel_height = padding * 2 + list + footer_height;
|
|
|
|
|
let top = (height.saturating_sub(header + header_gap + panel_height) / 2).max(margin);
|
|
|
|
|
let panel_x = width.saturating_sub(panel_width) / 2;
|
|
|
|
|
let panel_y = top + header + header_gap;
|
|
|
|
|
|
|
|
|
|
// The scrollbar is in a column next to the entries.
|
|
|
|
|
let scrolls = visible < entries.len();
|
|
|
|
|
let gutter = if scrolls { cell } else { 0 };
|
|
|
|
|
let list_width = panel_width.saturating_sub(padding * 2 + gutter);
|
|
|
|
|
let list_y = panel_y + padding;
|
2026-10-03 17:01:48 -07:00
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
scale,
|
|
|
|
|
cell,
|
2026-10-03 17:52:54 -07:00
|
|
|
x: panel_x + padding,
|
|
|
|
|
y: list_y,
|
|
|
|
|
width: list_width,
|
|
|
|
|
height: row,
|
2026-10-03 17:01:48 -07:00
|
|
|
gap,
|
|
|
|
|
visible,
|
2026-10-03 17:52:54 -07:00
|
|
|
panel: (panel_x, panel_y, panel_width, panel_height),
|
|
|
|
|
radius: cell,
|
|
|
|
|
text_x: panel_x + padding + cell * 3,
|
|
|
|
|
columns: list_width.saturating_sub(cell * 4) / cell,
|
|
|
|
|
footer: list_y + list + cell,
|
|
|
|
|
logo: (group_x, top + bounce, logo_width, logo_height),
|
|
|
|
|
bounce,
|
|
|
|
|
title: fits.then_some((
|
|
|
|
|
group_x + logo_width + cell,
|
|
|
|
|
top + bounce + logo_height.saturating_sub(GLYPH_SIZE * title_scale) / 2,
|
|
|
|
|
title_scale,
|
|
|
|
|
)),
|
|
|
|
|
scrolls,
|
2026-10-03 17:01:48 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:48:38 -07:00
|
|
|
/// A display that the menu is drawn to.
|
|
|
|
|
struct Screen {
|
|
|
|
|
/// The graphics output of the display.
|
|
|
|
|
gop: ScopedProtocol<GraphicsOutput>,
|
|
|
|
|
/// What is drawn before it is blitted to the display.
|
|
|
|
|
fb: Framebuffer,
|
|
|
|
|
/// Where the entries are on this display.
|
|
|
|
|
layout: Layout,
|
|
|
|
|
/// The index of the first entry that is on this display.
|
|
|
|
|
offset: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Screen {
|
|
|
|
|
/// Open every display that the firmware has, or fail if it has none.
|
|
|
|
|
/// The console splitter has a graphics output that draws to every display. It has no device
|
|
|
|
|
/// path, so it is only used if there are no outputs for the displays themselves.
|
|
|
|
|
fn open_all(entries: &[BootableEntry]) -> Result<Vec<Self>> {
|
|
|
|
|
let handles = uefi::boot::find_handles::<GraphicsOutput>()
|
|
|
|
|
.context("unable to find a graphics output")?;
|
|
|
|
|
let has_device_path = |handle: &Handle| {
|
|
|
|
|
uefi::boot::test_protocol::<DevicePath>(OpenProtocolParams {
|
|
|
|
|
handle: *handle,
|
|
|
|
|
agent: uefi::boot::image_handle(),
|
|
|
|
|
controller: None,
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
};
|
|
|
|
|
let displays = handles
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.filter(has_device_path)
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let handles = if displays.is_empty() {
|
|
|
|
|
handles
|
|
|
|
|
} else {
|
|
|
|
|
displays
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut screens = Vec::new();
|
|
|
|
|
for handle in handles {
|
|
|
|
|
// Opening it exclusively would disconnect the firmware's text console from the
|
|
|
|
|
// display, and anything printed after the menu may not show up.
|
2026-10-03 19:50:45 -07:00
|
|
|
let Ok(gop) = eficore::handle::open_shared::<GraphicsOutput>(handle) else {
|
2026-10-03 17:48:38 -07:00
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let (width, height) = gop.current_mode_info().resolution();
|
|
|
|
|
let Ok(fb) = Framebuffer::new(width, height) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let layout = Layout::new((width, height), entries);
|
|
|
|
|
screens.push(Self {
|
|
|
|
|
gop,
|
|
|
|
|
fb,
|
|
|
|
|
layout,
|
|
|
|
|
offset: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if screens.is_empty() {
|
|
|
|
|
bail!("unable to open a graphics output");
|
|
|
|
|
}
|
|
|
|
|
Ok(screens)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The size of the display in pixels.
|
|
|
|
|
fn size(&self) -> (usize, usize) {
|
|
|
|
|
(self.fb.width(), self.fb.height())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Where `position` on a display of size `from` is on this display, keeping its place
|
|
|
|
|
/// relative to the edges.
|
|
|
|
|
fn locate(&self, position: (usize, usize), from: (usize, usize)) -> (usize, usize) {
|
|
|
|
|
let scale = |value: usize, to: usize, from: usize| {
|
|
|
|
|
(value * to / from.max(1)).min(to.saturating_sub(1))
|
|
|
|
|
};
|
|
|
|
|
(
|
|
|
|
|
scale(position.0, self.fb.width(), from.0),
|
|
|
|
|
scale(position.1, self.fb.height(), from.1),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Scroll so that the `selected` entry is visible.
|
|
|
|
|
fn scroll_to(&mut self, selected: usize) {
|
|
|
|
|
if selected < self.offset {
|
|
|
|
|
self.offset = selected;
|
|
|
|
|
} else if selected >= self.offset + self.layout.visible {
|
|
|
|
|
self.offset = selected + 1 - self.layout.visible;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:01:48 -07:00
|
|
|
/// 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 status line at the bottom of the screen.
|
|
|
|
|
status: String,
|
|
|
|
|
/// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// Fill a rectangle that has rounded corners of `radius`.
|
|
|
|
|
fn draw_rounded(
|
2026-10-03 17:01:48 -07:00
|
|
|
fb: &mut Framebuffer,
|
|
|
|
|
x: usize,
|
|
|
|
|
y: usize,
|
|
|
|
|
width: usize,
|
|
|
|
|
height: usize,
|
2026-10-03 17:52:54 -07:00
|
|
|
radius: usize,
|
2026-10-03 17:01:48 -07:00
|
|
|
color: BltPixel,
|
|
|
|
|
) {
|
2026-10-03 17:52:54 -07:00
|
|
|
if width == 0 || height == 0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let radius = radius.min(height / 2).min(width / 2);
|
2026-10-03 17:01:48 -07:00
|
|
|
for row in 0..height {
|
2026-10-03 17:52:54 -07:00
|
|
|
// How far the row is from the edge, where the corners curve in.
|
2026-10-03 17:01:48 -07:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// 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,
|
|
|
|
|
) {
|
|
|
|
|
draw_rounded(fb, x, y, width, height, height / 2, color);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:01:48 -07:00
|
|
|
/// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// Draw `text` starting at `x` and `y`, cut off with an ellipsis if it is over `columns` characters.
|
2026-10-03 17:01:48 -07:00
|
|
|
fn draw_text(
|
|
|
|
|
fb: &mut Framebuffer,
|
|
|
|
|
x: usize,
|
|
|
|
|
y: usize,
|
|
|
|
|
scale: usize,
|
|
|
|
|
columns: usize,
|
|
|
|
|
text: &str,
|
|
|
|
|
color: BltPixel,
|
|
|
|
|
) {
|
2026-10-03 17:52:54 -07:00
|
|
|
let cut = text.chars().count() > columns;
|
|
|
|
|
let shown = if cut {
|
|
|
|
|
columns.saturating_sub(1)
|
|
|
|
|
} else {
|
|
|
|
|
columns
|
|
|
|
|
};
|
|
|
|
|
for (index, c) in text.chars().take(shown).enumerate() {
|
2026-10-03 17:01:48 -07:00
|
|
|
draw_glyph(
|
|
|
|
|
fb,
|
|
|
|
|
x + index * GLYPH_SIZE * scale,
|
|
|
|
|
y,
|
|
|
|
|
scale,
|
|
|
|
|
font::glyph(c),
|
|
|
|
|
color,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-03 17:52:54 -07:00
|
|
|
if cut && columns > 0 {
|
|
|
|
|
draw_glyph(
|
|
|
|
|
fb,
|
|
|
|
|
x + shown * GLYPH_SIZE * scale,
|
|
|
|
|
y,
|
|
|
|
|
scale,
|
|
|
|
|
&ELLIPSIS,
|
|
|
|
|
color,
|
|
|
|
|
);
|
2026-10-03 17:01:48 -07:00
|
|
|
}
|
2026-10-03 17:52:54 -07:00
|
|
|
}
|
2026-10-03 17:01:48 -07:00
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// Draw `text` in the middle of `x` and `width` at `y`.
|
|
|
|
|
fn draw_centered(
|
|
|
|
|
fb: &mut Framebuffer,
|
|
|
|
|
x: usize,
|
|
|
|
|
width: usize,
|
|
|
|
|
y: usize,
|
|
|
|
|
scale: usize,
|
|
|
|
|
text: &str,
|
|
|
|
|
color: BltPixel,
|
|
|
|
|
) {
|
|
|
|
|
let columns = width / (GLYPH_SIZE * scale);
|
|
|
|
|
let used = text.chars().count().min(columns) * GLYPH_SIZE * scale;
|
|
|
|
|
draw_text(fb, x + (width - used) / 2, y, scale, columns, text, color);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Draw the logo above the entries, bouncing a little with each of `ticks`.
|
|
|
|
|
fn draw_logo(fb: &mut Framebuffer, layout: &Layout, ticks: usize) {
|
|
|
|
|
let (x, floor, width, height) = layout.logo;
|
|
|
|
|
|
|
|
|
|
// The logo rises and falls by up to `bounce` pixels.
|
2026-10-03 17:01:48 -07:00
|
|
|
let phase = ticks % BOUNCE_TICKS;
|
2026-10-03 17:52:54 -07:00
|
|
|
let rise = phase.min(BOUNCE_TICKS - phase) * layout.bounce / (BOUNCE_TICKS / 2);
|
|
|
|
|
let y = floor - rise;
|
2026-10-03 17:01:48 -07:00
|
|
|
|
|
|
|
|
for row in 0..height {
|
|
|
|
|
for column in 0..width {
|
2026-10-03 17:52:54 -07:00
|
|
|
let source =
|
|
|
|
|
((row * LOGO_HEIGHT / height) * LOGO_WIDTH + column * LOGO_WIDTH / width) * 4;
|
2026-10-03 17:01:48 -07:00
|
|
|
let [red, green, blue, alpha] = LOGO[source..source + 4] else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-10-03 17:52:54 -07:00
|
|
|
if alpha == 0 {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-03 17:01:48 -07:00
|
|
|
// 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),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
/// Draw the track and the thumb of the scrollbar, which shows where `offset` is in `total`.
|
|
|
|
|
fn draw_scrollbar(fb: &mut Framebuffer, layout: &Layout, offset: usize, total: usize) {
|
|
|
|
|
let height = layout.visible * (layout.height + layout.gap) - layout.gap;
|
|
|
|
|
let x = layout.x + layout.width + layout.cell / 2 - layout.scale;
|
|
|
|
|
let width = layout.scale * 2;
|
|
|
|
|
fb.fill_rect(x, layout.y, width, height, PANEL_EDGE);
|
|
|
|
|
|
|
|
|
|
let thumb = (height * layout.visible / total)
|
|
|
|
|
.max(layout.cell)
|
|
|
|
|
.min(height);
|
|
|
|
|
let travel = total.saturating_sub(layout.visible).max(1);
|
|
|
|
|
let y = layout.y + (height - thumb) * offset.min(travel) / travel;
|
|
|
|
|
draw_rounded(fb, x, y, width, thumb, width / 2, ACCENT);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:01:48 -07:00
|
|
|
/// Draw the cursor with its tip at `position`.
|
|
|
|
|
fn draw_cursor(fb: &mut Framebuffer, scale: usize, position: (usize, usize)) {
|
|
|
|
|
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.
|
2026-10-03 17:48:38 -07:00
|
|
|
fn render(
|
|
|
|
|
fb: &mut Framebuffer,
|
|
|
|
|
layout: &Layout,
|
|
|
|
|
scene: &Scene,
|
|
|
|
|
offset: usize,
|
|
|
|
|
cursor: Option<(usize, usize)>,
|
|
|
|
|
) {
|
2026-10-03 17:01:48 -07:00
|
|
|
draw_background(fb, BACKGROUND_TOP, BACKGROUND_BOTTOM);
|
2026-10-03 17:52:54 -07:00
|
|
|
draw_logo(fb, layout, scene.ticks);
|
|
|
|
|
if let Some((x, y, scale)) = layout.title {
|
|
|
|
|
draw_text(fb, x, y, scale, TITLE.len(), TITLE, ACCENT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (panel_x, panel_y, panel_width, panel_height) = layout.panel;
|
|
|
|
|
let edge = layout.scale;
|
|
|
|
|
draw_rounded(
|
|
|
|
|
fb,
|
|
|
|
|
panel_x,
|
|
|
|
|
panel_y,
|
|
|
|
|
panel_width,
|
|
|
|
|
panel_height,
|
|
|
|
|
layout.radius,
|
|
|
|
|
PANEL_EDGE,
|
|
|
|
|
);
|
|
|
|
|
draw_rounded(
|
|
|
|
|
fb,
|
|
|
|
|
panel_x + edge,
|
|
|
|
|
panel_y + edge,
|
|
|
|
|
panel_width.saturating_sub(edge * 2),
|
|
|
|
|
panel_height.saturating_sub(edge * 2),
|
|
|
|
|
layout.radius.saturating_sub(edge),
|
|
|
|
|
PANEL,
|
|
|
|
|
);
|
2026-10-03 17:01:48 -07:00
|
|
|
|
|
|
|
|
for (slot, (index, entry)) in scene
|
|
|
|
|
.entries
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
2026-10-03 17:48:38 -07:00
|
|
|
.skip(offset)
|
2026-10-03 17:01:48 -07:00
|
|
|
.take(layout.visible)
|
|
|
|
|
.enumerate()
|
|
|
|
|
{
|
|
|
|
|
let row = layout.row(slot);
|
|
|
|
|
let text_row = row + (layout.height - layout.cell) / 2;
|
2026-10-03 17:52:54 -07:00
|
|
|
// Only the selected entry has a pill, which keeps the list calm.
|
|
|
|
|
let text = if index == scene.selected {
|
|
|
|
|
draw_pill(
|
|
|
|
|
fb,
|
|
|
|
|
layout.x,
|
|
|
|
|
row,
|
|
|
|
|
layout.width,
|
|
|
|
|
layout.height,
|
|
|
|
|
PILL_SELECTED,
|
|
|
|
|
);
|
2026-10-03 17:01:48 -07:00
|
|
|
draw_glyph(
|
|
|
|
|
fb,
|
|
|
|
|
layout.x + layout.cell,
|
|
|
|
|
text_row,
|
|
|
|
|
layout.scale,
|
|
|
|
|
&HEART,
|
2026-10-03 17:52:54 -07:00
|
|
|
TEXT_SELECTED,
|
2026-10-03 17:01:48 -07:00
|
|
|
);
|
2026-10-03 17:52:54 -07:00
|
|
|
TEXT_SELECTED
|
|
|
|
|
} else {
|
|
|
|
|
TEXT
|
|
|
|
|
};
|
2026-10-03 17:01:48 -07:00
|
|
|
draw_text(
|
|
|
|
|
fb,
|
2026-10-03 17:52:54 -07:00
|
|
|
layout.text_x,
|
2026-10-03 17:01:48 -07:00
|
|
|
text_row,
|
|
|
|
|
layout.scale,
|
2026-10-03 17:52:54 -07:00
|
|
|
layout.columns,
|
2026-10-03 17:01:48 -07:00
|
|
|
entry.title(),
|
|
|
|
|
text,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:52:54 -07:00
|
|
|
if layout.scrolls {
|
|
|
|
|
draw_scrollbar(fb, layout, offset, scene.entries.len());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The status line is below a line across the panel, with the position when it scrolls.
|
|
|
|
|
let inner = panel_width.saturating_sub(layout.cell * 3);
|
|
|
|
|
let inner_x = panel_x + layout.cell * 3 / 2;
|
|
|
|
|
fb.fill_rect(inner_x, layout.footer, inner, layout.scale, PANEL_EDGE);
|
|
|
|
|
let status_row = layout.footer + layout.scale + layout.cell / 2;
|
|
|
|
|
// The position is at the right edge, so the status keeps clear of it on both sides.
|
|
|
|
|
let keep_clear = if layout.scrolls { layout.cell * 8 } else { 0 };
|
|
|
|
|
draw_centered(
|
|
|
|
|
fb,
|
|
|
|
|
inner_x + keep_clear,
|
|
|
|
|
inner.saturating_sub(keep_clear * 2),
|
|
|
|
|
status_row,
|
|
|
|
|
layout.scale,
|
|
|
|
|
&scene.status,
|
|
|
|
|
TEXT_MUTED,
|
|
|
|
|
);
|
|
|
|
|
if layout.scrolls {
|
|
|
|
|
let position = format!("{}/{}", scene.selected + 1, scene.entries.len());
|
|
|
|
|
let width = position.len() * layout.cell;
|
|
|
|
|
draw_text(
|
|
|
|
|
fb,
|
|
|
|
|
(inner_x + inner).saturating_sub(width),
|
|
|
|
|
status_row,
|
|
|
|
|
layout.scale,
|
|
|
|
|
position.len(),
|
|
|
|
|
&position,
|
|
|
|
|
TEXT_MUTED,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:48:38 -07:00
|
|
|
if let Some(position) = cursor {
|
2026-10-03 17:01:48 -07:00
|
|
|
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>,
|
2026-10-03 17:48:38 -07:00
|
|
|
screens: &mut [Screen],
|
2026-10-03 17:01:48 -07:00
|
|
|
timeout: Option<Duration>,
|
|
|
|
|
entries: &[BootableEntry],
|
|
|
|
|
selected: usize,
|
|
|
|
|
) -> Result<usize> {
|
2026-10-03 17:48:38 -07:00
|
|
|
// The cursor moves within the first screen, and is shown at the same place on the others.
|
|
|
|
|
let bounds = screens[0].size();
|
|
|
|
|
let page = screens[0].layout.visible;
|
2026-10-03 17:01:48 -07:00
|
|
|
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,
|
|
|
|
|
status: String::new(),
|
|
|
|
|
ticks: 0,
|
|
|
|
|
};
|
2026-10-03 17:48:38 -07:00
|
|
|
let mut cursor = mouse.as_ref().map(|_| (bounds.0 / 2, bounds.1 / 2));
|
2026-10-03 17:01:48 -07:00
|
|
|
// The time left before booting, or None once input stops the countdown.
|
|
|
|
|
let mut remaining = timeout;
|
|
|
|
|
let mut button_down = false;
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
scene.status = match remaining {
|
|
|
|
|
Some(remaining) => format!("Boot in {} s.", remaining.as_millis().div_ceil(1000)),
|
|
|
|
|
None => String::from(HINT),
|
|
|
|
|
};
|
2026-10-03 17:48:38 -07:00
|
|
|
for screen in screens.iter_mut() {
|
|
|
|
|
screen.scroll_to(scene.selected);
|
|
|
|
|
let cursor = cursor.map(|position| screen.locate(position, bounds));
|
|
|
|
|
render(
|
|
|
|
|
&mut screen.fb,
|
|
|
|
|
&screen.layout,
|
|
|
|
|
&scene,
|
|
|
|
|
screen.offset,
|
|
|
|
|
cursor,
|
|
|
|
|
);
|
|
|
|
|
screen.fb.blit(&mut screen.gop)?;
|
|
|
|
|
}
|
2026-10-03 17:01:48 -07:00
|
|
|
|
|
|
|
|
// 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) => {
|
2026-10-03 17:48:38 -07:00
|
|
|
scene.selected = scene.selected.saturating_sub(page)
|
2026-10-03 17:01:48 -07:00
|
|
|
}
|
|
|
|
|
Key::Special(ScanCode::PAGE_DOWN) => {
|
2026-10-03 17:48:38 -07:00
|
|
|
scene.selected = (scene.selected + page).min(last)
|
2026-10-03 17:01:48 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:48:38 -07:00
|
|
|
if let (Some(mouse), Some(cursor)) = (mouse.as_mut(), cursor.as_mut())
|
|
|
|
|
&& let Some(down) = mouse.poll(cursor, bounds)?
|
2026-10-03 17:01:48 -07:00
|
|
|
{
|
|
|
|
|
remaining = None;
|
|
|
|
|
|
|
|
|
|
// Hovering over an entry selects it, and pressing the button boots it.
|
2026-10-03 17:48:38 -07:00
|
|
|
let hovered = screens.iter().find_map(|screen| {
|
|
|
|
|
let position = screen.locate(*cursor, bounds);
|
|
|
|
|
screen
|
|
|
|
|
.layout
|
|
|
|
|
.slot_at(position)
|
|
|
|
|
.map(|slot| screen.offset + slot)
|
|
|
|
|
});
|
|
|
|
|
if let Some(entry) = hovered {
|
|
|
|
|
scene.selected = entry;
|
2026-10-03 17:01:48 -07:00
|
|
|
if down && !button_down {
|
|
|
|
|
return Ok(scene.selected);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
button_down = down;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BootMenu for GraphicalMenu {
|
|
|
|
|
/// Selects an entry using the graphical menu.
|
2026-10-03 17:48:38 -07:00
|
|
|
/// The menu is drawn to every display, and fails if the firmware has none.
|
2026-10-03 17:01:48 -07:00
|
|
|
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]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:48:38 -07:00
|
|
|
let mut screens = Screen::open_all(entries)?;
|
2026-10-03 17:01:48 -07:00
|
|
|
let mut mouse = Mouse::open();
|
|
|
|
|
|
|
|
|
|
let result = uefi::system::with_stdin(|input| {
|
2026-10-03 17:48:38 -07:00
|
|
|
run(input, mouse.take(), &mut screens, timeout, entries, default)
|
2026-10-03 17:01:48 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Clear the console so that anything printed after the menu is readable.
|
|
|
|
|
let _ = uefi::system::with_stdout(|output| output.clear());
|
|
|
|
|
|
|
|
|
|
Ok(&entries[result?])
|
|
|
|
|
}
|
|
|
|
|
}
|