2026-10-03 17:01:48 -07:00
|
|
|
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;
|
2026-10-03 17:48:07 -07:00
|
|
|
use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol};
|
|
|
|
|
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: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);
|
|
|
|
|
|
|
|
|
|
/// 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>>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:48:07 -07:00
|
|
|
/// Open the protocol `P` on `handle` without taking it from the firmware.
|
|
|
|
|
/// An exclusive open makes the firmware disconnect every driver that is using the protocol,
|
|
|
|
|
/// and on some firmware that never returns.
|
|
|
|
|
fn open_shared<P: ProtocolPointer + ?Sized>(handle: Handle) -> uefi::Result<ScopedProtocol<P>> {
|
|
|
|
|
// SAFETY: The protocols opened this way are only used from this thread, and the firmware
|
|
|
|
|
// keeps them installed for as long as the menu is open.
|
|
|
|
|
unsafe {
|
|
|
|
|
uefi::boot::open_protocol::<P>(
|
|
|
|
|
OpenProtocolParams {
|
|
|
|
|
handle,
|
|
|
|
|
agent: uefi::boot::image_handle(),
|
|
|
|
|
controller: None,
|
|
|
|
|
},
|
|
|
|
|
OpenProtocolAttributes::GetProtocol,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
.and_then(|handle| open_shared::<P>(handle).ok())
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
uefi::boot::get_handle_for_protocol::<P>()
|
|
|
|
|
.and_then(open_shared::<P>)
|
|
|
|
|
.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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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")?;
|
2026-10-03 17:48:08 -07:00
|
|
|
// Opening it exclusively would disconnect the firmware's text console from the display,
|
|
|
|
|
// and anything printed after the menu may not show up.
|
|
|
|
|
let mut gop =
|
|
|
|
|
open_shared::<GraphicsOutput>(handle).context("unable to open the graphics output")?;
|
2026-10-03 17:01:48 -07:00
|
|
|
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?])
|
|
|
|
|
}
|
|
|
|
|
}
|