mod order;
pub use order::RailOrder;
use crate::detail::Part;
pub(crate) use order::{RailEntry, move_to, published, request};
pub const COMPACT_WIDTH: f32 = 600.0;
const RAIL_WIDTH: f32 = 48.0;
const STATUS_MARGIN: f32 = 3.0;
const RAIL_END: f32 = 4.0;
pub struct Destination<D> {
pub id: D,
pub icon: egui::ImageSource<'static>,
pub label: &'static str,
}
impl<D> Destination<D> {
pub fn new(id: D, icon: egui::ImageSource<'static>, label: &'static str) -> Destination<D> {
Destination { id, icon, label }
}
}
pub struct Action {
pub id: &'static str,
pub icon: egui::ImageSource<'static>,
pub label: &'static str,
}
impl Action {
pub fn new(id: &'static str, icon: egui::ImageSource<'static>, label: &'static str) -> Action {
Action { id, icon, label }
}
}
pub enum RailItem<D> {
Screen(Destination<D>),
Action(Action),
}
impl<D> From<Destination<D>> for RailItem<D> {
fn from(d: Destination<D>) -> RailItem<D> {
RailItem::Screen(d)
}
}
impl<D> From<Action> for RailItem<D> {
fn from(a: Action) -> RailItem<D> {
RailItem::Action(a)
}
}
pub struct Shell<D> {
destinations: Vec<Destination<D>>,
actions: Vec<Action>,
footer: Vec<Destination<D>>,
defaults: Vec<Item>,
slots: Vec<Slot>,
on_rail: Vec<usize>,
footer_in_menu: bool,
clicked: Option<&'static str>,
#[cfg(test)]
action_rects: Vec<egui::Rect>,
#[cfg(test)]
footer_rects: Vec<egui::Rect>,
#[cfg(test)]
cell_rects: Vec<egui::Rect>,
#[cfg(test)]
more_rect: egui::Rect,
#[cfg(test)]
menu_rects: Vec<egui::Rect>,
open: Option<D>,
foot_size: egui::Vec2,
pane: crate::detail::DetailPane,
side_column: Option<bool>,
loaders: bool,
}
pub trait ShellApp<D> {
fn screen_ui(&mut self, ui: &mut egui::Ui, screen: D, side: &mut Side);
fn side_ui(&mut self, ui: &mut egui::Ui, screen: D, side: &mut Side) {
let _ = (ui, screen, side);
}
fn foot_ui(&mut self, ui: &mut egui::Ui) -> Option<D> {
let _ = ui;
None
}
fn status_ui(&mut self, ui: &mut egui::Ui) {
let _ = ui;
}
fn action(&mut self, id: &'static str, side: &mut Side) {
let _ = (id, side);
}
}
#[derive(Debug)]
pub struct Side {
pane: bool,
open: bool,
asked: Option<bool>,
}
impl Side {
pub fn open(&mut self) {
self.asked = Some(true);
self.open = self.pane;
}
pub fn close(&mut self) {
self.asked = Some(false);
self.open = false;
}
pub fn is_open(&self) -> bool {
self.open
}
}
struct Closures<F, S, C> {
foot: Option<F>,
status: Option<S>,
content: Option<C>,
}
impl<D, F, S, C> ShellApp<D> for Closures<F, S, C>
where
F: FnOnce(&mut egui::Ui) -> Option<D>,
S: FnOnce(&mut egui::Ui),
C: FnOnce(&mut egui::Ui, D),
{
fn screen_ui(&mut self, ui: &mut egui::Ui, screen: D, _: &mut Side) {
if let Some(content) = self.content.take() {
content(ui, screen);
}
}
fn foot_ui(&mut self, ui: &mut egui::Ui) -> Option<D> {
self.foot.take().and_then(|foot| foot(ui))
}
fn status_ui(&mut self, ui: &mut egui::Ui) {
if let Some(status) = self.status.take() {
status(ui);
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Item {
Screen(usize),
Action(usize),
}
#[derive(Clone, Copy, PartialEq, Debug)]
struct Slot {
item: Item,
shown: bool,
}
#[derive(Clone, Copy, PartialEq, Debug)]
struct Layout {
status: bool,
side: bool,
}
impl Layout {
const FULL: Layout = Layout {
status: true,
side: true,
};
const BARE: Layout = Layout {
status: false,
side: false,
};
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Row {
Footer(usize),
Slot(usize),
}
impl<D: Copy + PartialEq> Shell<D> {
pub fn new(destinations: Vec<Destination<D>>) -> Shell<D> {
Shell::from_items(destinations.into_iter().map(RailItem::from).collect())
}
pub fn from_items(items: Vec<RailItem<D>>) -> Shell<D> {
let mut shell = Shell {
destinations: Vec::new(),
actions: Vec::new(),
footer: Vec::new(),
defaults: Vec::new(),
slots: Vec::new(),
on_rail: Vec::new(),
footer_in_menu: false,
clicked: None,
#[cfg(test)]
action_rects: Vec::new(),
#[cfg(test)]
footer_rects: Vec::new(),
#[cfg(test)]
cell_rects: Vec::new(),
#[cfg(test)]
more_rect: egui::Rect::NOTHING,
#[cfg(test)]
menu_rects: Vec::new(),
open: None,
foot_size: egui::Vec2::ZERO,
pane: crate::detail::DetailPane::new("corundum_shell_side"),
side_column: None,
loaders: false,
};
for item in items {
shell.push(item);
}
shell.open = shell.destinations.first().map(|d| d.id);
shell.slots = shell.default_slots();
shell
}
fn push(&mut self, item: RailItem<D>) {
match item {
RailItem::Screen(d) => {
self.defaults.push(Item::Screen(self.destinations.len()));
self.destinations.push(d);
}
RailItem::Action(a) => {
self.defaults.push(Item::Action(self.actions.len()));
self.actions.push(a);
}
}
}
pub fn with_actions(mut self, actions: Vec<Action>) -> Shell<D> {
let order = self.rail_order();
for action in actions {
self.push(action.into());
}
self.set_rail_order(&order);
self
}
pub fn with_footer(mut self, footer: Vec<Destination<D>>) -> Shell<D> {
self.footer = footer;
if self.open.is_none() {
self.open = self.footer.first().map(|d| d.id);
}
self
}
pub fn with_rail_order(mut self, order: &RailOrder) -> Shell<D> {
self.set_rail_order(order);
self
}
pub fn rail_order(&self) -> RailOrder {
RailOrder {
order: self.slots.iter().map(|s| self.key(s.item).into()).collect(),
hidden: self
.slots
.iter()
.filter(|s| !s.shown)
.map(|s| self.key(s.item).into())
.collect(),
}
}
pub fn set_rail_order(&mut self, order: &RailOrder) {
let place = |key: &str| {
order
.order
.iter()
.position(|k| k == key)
.unwrap_or(usize::MAX)
};
let mut slots = self.default_slots();
slots.sort_by_key(|s| place(self.key(s.item)));
for slot in &mut slots {
slot.shown = !order.hidden.iter().any(|k| k == self.key(slot.item));
}
self.slots = slots;
}
fn default_slots(&self) -> Vec<Slot> {
self.defaults
.iter()
.map(|&item| Slot { item, shown: true })
.collect()
}
fn key(&self, item: Item) -> &'static str {
match item {
Item::Screen(i) => self.destinations[i].label,
Item::Action(i) => self.actions[i].id,
}
}
fn icon_and_label(&self, item: Item) -> (egui::ImageSource<'static>, &'static str) {
match item {
Item::Screen(i) => (
self.destinations[i].icon.clone(),
self.destinations[i].label,
),
Item::Action(i) => (self.actions[i].icon.clone(), self.actions[i].label),
}
}
fn sync_order(&mut self, ctx: &egui::Context) {
if let Some(order) = order::take_request(ctx) {
self.set_rail_order(&order);
#[cfg(feature = "serde")]
crate::store::update(ctx, |state| state.rail = Some(self.rail_order()));
}
let entries = self
.slots
.iter()
.map(|s| {
let (icon, label) = self.icon_and_label(s.item);
RailEntry {
key: self.key(s.item),
icon,
label,
shown: s.shown,
}
})
.collect();
order::publish(ctx, entries);
}
#[deprecated(
since = "0.3.0",
note = "draw with `show`, which hands the action to `ShellApp::action`"
)]
pub fn take_action(&mut self) -> Option<&'static str> {
self.clicked.take()
}
pub fn current(&self) -> Option<D> {
self.open
}
pub fn open(&mut self, id: D) {
self.open = Some(id);
}
#[deprecated(since = "0.3.0", note = "every shell has a side pane")]
pub fn with_side_pane(self) -> Shell<D> {
self
}
#[deprecated(since = "0.3.0", note = "`show` always draws a status bar, wide")]
pub fn with_status_bar(self) -> Shell<D> {
self
}
pub fn show(&mut self, ui: &mut egui::Ui, app: &mut impl ShellApp<D>) {
self.frame_ui(ui, app, Layout::FULL);
if let Some(id) = self.clicked.take() {
let mut side = self.side(true);
app.action(id, &mut side);
self.apply_side(side);
ui.ctx().request_repaint();
}
}
#[deprecated(since = "0.3.0", note = "implement `ShellApp` and draw with `show`")]
pub fn ui(&mut self, ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui, D)) {
let mut app = Closures {
foot: Some(|_: &mut egui::Ui| None),
status: None::<fn(&mut egui::Ui)>,
content: Some(content),
};
self.frame_ui(ui, &mut app, Layout::BARE);
}
#[deprecated(
since = "0.3.0",
note = "implement `ShellApp` (`foot_ui`) and draw with `show`"
)]
pub fn ui_with_foot(
&mut self,
ui: &mut egui::Ui,
foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
content: impl FnOnce(&mut egui::Ui, D),
) {
let mut app = Closures {
foot: Some(foot),
status: None::<fn(&mut egui::Ui)>,
content: Some(content),
};
self.frame_ui(ui, &mut app, Layout::BARE);
}
#[deprecated(
since = "0.3.0",
note = "implement `ShellApp` (`foot_ui`, `status_ui`) and draw with `show`"
)]
pub fn ui_with_status(
&mut self,
ui: &mut egui::Ui,
foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
status: impl FnOnce(&mut egui::Ui),
content: impl FnOnce(&mut egui::Ui, D),
) {
let mut app = Closures {
foot: Some(foot),
status: Some(status),
content: Some(content),
};
self.frame_ui(
ui,
&mut app,
Layout {
status: true,
side: false,
},
);
}
fn side(&self, pane: bool) -> Side {
Side {
pane,
open: pane && self.pane.is_open(),
asked: None,
}
}
fn apply_side(&mut self, side: Side) {
if !side.pane {
return;
}
match side.asked {
Some(true) => self.pane.open(),
Some(false) => self.pane.close(),
None => {}
}
}
fn content_ui(&mut self, ui: &mut egui::Ui, app: &mut impl ShellApp<D>, id: D, layout: Layout) {
let mut side = self.side(layout.side);
if layout.side {
self.pane.show(ui, |ui, part| match part {
Part::Main => app.screen_ui(ui, id, &mut side),
Part::Detail => app.side_ui(ui, id, &mut side),
});
} else {
app.screen_ui(ui, id, &mut side);
}
self.apply_side(side);
}
fn sync_side(&mut self, ctx: &egui::Context, first: bool) {
let pane = &mut self.pane;
#[cfg(feature = "serde")]
{
if first && let Some(saved) = crate::store::state(ctx).side {
pane.set_column(saved.column);
}
let column = pane.column();
if !first && self.side_column != Some(column) {
crate::store::update(ctx, |state| {
state.side = Some(crate::store::SidePane { column });
});
}
}
#[cfg(not(feature = "serde"))]
let _ = (ctx, first);
self.side_column = Some(pane.column());
}
fn frame_ui(&mut self, ui: &mut egui::Ui, app: &mut impl ShellApp<D>, layout: Layout) {
let first = !std::mem::replace(&mut self.loaders, true);
if first {
crate::icons::install_loaders(ui.ctx());
if !crate::theme::is_installed(ui.ctx()) {
crate::theme::install(ui.ctx(), &crate::theme::ThemeSettings::default());
}
if !crate::i18n::is_installed(ui.ctx()) {
crate::i18n::install(ui.ctx(), &crate::i18n::LanguageSettings::default());
}
#[cfg(feature = "serde")]
if let Some(order) = crate::store::state(ui.ctx()).rail {
self.set_rail_order(&order);
}
self.sync_side(ui.ctx(), true);
}
self.sync_order(ui.ctx());
let safe = unsafe_margin(ui);
let full = ui.max_rect();
let compact = full.width() - safe.sum().x < COMPACT_WIDTH;
let inner = if compact {
full - egui::epaint::MarginF32 {
bottom: 0.0,
..safe
}
} else {
full - safe
};
if safe != egui::epaint::MarginF32::ZERO {
ui.painter().rect_filled(full, 0.0, ui.visuals().panel_fill);
}
if !compact && layout.status {
let height = ui.spacing().interact_size.y + 2.0 * STATUS_MARGIN;
let above = inner.with_max_y(inner.bottom() - height);
let strip = inner.with_min_y(above.bottom());
ui.scope_builder(egui::UiBuilder::new().max_rect(above), |ui| {
let opened = egui::Panel::left("shell_rail")
.resizable(false)
.exact_size(RAIL_WIDTH)
.show(ui, |ui| self.rail_ui(ui, |_| None))
.inner;
if let Some(id) = opened {
self.open = Some(id);
}
if let Some(id) = self.open {
self.content_ui(ui, app, id, layout);
}
});
if let Some(id) = status_bar_ui(ui, strip, app) {
self.open = Some(id);
ui.ctx().request_repaint();
}
} else {
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
let foot = |ui: &mut egui::Ui| app.foot_ui(ui);
let opened = if compact {
let mut frame = egui::Frame::side_top_panel(ui.style());
frame.inner_margin.bottom = frame
.inner_margin
.bottom
.saturating_add(safe.bottom.round().min(127.0) as i8);
egui::Panel::bottom("shell_bar")
.frame(frame)
.show(ui, |ui| self.bar_ui(ui, foot))
.inner
} else {
egui::Panel::left("shell_rail")
.resizable(false)
.exact_size(RAIL_WIDTH)
.show(ui, |ui| self.rail_ui(ui, foot))
.inner
};
if let Some(id) = opened {
self.open = Some(id);
}
if let Some(id) = self.open {
self.content_ui(ui, app, id, layout);
}
});
}
self.sync_side(ui.ctx(), false);
}
fn fit(&self, shown: usize, room: f32, step: f32) -> (usize, bool) {
let hidden = shown < self.slots.len();
let footer = self.footer.len() as f32 * step;
if !hidden && shown as f32 * step + footer <= room {
return (shown, false);
}
let held = ((room - step) / step).floor().max(0.0) as usize;
(held.min(shown), true)
}
fn foot_measured(&mut self, ctx: &egui::Context, size: egui::Vec2) {
if (size - self.foot_size).length() > 0.5 {
self.foot_size = size;
ctx.request_discard("the shell's foot changed size");
}
}
fn rail_ui(
&mut self,
ui: &mut egui::Ui,
foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
) -> Option<D> {
let full = ui.available_rect_before_wrap();
let step = BUTTON + ui.spacing().item_spacing.y;
let shown = self.shown();
let room = full.height() - 2.0 * RAIL_END - self.foot_size.y;
let (held, more) = self.fit(shown.len(), room, step);
self.lay_out(&shown[..held], more);
let bottom = ui.scope_builder(
egui::UiBuilder::new()
.max_rect(full)
.layout(egui::Layout::bottom_up(egui::Align::Center)),
|ui| {
ui.add_space(RAIL_END);
self.end_ui(ui, more, false);
let foot = ui.scope(foot);
(foot.inner, foot.response.rect.size())
},
);
let (opened, foot_size) = bottom.inner;
self.foot_measured(ui.ctx(), foot_size);
let top = full.with_max_y(bottom.response.rect.top());
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(top)
.layout(egui::Layout::top_down(egui::Align::Center)),
|ui| {
ui.add_space(RAIL_END);
for &s in &shown[..held] {
self.slot_ui(ui, s, BUTTON);
}
},
);
opened
}
fn bar_ui(
&mut self,
ui: &mut egui::Ui,
foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
) -> Option<D> {
let rect = ui.available_rect_before_wrap();
let gap = ui.spacing().item_spacing.x;
let step = BUTTON + gap;
let shown = self.shown();
let room = rect.width() - self.foot_size.x - gap;
let (held, more) = self.fit(shown.len(), room, step);
self.lay_out(&shown[..held], more);
let end = ui.scope_builder(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|ui| {
let foot = ui.scope(foot);
self.end_ui(ui, more, true);
(foot.inner, foot.response.rect.size())
},
);
let (opened, foot_size) = end.inner;
self.foot_measured(ui.ctx(), foot_size);
let cells = held;
if cells > 0 {
let row = rect.with_max_x(end.response.rect.left() - gap);
ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| {
ui.columns(cells, |columns| {
for (i, column) in columns.iter_mut().enumerate() {
column.vertical_centered(|ui| {
let side = ui.available_width().min(BUTTON);
self.slot_ui(ui, shown[i], side);
});
}
});
});
}
opened
}
fn lay_out(&mut self, on_rail: &[usize], more: bool) {
self.on_rail = on_rail.to_vec();
self.footer_in_menu = more;
#[cfg(test)]
{
self.cell_rects.clear();
self.footer_rects.clear();
self.more_rect = egui::Rect::NOTHING;
}
}
fn end_ui(&mut self, ui: &mut egui::Ui, more: bool, backwards: bool) {
if more {
self.more_ui(ui);
return;
}
let mut order: Vec<usize> = (0..self.footer.len()).collect();
if backwards {
order.reverse();
}
for i in order {
self.footer_ui(ui, i);
}
}
fn shown(&self) -> Vec<usize> {
(0..self.slots.len())
.filter(|&s| self.slots[s].shown)
.collect()
}
fn slot_ui(&mut self, ui: &mut egui::Ui, s: usize, side: f32) {
let response = match self.slots[s].item {
Item::Screen(i) => self.destination_ui(ui, i, side),
Item::Action(i) => self.action_ui(ui, i, side),
};
#[cfg(test)]
self.cell_rects.push(response.rect);
#[cfg(not(test))]
let _ = response;
}
fn menu_rows(&self) -> Vec<Row> {
let footer = (0..self.footer.len()).map(Row::Footer);
let off = (0..self.slots.len())
.filter(|s| !self.on_rail.contains(s))
.map(Row::Slot);
footer.chain(off).collect()
}
fn more_ui(&mut self, ui: &mut egui::Ui) {
let rows = self.menu_rows();
let open_inside = rows
.iter()
.any(|&row| self.row_opens(row) == self.open && self.open.is_some());
let response = icon_button(ui, open_inside, crate::icons::ellipsis(), BUTTON)
.on_hover_text(crate::i18n::tr(ui.ctx(), "corundum-shell-more"));
#[cfg(test)]
{
self.more_rect = response.rect;
}
egui::Popup::menu(&response).show(|ui| self.menu_ui(ui, &rows));
}
fn row_opens(&self, row: Row) -> Option<D> {
match row {
Row::Footer(i) => Some(self.footer[i].id),
Row::Slot(s) => match self.slots[s].item {
Item::Screen(i) => Some(self.destinations[i].id),
Item::Action(_) => None,
},
}
}
fn menu_ui(&mut self, ui: &mut egui::Ui, rows: &[Row]) {
#[cfg(test)]
self.menu_rects.clear();
for (n, &row) in rows.iter().enumerate() {
if n > 0 && matches!(rows[n - 1], Row::Footer(_)) && matches!(row, Row::Slot(_)) {
ui.separator();
}
let (icon, label) = match row {
Row::Footer(i) => (self.footer[i].icon.clone(), self.footer[i].label),
Row::Slot(s) => self.icon_and_label(self.slots[s].item),
};
let open = self.open.is_some() && self.row_opens(row) == self.open;
let image = crate::icons::image(ui, icon, 16.0);
let response = ui.add(
egui::Button::image_and_text(image, crate::i18n::tr(ui.ctx(), label))
.selected(open),
);
#[cfg(test)]
self.menu_rects.push(response.rect);
if response.clicked() {
match row {
Row::Slot(s) => match self.slots[s].item {
Item::Screen(i) => self.open = Some(self.destinations[i].id),
Item::Action(i) => self.clicked = Some(self.actions[i].id),
},
Row::Footer(i) => self.open = Some(self.footer[i].id),
}
}
}
}
fn destination_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
let d = &self.destinations[i];
let open = self.open == Some(d.id);
let response = icon_button(ui, open, d.icon.clone(), side)
.on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
if response.clicked() {
self.open = Some(d.id);
}
response
}
#[cfg(test)]
fn label_text(&self, ctx: &egui::Context, i: usize) -> String {
crate::i18n::tr(ctx, self.destinations[i].label)
}
fn footer_ui(&mut self, ui: &mut egui::Ui, i: usize) {
let d = &self.footer[i];
let open = self.open == Some(d.id);
let response = icon_button(ui, open, d.icon.clone(), BUTTON)
.on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
#[cfg(test)]
{
self.footer_rects
.resize(self.footer.len(), egui::Rect::NOTHING);
self.footer_rects[i] = response.rect;
}
if response.clicked() {
self.open = Some(self.footer[i].id);
}
}
fn action_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
let a = &self.actions[i];
let response = icon_button(ui, false, a.icon.clone(), side)
.on_hover_text(crate::i18n::tr(ui.ctx(), a.label));
#[cfg(test)]
{
self.action_rects
.resize(self.actions.len(), egui::Rect::NOTHING);
self.action_rects[i] = response.rect;
}
if response.clicked() {
self.clicked = Some(a.id);
}
response
}
}
fn status_bar_ui<D>(ui: &mut egui::Ui, strip: egui::Rect, app: &mut impl ShellApp<D>) -> Option<D> {
let visuals = ui.visuals();
ui.painter().rect_filled(strip, 0.0, visuals.panel_fill);
ui.painter().hline(
strip.x_range(),
strip.top(),
visuals.widgets.noninteractive.bg_stroke,
);
let row = strip.shrink2(egui::vec2(8.0, STATUS_MARGIN));
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(row)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|ui| {
ui.set_clip_rect(row);
crate::detail::toggle_ui(ui);
let opened = app.foot_ui(ui);
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
app.status_ui(ui)
});
opened
},
)
.inner
}
fn unsafe_margin(ui: &egui::Ui) -> egui::epaint::MarginF32 {
let safe = ui.ctx().content_rect();
let rect = ui.max_rect();
egui::epaint::MarginF32 {
left: (safe.left() - rect.left()).max(0.0),
right: (rect.right() - safe.right()).max(0.0),
top: (safe.top() - rect.top()).max(0.0),
bottom: (rect.bottom() - safe.bottom()).max(0.0),
}
}
const BUTTON: f32 = 36.0;
fn icon_button(
ui: &mut egui::Ui,
open: bool,
icon: egui::ImageSource<'static>,
side: f32,
) -> egui::Response {
let pad = ui.spacing().button_padding.x;
let icon_size = (side - 2.0 * pad).clamp(12.0, 20.0);
ui.scope(|ui| {
let padding = &mut ui.spacing_mut().button_padding.x;
*padding = padding.min(((side - icon_size) / 2.0).max(0.0));
ui.add(
crate::icons::button(icon, icon_size)
.selected(open)
.frame_when_inactive(open)
.min_size(egui::vec2(side, BUTTON)),
)
})
.inner
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use crate::test_support::render;
#[derive(Clone, Copy, PartialEq, Debug)]
enum Screen {
Entries,
Summary,
Prefs,
}
const SIZES: [[f32; 2]; 2] = [[900.0, 600.0], [400.0, 700.0]];
fn shell() -> Shell<Screen> {
Shell::new(vec![
Destination::new(Screen::Entries, crate::icons::settings(), "entries"),
Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary"),
])
}
fn prefs() -> Vec<Destination<Screen>> {
vec![Destination::new(
Screen::Prefs,
crate::icons::settings(),
"preferences",
)]
}
fn screen(ui: &mut egui::Ui) {
egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
}
fn click(shell: &mut Shell<Screen>, size: [f32; 2], at: impl Fn(&Shell<Screen>) -> egui::Pos2) {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
let frame = |events: Vec<egui::Event>, shell: &mut Shell<Screen>| {
let input = egui::RawInput {
screen_rect: Some(rect),
events,
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| shell.ui(ui, |ui, _| screen(ui)));
output.textures_delta.clear();
};
frame(vec![], shell);
frame(vec![], shell);
let pos = at(shell);
let button = |pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
frame(vec![egui::Event::PointerMoved(pos)], shell);
frame(vec![button(true)], shell);
frame(vec![button(false)], shell);
}
#[test]
fn it_opens_on_the_first_screen_and_draws_it() {
let mut shell = shell();
assert_eq!(shell.current(), Some(Screen::Entries));
for size in SIZES {
let mut drawn = Vec::new();
render(size, |ui| {
shell.ui(ui, |ui, s| {
drawn.push(s);
screen(ui);
})
});
assert_eq!(drawn, [Screen::Entries, Screen::Entries]);
}
}
#[test]
fn actions_are_drawn_in_both_layouts_and_report_nothing_unclicked() {
let mut shell =
shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
for size in SIZES {
shell.action_rects.clear();
render(size, |ui| shell.ui(ui, |ui, _| screen(ui)));
assert!(shell.action_rects[0].is_positive(), "{size:?}");
assert_eq!(shell.take_action(), None);
}
assert_eq!(shell.current(), Some(Screen::Entries));
}
#[test]
fn a_clicked_action_is_taken_once_and_opens_nothing() {
let mut shell =
shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
click(&mut shell, SIZES[0], |s| s.action_rects[0].center());
assert_eq!(shell.take_action(), Some("new"));
assert_eq!(shell.take_action(), None);
assert_eq!(shell.current(), Some(Screen::Entries));
}
#[test]
fn footer_and_foot_are_in_both_layouts() {
let mut shell = shell().with_footer(prefs());
for size in SIZES {
shell.footer_rects.clear();
let mut foot = egui::Rect::NOTHING;
render(size, |ui| {
shell.ui_with_foot(
ui,
|ui| {
foot = ui
.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
.0;
None
},
|ui, _| screen(ui),
)
});
assert!(shell.footer_rects[0].is_positive(), "{size:?}");
assert!(foot.is_positive(), "{size:?}");
}
}
#[test]
fn clicking_a_footer_destination_opens_it() {
for size in SIZES {
let mut shell = shell().with_footer(prefs());
click(&mut shell, size, |s| s.footer_rects[0].center());
assert_eq!(shell.current(), Some(Screen::Prefs), "{size:?}");
}
let mut shell = shell().with_footer(prefs());
shell.open(Screen::Prefs);
let mut drawn = Vec::new();
render([900.0, 600.0], |ui| {
shell.ui(ui, |ui, s| {
drawn.push(s);
screen(ui);
})
});
assert_eq!(drawn, [Screen::Prefs, Screen::Prefs]);
}
#[test]
fn a_destination_the_foot_returns_is_opened() {
let mut shell = shell();
let mut drawn = Vec::new();
render([900.0, 600.0], |ui| {
shell.ui_with_foot(
ui,
|_| Some(Screen::Summary),
|ui, s| {
drawn.push(s);
screen(ui);
},
)
});
assert_eq!(shell.current(), Some(Screen::Summary));
assert_eq!(drawn, [Screen::Summary, Screen::Summary]);
}
#[test]
fn the_shell_installs_the_default_theme() {
let ctx = egui::Context::default();
let mut shell = shell();
crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
assert!(crate::theme::is_installed(&ctx));
}
#[test]
fn the_shell_keeps_an_installed_theme() {
use crate::theme::{Contrast, ThemeSettings};
let ctx = egui::Context::default();
let settings = ThemeSettings {
preference: egui::ThemePreference::Dark,
contrast: Contrast::High,
..Default::default()
};
crate::theme::install(&ctx, &settings);
let mut shell = shell();
crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
let high = crate::theme::Theme::new(
true,
Contrast::High,
crate::theme::Tint::Swatch(crate::theme::Swatch::Blue),
);
assert_eq!(*crate::theme::current(&ctx), high);
}
const T: crate::i18n::Resources = crate::i18n::Resources {
name: "shell-test",
files: &[
("en", "t-entries = Entries\n"),
("ja", "t-entries = é …ç›®\n"),
],
};
#[test]
fn the_shell_installs_the_default_language() {
let ctx = egui::Context::default();
let mut shell = shell();
crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
assert!(crate::i18n::is_installed(&ctx));
}
#[test]
fn the_shell_keeps_an_installed_language() {
use crate::i18n::{Language, LanguageSettings};
let ctx = egui::Context::default();
let ja = LanguageSettings {
language: Language::Explicit("ja".into()),
};
crate::i18n::install(&ctx, &ja);
let mut shell = shell();
crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
assert_eq!(crate::i18n::current(&ctx).to_string(), "ja");
}
#[test]
fn hover_text_follows_the_language() {
use crate::i18n::{Language, LanguageSettings};
let ctx = egui::Context::default();
crate::i18n::register(&ctx, T);
let shell = Shell::new(vec![Destination::new(
Screen::Entries,
crate::icons::settings(),
"t-entries",
)]);
let set = |tag: &str| {
crate::i18n::install(
&ctx,
&LanguageSettings {
language: Language::Explicit(tag.into()),
},
)
};
set("en");
assert_eq!(shell.label_text(&ctx, 0), "Entries");
set("ja");
assert_eq!(shell.label_text(&ctx, 0), "é …ç›®");
}
fn render_safe(
shell: &mut Shell<Screen>,
size: [f32; 2],
safe: egui::epaint::MarginF32,
own: egui::epaint::MarginF32,
) -> egui::Rect {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
let mut drawn = egui::Rect::NOTHING;
for _ in 0..2 {
let input = egui::RawInput {
screen_rect: Some(rect),
safe_area_insets: Some(egui::SafeAreaInsets(safe)),
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| {
let inner = ui.max_rect() - own;
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
shell.ui(ui, |ui, _| {
drawn = ui.available_rect_before_wrap();
screen(ui);
})
});
});
output.textures_delta.clear();
}
drawn
}
fn margin(left: f32, top: f32, right: f32, bottom: f32) -> egui::epaint::MarginF32 {
egui::epaint::MarginF32 {
left,
right,
top,
bottom,
}
}
#[test]
fn narrow_it_keeps_clear_of_the_status_bar_and_the_home_indicator() {
let mut shell = shell().with_footer(prefs());
let drawn = render_safe(
&mut shell,
[400.0, 800.0],
margin(0.0, 62.0, 0.0, 34.0),
egui::epaint::MarginF32::ZERO,
);
assert_eq!(drawn.top(), 62.0);
let button = shell.footer_rects[0];
assert!(button.bottom() <= 800.0 - 34.0, "{button:?}");
assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
assert!(drawn.bottom() <= button.top(), "{drawn:?} {button:?}");
}
#[test]
fn wide_it_keeps_clear_of_the_notch_and_the_home_indicator() {
let mut shell = shell().with_footer(prefs());
let drawn = render_safe(
&mut shell,
[900.0, 400.0],
margin(59.0, 0.0, 59.0, 21.0),
egui::epaint::MarginF32::ZERO,
);
let button = shell.footer_rects[0];
assert!(button.left() >= 59.0, "{button:?}");
assert!(button.bottom() <= 400.0 - 21.0, "{button:?}");
assert!(drawn.right() <= 900.0 - 59.0, "{drawn:?}");
assert!(drawn.bottom() <= 400.0 - 21.0, "{drawn:?}");
}
#[test]
fn a_margin_the_app_already_keeps_is_not_doubled() {
let safe = margin(0.0, 62.0, 0.0, 34.0);
let mut shell = shell().with_footer(prefs());
let drawn = render_safe(&mut shell, [400.0, 800.0], safe, safe);
assert_eq!(drawn.top(), 62.0);
let button = shell.footer_rects[0];
assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
}
#[test]
fn with_only_a_footer_it_opens_on_the_footer() {
let shell = Shell::new(vec![]).with_footer(prefs());
assert_eq!(shell.current(), Some(Screen::Prefs));
}
fn crowded_shell() -> Shell<Screen> {
const NAMES: [&str; 8] = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"];
let mut items: Vec<RailItem<Screen>> =
vec![Action::new("new", crate::icons::plus(), "new-entry").into()];
items.extend(
NAMES.iter().map(|&name| {
Destination::new(Screen::Entries, crate::icons::settings(), name).into()
}),
);
Shell::from_items(items).with_footer(prefs())
}
fn crowded(shell: &mut Shell<Screen>, size: [f32; 2], status: bool) -> egui::Rect {
let mut foot = egui::Rect::NOTHING;
render(size, |ui| {
let dot = |ui: &mut egui::Ui| {
foot = ui
.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
.0;
None
};
if status {
shell.ui_with_status(ui, dot, |ui| drop(ui.label("saved")), |ui, _| screen(ui));
} else {
shell.ui_with_foot(ui, dot, |ui, _| screen(ui));
}
});
foot
}
#[test]
fn items_and_actions_mix_in_the_apps_order() {
let mut shell = crowded_shell();
assert_eq!(
shell.current(),
Some(Screen::Entries),
"the first screen, not the action"
);
crowded(&mut shell, [914.0, 900.0], true);
assert_eq!(shell.cell_rects.len(), 9);
assert_eq!(
shell.cell_rects[0], shell.action_rects[0],
"the action on top"
);
assert!(!shell.footer_in_menu);
assert!(shell.footer_rects[0].is_positive());
assert_eq!(shell.more_rect, egui::Rect::NOTHING, "no menu when all fit");
}
#[test]
fn a_crowded_bar_puts_the_more_button_where_the_footer_was() {
for width in [320.0, 360.0, 411.0] {
let mut shell = crowded_shell();
let foot = crowded(&mut shell, [width, 700.0], false);
let (cells, more) = (&shell.cell_rects, shell.more_rect);
assert!(
!cells.is_empty() && cells.len() < 9,
"{width}: {}",
cells.len()
);
assert!(shell.footer_in_menu, "{width}");
assert!(
shell.footer_rects.is_empty(),
"{width}: the footer is in the menu"
);
for rect in cells.iter().chain([&more, &foot]) {
assert!(rect.is_positive(), "{width}: {rect:?}");
assert!(
rect.left() >= 0.0 && rect.right() <= width,
"{width}: {rect:?}"
);
}
let row: Vec<egui::Rect> = cells.iter().copied().chain([more, foot]).collect();
for pair in row.windows(2) {
assert!(pair[0].right() <= pair[1].left(), "{width}: {pair:?}");
}
for rect in cells.iter().chain([&more]) {
assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{width}: {rect:?}");
assert_eq!(rect.center().y, more.center().y, "{width}: {rect:?}");
}
}
}
#[test]
fn a_low_rail_keeps_its_items_clear_of_the_more_button() {
for (height, status) in [(411.0, true), (411.0, false), (300.0, true), (160.0, false)] {
let mut shell = crowded_shell();
let foot = crowded(&mut shell, [914.0, height], status);
let (cells, more) = (&shell.cell_rects, shell.more_rect);
let case = format!("{height} {status}");
assert!(cells.len() < 9, "{case}: {}", cells.len());
assert!(
shell.footer_rects.is_empty(),
"{case}: the footer is in the menu"
);
assert!(more.is_positive(), "{case}");
let column: Vec<egui::Rect> = cells.iter().copied().chain([more]).collect();
for pair in column.windows(2) {
assert!(pair[0].bottom() <= pair[1].top(), "{case}: {pair:?}");
}
if !status {
let last = cells.last().copied().unwrap_or(egui::Rect::NOTHING);
assert!(last.bottom() <= foot.top(), "{case}: {last:?} {foot:?}");
assert!(foot.bottom() <= more.top(), "{case}: {foot:?} {more:?}");
}
}
}
#[test]
fn at_every_height_the_footer_or_the_more_button_holds_the_end() {
let mut both = (false, false);
for height in (200..=900).step_by(10) {
let height = height as f32;
let mut shell = crowded_shell();
crowded(&mut shell, [914.0, height], true);
let cells = &shell.cell_rects;
let end = if shell.footer_in_menu {
both.1 = true;
assert!(cells.len() < 9, "{height}");
assert!(shell.footer_rects.is_empty(), "{height}");
shell.more_rect
} else {
both.0 = true;
assert_eq!(cells.len(), 9, "{height}: all fit, or the footer goes");
assert_eq!(shell.more_rect, egui::Rect::NOTHING, "{height}");
shell.footer_rects[0]
};
assert!(end.is_positive(), "{height}");
for pair in cells
.iter()
.copied()
.chain([end])
.collect::<Vec<_>>()
.windows(2)
{
assert!(pair[0].bottom() <= pair[1].top(), "{height}: {pair:?}");
}
}
assert_eq!(
both,
(true, true),
"the sweep crosses from one to the other"
);
}
fn keys(shell: &Shell<Screen>) -> Vec<String> {
shell.rail_order().order
}
#[test]
fn the_rail_order_round_trips_and_skips_names_it_does_not_know() {
let mut shell = crowded_shell();
assert_eq!(keys(&shell)[..3], ["new", "s0", "s1"]);
let order = RailOrder {
order: vec!["s3".into(), "gone".into(), "new".into(), "s1".into()],
hidden: vec!["s1".into(), "gone".into()],
};
shell.set_rail_order(&order);
assert_eq!(
keys(&shell),
["s3", "new", "s1", "s0", "s2", "s4", "s5", "s6", "s7"]
);
assert_eq!(shell.rail_order().hidden, ["s1"]);
let again = shell.rail_order();
shell.set_rail_order(&again);
assert_eq!(shell.rail_order(), again);
shell.set_rail_order(&RailOrder::default());
assert_eq!(
keys(&shell)[..3],
["new", "s0", "s1"],
"the default is the app's"
);
}
#[test]
fn with_actions_still_puts_them_last() {
let shell =
shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
assert_eq!(keys(&shell), ["entries", "summary", "new"]);
}
#[test]
fn a_hidden_item_sends_the_footer_into_the_menu() {
let mut shell = shell().with_footer(prefs()).with_rail_order(&RailOrder {
order: vec![],
hidden: vec!["entries".into()],
});
for size in SIZES {
render(size, |ui| shell.ui(ui, |ui, _| screen(ui)));
assert_eq!(shell.on_rail, [1], "{size:?}");
assert!(shell.footer_in_menu, "{size:?}");
assert_eq!(
shell.menu_rows(),
[Row::Footer(0), Row::Slot(0)],
"{size:?}: the footer first"
);
}
}
fn frames(ctx: &egui::Context, shell: &mut Shell<Screen>, at: Option<egui::Pos2>) {
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0));
let frame = |events: Vec<egui::Event>, shell: &mut Shell<Screen>| {
let input = egui::RawInput {
screen_rect: Some(rect),
events,
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| shell.ui(ui, |ui, _| screen(ui)));
output.textures_delta.clear();
};
let Some(pos) = at else {
frame(vec![], shell);
frame(vec![], shell);
return;
};
let button = |pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
frame(vec![egui::Event::PointerMoved(pos)], shell);
frame(vec![button(true)], shell);
frame(vec![button(false)], shell);
frame(vec![], shell);
}
#[test]
fn the_menu_opens_what_is_off_the_rail() {
let mut shell = shell().with_footer(prefs()).with_rail_order(&RailOrder {
order: vec![],
hidden: vec!["summary".into()],
});
let ctx = egui::Context::default();
frames(&ctx, &mut shell, None);
let at = shell.more_rect.center();
frames(&ctx, &mut shell, Some(at));
assert_eq!(
shell.menu_rects.len(),
2,
"the footer and the hidden screen"
);
let at = shell.menu_rects[1].center();
frames(&ctx, &mut shell, Some(at));
assert_eq!(shell.current(), Some(Screen::Summary));
let at = shell.more_rect.center();
frames(&ctx, &mut shell, Some(at));
let at = shell.menu_rects[0].center();
frames(&ctx, &mut shell, Some(at));
assert_eq!(shell.current(), Some(Screen::Prefs));
}
#[test]
fn the_shell_lists_its_items_and_takes_the_order_asked_for() {
let ctx = egui::Context::default();
let mut shell = crowded_shell();
frames(&ctx, &mut shell, None);
let listed = published(&ctx).unwrap();
assert_eq!(listed.len(), 9);
assert_eq!(listed[0].key, "new");
request(
&ctx,
RailOrder {
order: vec!["s7".into()],
hidden: vec!["new".into()],
},
);
frames(&ctx, &mut shell, None);
assert_eq!(keys(&shell)[0], "s7");
assert_eq!(shell.rail_order().hidden, ["new"]);
assert_eq!(published(&ctx).unwrap()[0].key, "s7");
}
#[cfg(feature = "serde")]
#[test]
fn the_rail_order_is_taken_from_the_store_and_saved_there() {
use crate::store::{self, Store, Stored};
let dir = tempfile::tempdir().unwrap();
let file = Store::at(dir.path().join(store::FILE_NAME));
let saved = RailOrder {
order: vec!["summary".into(), "entries".into()],
hidden: vec![],
};
file.save(&Stored {
look: None,
rail: Some(saved.clone()),
side: None,
launcher: None,
})
.unwrap();
let ctx = egui::Context::default();
store::attach(&ctx, file.clone());
let mut shell = shell();
frames(&ctx, &mut shell, None);
assert_eq!(shell.rail_order(), saved);
request(
&ctx,
RailOrder {
order: vec!["entries".into(), "summary".into()],
hidden: vec![],
},
);
frames(&ctx, &mut shell, None);
assert_eq!(
file.load().and_then(|s| s.rail).map(|r| r.order),
Some(vec!["entries".to_owned(), "summary".to_owned()])
);
}
#[derive(Default)]
struct Notes {
draft: Option<String>,
screens: Vec<Screen>,
sides: Vec<Screen>,
status: bool,
}
impl ShellApp<Screen> for Notes {
fn screen_ui(&mut self, ui: &mut egui::Ui, screen: Screen, _: &mut Side) {
self.screens.push(screen);
egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
}
fn side_ui(&mut self, ui: &mut egui::Ui, screen: Screen, side: &mut Side) {
self.sides.push(screen);
if let Some(draft) = &mut self.draft {
ui.text_edit_singleline(draft);
if ui.button("save").clicked() {
self.draft = None;
side.close();
}
}
}
fn status_ui(&mut self, ui: &mut egui::Ui) {
self.status = true;
ui.label("ready");
}
fn action(&mut self, id: &'static str, side: &mut Side) {
assert_eq!(id, "new");
self.draft = Some(String::new());
side.open();
}
}
fn notes_shell() -> Shell<Screen> {
Shell::from_items(vec![
Action::new("new", crate::icons::plus(), "new-entry").into(),
Destination::new(Screen::Entries, crate::icons::settings(), "entries").into(),
Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary").into(),
])
.with_footer(prefs())
}
fn show(
ctx: &egui::Context,
shell: &mut Shell<Screen>,
app: &mut Notes,
size: [f32; 2],
at: Option<egui::Pos2>,
) {
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
let mut frame = |events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(rect),
events,
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| shell.show(ui, app));
output.textures_delta.clear();
};
frame(vec![]);
frame(vec![]);
if let Some(pos) = at {
let button = |pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
frame(vec![egui::Event::PointerMoved(pos)]);
frame(vec![button(true)]);
frame(vec![button(false)]);
frame(vec![]);
}
}
#[test]
fn wide_the_side_pane_is_beside_every_screen() {
let ctx = egui::Context::default();
let (mut shell, mut app) = (notes_shell(), Notes::default());
for screen in [Screen::Entries, Screen::Summary, Screen::Prefs] {
app.sides.clear();
shell.open(screen);
show(&ctx, &mut shell, &mut app, SIZES[0], None);
assert_eq!(app.sides.last(), Some(&screen), "{screen:?}");
assert_eq!(app.screens.last(), Some(&screen), "{screen:?}");
}
assert!(app.status, "the status bar is drawn");
}
#[test]
fn an_action_begins_an_entry_in_the_side_pane() {
for (size, in_place) in [(SIZES[0], false), (SIZES[1], true)] {
let ctx = egui::Context::default();
let (mut shell, mut app) = (notes_shell(), Notes::default());
show(&ctx, &mut shell, &mut app, size, None);
let at = shell.action_rects[0].center();
show(&ctx, &mut shell, &mut app, size, Some(at));
assert_eq!(app.draft.as_deref(), Some(""), "{size:?}");
assert_eq!(
shell.current(),
Some(Screen::Entries),
"{size:?}: the screen stays"
);
app.screens.clear();
app.sides.clear();
show(&ctx, &mut shell, &mut app, size, None);
assert!(!app.sides.is_empty(), "{size:?}");
assert_eq!(app.screens.is_empty(), in_place, "{size:?}");
}
}
#[test]
fn every_shell_shows_a_side_pane_and_a_status_bar() {
let ctx = egui::Context::default();
let mut shell = shell();
let mut app = Notes::default();
show(&ctx, &mut shell, &mut app, SIZES[0], None);
assert_eq!(app.sides.last(), Some(&Screen::Entries));
assert!(app.status);
}
#[test]
fn the_deprecated_closures_draw_no_side_pane() {
let ctx = egui::Context::default();
let mut shell = shell();
let mut toggle = None;
crate::test_support::render_in(&ctx, SIZES[0], |ui| {
shell.ui(ui, |ui, _| screen(ui));
toggle = crate::detail::toggle_ui(ui).map(|r| r.rect);
});
assert_eq!(toggle, None, "no pane drawn, so no toggle offered");
}
#[cfg(feature = "serde")]
#[test]
fn the_side_column_is_kept_in_the_store() {
use crate::store::{self, Store};
let dir = tempfile::tempdir().unwrap();
let file = Store::at(dir.path().join(store::FILE_NAME));
let ctx = egui::Context::default();
store::attach(&ctx, file.clone());
let (mut shell, mut app) = (notes_shell(), Notes::default());
show(&ctx, &mut shell, &mut app, SIZES[0], None);
assert_eq!(
file.load().and_then(|s| s.side),
None,
"nothing changed yet"
);
shell.pane.set_column(false);
show(&ctx, &mut shell, &mut app, SIZES[0], None);
assert_eq!(
file.load().and_then(|s| s.side).map(|s| s.column),
Some(false)
);
let ctx = egui::Context::default();
store::attach(&ctx, file);
let (mut shell, mut app) = (notes_shell(), Notes::default());
show(&ctx, &mut shell, &mut app, SIZES[0], None);
assert!(!shell.pane.column());
}
#[test]
fn a_roomy_bar_keeps_full_size_buttons() {
let mut shell = shell().with_footer(prefs());
render(SIZES[1], |ui| shell.ui(ui, |ui, _| screen(ui)));
assert_eq!(shell.cell_rects.len(), 2);
for rect in &shell.cell_rects {
assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{rect:?}");
}
}
fn with_status(shell: &mut Shell<Screen>, size: [f32; 2]) -> (egui::Rect, bool) {
let (mut foot, mut status) = (egui::Rect::NOTHING, false);
render(size, |ui| {
shell.ui_with_status(
ui,
|ui| {
foot = ui.label("dot").rect;
None
},
|ui| {
status = true;
ui.label("saved");
},
|ui, _| screen(ui),
)
});
(foot, status)
}
#[test]
fn wide_the_status_bar_holds_the_status_and_the_foot() {
let mut shell = shell().with_footer(prefs());
let (foot, status) = with_status(&mut shell, SIZES[0]);
assert!(status);
assert!(foot.bottom() > SIZES[0][1] - 30.0, "{foot:?}");
assert!(foot.left() > SIZES[0][0] / 2.0, "{foot:?}");
assert!(
shell.footer_rects[0].bottom() < foot.top(),
"{:?}",
shell.footer_rects
);
}
#[test]
fn narrow_there_is_no_status_bar_and_the_foot_stays_on_the_bar() {
let mut shell = shell().with_footer(prefs());
let (foot, status) = with_status(&mut shell, SIZES[1]);
assert!(!status);
assert!(foot.is_positive());
let bar_top = shell.footer_rects[0].top();
assert!(foot.top() >= bar_top - 10.0, "{foot:?} beside {bar_top}");
}
#[test]
fn with_nothing_there_is_nothing_to_draw() {
let mut shell: Shell<Screen> = Shell::new(vec![]);
assert_eq!(shell.current(), None);
for size in SIZES {
render(size, |ui| shell.ui(ui, |_, s| panic!("{s:?} drawn")));
}
}
}