crates/veilvoice-gui/src/layout.rs
what this file is for · veilvoice-gui · 473 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Centring a row of widgets, which egui does not do by nesting.
//!
//! # The defect this exists to fix
//!
//! The unlock screen drew its mark, its name and the word "locked" inside
//! [`egui::Ui::vertical_centered`], and then drew the password field, the
//! unlock button and the status line underneath in a plain row. The heading
//! sat in the middle of the window and the controls sat against the left edge.
//! The same shape turns up wherever a row is drawn only after setup rather
//! than at launch, because those rows tend to be written later and separately
//! from the ones they end up beside.
//!
//! The obvious repair does not work, and it is worth writing down why, because
//! it looks like it should. Wrapping the row in `vertical_centered` puts it in
//! a `Layout::top_down(Align::Center)`, which centres each child *narrower
//! than the available width*. `Ui::horizontal` is never narrower: it allocates
//!
//! ```text
//! let initial_size = vec2(
//! self.available_size_before_wrap().x, // the whole width
//! self.spacing().interact_size.y,
//! );
//! ```
//!
//! so the row's box is already full width, there is nothing left to centre it
//! within, and its contents start at that box's left edge. A centred layout
//! inside a centred layout changes nothing. `Layout::left_to_right` carries
//! `main_align: Align::Center` and does not help for the same reason.
//!
//! # Measured last frame, drawn this frame
//!
//! The row is drawn once, with a space in front of it worked out from how wide
//! the same row turned out to be on the previous frame, remembered in egui's
//! own temporary memory.
//!
//! **The closure is called once, and that is the whole design constraint.**
//! The tidier-looking approach is [`egui::UiBuilder::sizing_pass`]: lay the
//! row out invisibly, measure it, then lay it out again for real. That needs
//! the closure twice, and these closures are not pure. The unlock row spawns
//! a key derivation when its button reports a click, and a sizing pass runs
//! the body rather than skipping it, so measuring that way would risk
//! spawning the work twice from one press. A row of widgets is not worth a
//! double unlock, so the width comes from the last frame instead.
//!
//! The cost is that the first frame a row appears on is drawn left-aligned,
//! for as long as it takes to ask for another frame, which is done here
//! immediately. Nobody sees a single frame at sixty of them a second; and if
//! it were ever visible, being briefly left-aligned is what the defect looked
//! like permanently.
use egui::{InnerResponse, Ui};
/// Draw a row of widgets centred in the width available.
///
/// A drop-in replacement for [`egui::Ui::horizontal`] wherever a row belongs
/// under centred headings. The returned response covers the row itself, not
/// the padding in front of it.
pub fn centred_row<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
// Tied to this call site rather than to anything the caller passes, so two
// rows in one panel cannot share a remembered width.
let id = ui.next_auto_id();
ui.skip_ahead_auto_ids(1);
let room = ui.available_width();
let remembered: Option<f32> = ui.ctx().memory(|m| m.data.get_temp(id));
// `min(room)` because a row wider than the window is drawn from the left
// rather than pushed off both edges: clipped on one side beats two.
let indent = remembered
.map(|was| ((room - was.min(room)) / 2.0).max(0.0))
.unwrap_or(0.0);
let mut inner = None;
let outer = ui.horizontal(|ui| {
ui.add_space(indent);
// Measured by how far the cursor travels, not by any child `Ui`'s
// rect. A nested `scope` reports the full available width for the same
// reason `horizontal` does, so measuring one would feed this the window
// width every frame and the indent would compute to zero: the exact
// defect being fixed, wearing the fix's own clothes.
let start = ui.cursor().min.x;
let value = add(ui);
let end = ui.cursor().min.x;
inner = Some(value);
(start, end)
});
// The row's own rect, not the horizontal box it sits in. `ui.horizontal`
// reports a rect that starts at the left edge and therefore includes the
// space put in front of the widgets, so a caller reading it back would be
// told the row is still hard left however far it was actually moved.
let (start, end) = outer.inner;
let mut response = outer.response;
let band = response.rect;
response.rect = egui::Rect::from_min_max(
egui::pos2(start, band.top()),
egui::pos2(end.max(start), band.bottom()),
);
// The cursor sits one item-spacing past the last widget, because egui has
// already made room for whatever might come next. Including that would
// centre the row plus a trailing gap, which puts the visible row half a
// spacing left of centre: measured at 4px on an 8px spacing, in a capture
// of the running window.
let measured = (end - start - ui.spacing().item_spacing.x).max(0.0);
if remembered != Some(measured) {
ui.ctx().memory_mut(|m| m.data.insert_temp(id, measured));
// Draw again straight away, so the corrected position is on screen on
// the next frame rather than whenever something else happens to move.
ui.ctx().request_repaint();
}
InnerResponse::new(inner.expect("the row body runs exactly once"), response)
}
/// A fixed-width column inside a row, so what follows it starts at one x.
///
/// # Why this is not `ui.label(format!("{label:<16}"))`
///
/// Padding a label with trailing spaces is the obvious way to make a column
/// and it aligns nothing outside a terminal. The interface font is
/// proportional, so a space is not the width of a letter and eight letters
/// plus two spaces is not the width of ten; and egui gives trailing
/// whitespace no reliable width at all. Rows padded that way sat at slightly
/// different places on different screens, and everything lined up beneath
/// them inherited the drift.
///
/// This has now been the cause of two findings in two different files, which
/// is why it is here rather than written out a third time.
///
/// # Why `set_min_width` is not optional
///
/// [`egui::Ui::allocate_ui_with_layout`] asks for a width and then gives back
/// only what the contents actually used, so a short label would take a short
/// column and the next widget would move left again, which is the whole
/// problem. `set_min_width` is what turns the request into a column.
pub fn column<R>(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
ui.allocate_ui_with_layout(
egui::vec2(width, ui.spacing().interact_size.y),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.set_min_width(width);
add(ui)
},
)
.inner
}
/// The width of every control that locks or unlocks the application.
///
/// **Finding F-196.** The header draws the theme picker and, beside it, the
/// button that locks the window; the lock screen draws the password field and,
/// beside it, the button that unlocks it. Each button was written where it was
/// needed and sized by whatever its own text happened to measure, so the two
/// halves of one idea, lock and unlock, were 46 and 98 points wide and neither
/// matched the control it sat next to.
///
/// One number, used by the picker and by both buttons. It is the picker's
/// width because the picker is the one of the three that has a list of theme
/// names to fit; a button is happy at any width and a dropdown is not.
pub const LOCK_WIDTH: f32 = 132.0;
/// The height this style gives a button: its own text, plus its own padding.
///
/// Needed because a row has to agree on a height *before* the taller of its
/// controls has been drawn. The password field is asked for this height, so
/// the field grows to the button rather than the button shrinking to the
/// field: both stay a comfortable target that way, and a button squeezed to a
/// text field's height reads as a link rather than as something to press.
///
/// The test below draws a real button and fails if this stops agreeing with
/// it, so it is checked against egui rather than assumed to match.
pub fn button_height(ui: &Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y
}
/// A button that locks or unlocks: one width, and the height of its neighbour.
///
/// The height is passed in rather than worked out, because what it has to
/// match differs by where it is drawn: the theme picker in the header, the
/// password field on the lock screen. The width does not differ, which is the
/// point of [`LOCK_WIDTH`].
pub fn lock_button<'a>(text: egui::RichText, height: f32) -> egui::Button<'a> {
egui::Button::new(text).min_size(egui::vec2(LOCK_WIDTH, height))
}
#[cfg(test)]
mod tests {
use super::*;
/// Lay the same content out twice through one `Ui`, so the second call
/// sees what the first remembered. Returns where the row starts each time.
/// A context whose window really is `width` across.
///
/// `Ui::set_width` does not do this: it sets a minimum, and the panel goes
/// on offering the whole screen, which in a default test context is nearly
/// ten thousand points. Centring inside that is centring inside the wrong
/// number, so the screen itself is sized here.
fn input(width: f32) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(width, 400.0),
)),
..Default::default()
}
}
fn twice(width: f32, mut body: impl FnMut(&mut Ui)) -> (f32, f32, f32) {
let ctx = egui::Context::default();
let mut lefts = Vec::new();
let mut available = 0.0_f32;
for _ in 0..2 {
let _ = crate::headless_frame(&ctx, input(width), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
let origin = ui.min_rect().left();
available = ui.available_width();
let left = centred_row(ui, |ui| body(ui)).response.rect.left();
lefts.push(left - origin);
});
});
}
(lefts[0], lefts[1], available)
}
/// The row lands in the middle once its width is known.
///
/// Both halves matter. Asserting only that the centred row is centred
/// would pass just as happily if egui had been centring rows all along,
/// and the helper would be dead weight nobody could tell was dead. So the
/// same content is drawn as a plain row too, and the two must differ.
#[test]
fn a_centred_row_is_centred_and_a_plain_row_is_not() {
let (first, second, available) = twice(600.0, |ui| {
ui.label("password");
let _ = ui.button("unlock");
});
assert!(
available > 100.0,
"the test ui has no room to centre within"
);
// First frame has nothing remembered, so it sits at the left. That is
// the documented cost, asserted rather than hoped for.
assert!(
first < 1.0,
"the first frame should be left-aligned, and started at {first}"
);
// Second frame knows the width and centres it.
assert!(
second > 100.0,
"the second frame did not centre the row: it starts at {second} \
of {available} available"
);
// And the row is as far from the right edge as from the left.
let ctx = egui::Context::default();
let mut gaps = (0.0_f32, 0.0_f32);
for _ in 0..2 {
let _ = crate::headless_frame(&ctx, input(600.0), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
let origin = ui.min_rect().left();
let room = ui.available_width();
let rect = centred_row(ui, |ui| {
ui.label("password");
let _ = ui.button("unlock");
})
.response
.rect;
gaps = (rect.left() - origin, room - (rect.right() - origin));
});
});
}
assert!(
(gaps.0 - gaps.1).abs() < 12.0,
"not centred: {} to the left, {} to the right",
gaps.0,
gaps.1
);
}
/// A row too wide for the window starts at the left, not off both edges.
#[test]
fn a_row_wider_than_the_window_is_not_pushed_off_both_edges() {
let (_, second, _) = twice(40.0, |ui| {
ui.label("a label a great deal wider than forty points across");
});
assert!(
second >= -0.5,
"the row was pushed to {second}, off the left edge"
);
}
/// The body runs once per frame, however many times the row is measured.
///
/// This is the constraint the whole design exists for: the unlock row
/// spawns a key derivation when its button reports a click, and a helper
/// that ran its closure twice could spawn it twice from one press.
#[test]
fn the_row_body_runs_exactly_once_per_frame() {
let ctx = egui::Context::default();
let mut runs = 0_u32;
for _ in 0..3 {
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
centred_row(ui, |ui| {
runs += 1;
ui.label("once");
});
});
});
}
assert_eq!(runs, 3, "the body ran {runs} times across three frames");
}
/// [`button_height`] is what the toolkit actually makes a button.
///
/// It is arithmetic over the style rather than a number read out of egui,
/// which is fine while the two agree and silently wrong the day they stop.
/// So a real button is drawn and measured.
#[test]
fn the_height_a_button_is_predicted_to_be_is_the_height_it_is() {
let ctx = egui::Context::default();
crate::theme::install(&ctx);
let mut drawn = 0.0_f32;
let mut predicted = 0.0_f32;
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
predicted = button_height(ui);
drawn = ui.button("unlock").rect.height();
});
});
assert!(drawn > 0.0, "nothing was drawn");
assert!(
(drawn - predicted).abs() < 0.5,
"a button is {drawn:.2} tall and this predicts {predicted:.2}"
);
}
/// **Finding F-196.** The two buttons are one size, and each is the size
/// of what it stands beside.
///
/// Both rows are laid out here, in one frame, with this application's own
/// theme installed: without the theme every control is the same height by
/// default and the test would pass while measuring nothing.
#[test]
fn the_lock_and_unlock_buttons_are_one_size() {
let ctx = egui::Context::default();
crate::theme::install(&ctx);
let mut entry = String::from("a passphrase");
let (mut picker, mut lock) = (egui::Rect::NOTHING, egui::Rect::NOTHING);
let (mut field, mut unlock) = (egui::Rect::NOTHING, egui::Rect::NOTHING);
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
ui.horizontal(|ui| {
picker = egui::ComboBox::from_id_salt("header-theme")
.selected_text(egui::RichText::new("Tokyo Night").small())
.width(LOCK_WIDTH)
.show_ui(ui, |_| {})
.response
.rect;
lock = ui
.add(lock_button(
egui::RichText::new("lock").small(),
picker.height(),
))
.rect;
});
let row = button_height(ui);
ui.horizontal(|ui| {
field = ui
.add(
egui::TextEdit::singleline(&mut entry)
.password(true)
.desired_width(260.0)
.min_size(egui::vec2(260.0, row))
.vertical_align(egui::Align::Center),
)
.rect;
unlock = ui
.add(lock_button(egui::RichText::new("unlock").strong(), row))
.rect;
});
});
});
for (name, rect) in [
("picker", picker),
("lock", lock),
("field", field),
("unlock", unlock),
] {
assert!(rect.height() > 0.0, "{name} was not drawn");
}
// One size, both of them, which is what somebody looking at the two
// screens in turn actually sees.
assert!(
(lock.width() - unlock.width()).abs() < 0.5,
"the lock button is {:.1} wide and the unlock button {:.1}",
lock.width(),
unlock.width()
);
// And each in its place: the same height as its neighbour, on the same
// middle. Height alone was what the earlier fix checked, and two boxes
// of one height whose centres differ still read as out of place.
for (what, button, beside) in [("lock", lock, picker), ("unlock", unlock, field)] {
assert!(
(button.height() - beside.height()).abs() < 0.5,
"the {what} button is {:.1} tall beside a control {:.1} tall",
button.height(),
beside.height()
);
assert!(
(button.center().y - beside.center().y).abs() < 0.5,
"the {what} button sits {:.1} points off its neighbour's middle",
button.center().y - beside.center().y
);
}
}
/// And the shape all of that corrects, so none of it can pass by accident:
/// left to themselves, a button beside a password field is neither the
/// field's height nor the other button's width.
#[test]
fn buttons_left_to_size_themselves_match_nothing() {
let ctx = egui::Context::default();
crate::theme::install(&ctx);
let mut entry = String::from("a passphrase");
let (mut field, mut unlock, mut lock) = (
egui::Rect::NOTHING,
egui::Rect::NOTHING,
egui::Rect::NOTHING,
);
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
ui.horizontal(|ui| {
field = ui
.add(
egui::TextEdit::singleline(&mut entry)
.password(true)
.desired_width(260.0),
)
.rect;
unlock = ui
.add(egui::Button::new(
egui::RichText::new(" unlock ").strong(),
))
.rect;
lock = ui
.add(egui::Button::new(egui::RichText::new("lock").small()))
.rect;
});
});
});
assert!(
(unlock.height() - field.height()).abs() >= 0.5,
"a button and a password field are the same height with nothing \
making them match, so the test above would pass either way: \
{:.1} against {:.1}",
unlock.height(),
field.height()
);
assert!(
(unlock.width() - lock.width()).abs() >= 0.5,
"the two buttons are already one width: {:.1} against {:.1}",
unlock.width(),
lock.width()
);
}
}