use eframe::egui;
use nord_usb::ObjectClass;
use crate::app::{accent, bold, ui as ui_text, DrawbarApp, ThemeChoice};
use crate::browser::{new_menu, Act};
use crate::device::occupancy;
use crate::filter::Filter;
use crate::icon::{icon, sized, Glyph};
use crate::log::Level;
use crate::panel::{caps, chevron, dock_header, flat, strip, DOCK, GAP, GLYPH, PAD};
use crate::strings::folder;
use crate::tabs::Spot;
pub const TITLEBAR: f32 = 30.0;
pub const TOOLBAR: f32 = 32.0;
pub const STATUS: f32 = 22.0;
pub const DOCK_BODY: f32 = 184.0;
const BODY_LEAST: f32 = 120.0;
pub const BROWSER: f32 = 232.0;
pub const INSPECTOR: f32 = 244.0;
pub const SHUT: f32 = 30.0;
const SIDE_LEAST: f32 = 180.0;
const CENTRE_WIDE: f32 = 300.0;
const CENTRE_TALL: f32 = 200.0;
pub const LEAST: egui::Vec2 = egui::vec2(
SIDE_LEAST + CENTRE_WIDE + SIDE_LEAST,
TITLEBAR + TOOLBAR + STATUS + DOCK + BODY_LEAST + CENTRE_TALL,
);
const TOO_SMALL: &str = "drawbar needs a larger screen";
const TOO_SMALL_WHY: &str = "It is a desktop application: its panels need more room \
than a phone or a narrow window has. Open it on a larger \
screen, or make this window wider.";
const CHECK: f32 = 12.0;
const BUTTON: f32 = 22.0;
const OMNIBOX: f32 = 300.0;
const SEARCH: &str = "omnibox";
const MENU: f32 = 240.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Dock {
Browser,
Inspector,
Bottom,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Page {
#[default]
Queue,
Log,
}
impl Page {
fn title(self) -> &'static str {
match self {
Page::Queue => "send queue",
Page::Log => "activity log",
}
}
fn stored(self) -> &'static str {
match self {
Page::Queue => "queue",
Page::Log => "log",
}
}
}
pub struct Shell {
pub browser_open: bool,
pub inspector_open: bool,
pub dock_open: bool,
pub room_open: bool,
pub info_open: bool,
pub browser_width: f32,
pub inspector_width: f32,
pub dock_body: f32,
pub page: Page,
pub omnibox: String,
pub filter: Filter,
}
impl Default for Shell {
fn default() -> Shell {
Shell {
browser_open: true,
inspector_open: true,
dock_open: false,
room_open: true,
info_open: false,
browser_width: BROWSER,
inspector_width: INSPECTOR,
dock_body: DOCK_BODY,
page: Page::default(),
omnibox: String::new(),
filter: Filter::default(),
}
}
}
impl Shell {
pub const KEY: &'static str = "drawbar.docks";
const VERSION: &'static str = "drawbar docks 5";
pub fn open(&self, dock: Dock) -> bool {
match dock {
Dock::Browser => self.browser_open,
Dock::Inspector => self.inspector_open,
Dock::Bottom => self.dock_open,
}
}
pub fn toggle(&mut self, dock: Dock) {
let held = !self.open(dock);
match dock {
Dock::Browser => self.browser_open = held,
Dock::Inspector => self.inspector_open = held,
Dock::Bottom => self.dock_open = held,
}
}
pub fn show_page(&mut self, page: Page) {
self.dock_open = true;
self.page = page;
}
pub fn restore(&mut self, storage: &dyn eframe::Storage) {
let Some(text) = storage.get_string(Shell::KEY) else {
return;
};
let mut lines = text.lines();
if lines.next() != Some(Shell::VERSION) {
return;
}
let mut held = Shell::default();
for line in lines {
let mut parts = line.split('\t');
match (parts.next(), parts.next()) {
(Some("browser"), Some(open)) => held.browser_open = open == "1",
(Some("inspector"), Some(open)) => held.inspector_open = open == "1",
(Some("dock"), Some(open)) => held.dock_open = open == "1",
(Some("room"), Some(open)) => held.room_open = open == "1",
(Some("info"), Some(open)) => held.info_open = open == "1",
(Some("browser_width"), Some(text)) => {
held.browser_width = size(text, SIDE_LEAST, BROWSER)
}
(Some("inspector_width"), Some(text)) => {
held.inspector_width = size(text, SIDE_LEAST, INSPECTOR)
}
(Some("dock_body"), Some(text)) => {
held.dock_body = size(text, BODY_LEAST, DOCK_BODY)
}
(Some("page"), Some(page)) => {
held.page = match page == Page::Log.stored() {
true => Page::Log,
false => Page::Queue,
}
}
_ => {}
}
}
self.browser_open = held.browser_open;
self.inspector_open = held.inspector_open;
self.dock_open = held.dock_open;
self.room_open = held.room_open;
self.info_open = held.info_open;
self.browser_width = held.browser_width;
self.inspector_width = held.inspector_width;
self.dock_body = held.dock_body;
self.page = held.page;
}
pub fn keep(&self, storage: &mut dyn eframe::Storage) {
let bit = |open: bool| match open {
true => "1",
false => "0",
};
storage.set_string(
Shell::KEY,
format!(
"{}\nbrowser\t{}\ninspector\t{}\ndock\t{}\nroom\t{}\ninfo\t{}\n\
browser_width\t{}\ninspector_width\t{}\ndock_body\t{}\npage\t{}\n",
Shell::VERSION,
bit(self.browser_open),
bit(self.inspector_open),
bit(self.dock_open),
bit(self.room_open),
bit(self.info_open),
self.browser_width,
self.inspector_width,
self.dock_body,
self.page.stored(),
),
);
}
}
fn size(text: &str, least: f32, default: f32) -> f32 {
match text.parse::<f32>() {
Ok(size) if size.is_finite() && size >= least => size,
_ => default,
}
}
enum Side {
Top,
Bottom,
Left,
Right,
}
fn edge(ui: &egui::Ui, side: Side) {
let rect = ui.max_rect();
let stroke = egui::Stroke::new(1.0_f32, ui.visuals().widgets.noninteractive.bg_stroke.color);
let painter = ui.painter();
match side {
Side::Top => painter.hline(rect.x_range(), rect.top() + 0.5, stroke),
Side::Bottom => painter.hline(rect.x_range(), rect.bottom() - 0.5, stroke),
Side::Left => painter.vline(rect.left() + 0.5, rect.y_range(), stroke),
Side::Right => painter.vline(rect.right() - 0.5, rect.y_range(), stroke),
};
}
fn bare(fill: egui::Color32) -> egui::Frame {
egui::Frame::new()
.fill(fill)
.inner_margin(egui::Margin::ZERO)
}
fn rule(ui: &mut egui::Ui, height: f32) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(1.0, height), egui::Sense::hover());
let stroke = egui::Stroke::new(1.0_f32, ui.visuals().widgets.noninteractive.bg_stroke.color);
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
}
fn along<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
let mut inner = ui.new_child(
egui::UiBuilder::new()
.max_rect(ui.max_rect().shrink2(egui::vec2(PAD, 0.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
inner.spacing_mut().item_spacing.x = GAP;
contents(&mut inner)
}
mod key {
use eframe::egui::{Key, KeyboardShortcut as Shortcut, Modifiers as With};
pub const OPEN: Shortcut = Shortcut::new(With::COMMAND, Key::O);
pub const SAVE: Shortcut = Shortcut::new(With::COMMAND, Key::S);
pub const EXPORT: Shortcut = Shortcut::new(With::COMMAND.plus(With::SHIFT), Key::E);
pub const CLOSE: Shortcut = Shortcut::new(With::COMMAND, Key::W);
pub const QUIT: Shortcut = Shortcut::new(With::COMMAND, Key::Q);
pub const KEYBOARD: Shortcut = Shortcut::new(With::COMMAND, Key::Num2);
pub const DOCUMENT: Shortcut = Shortcut::new(With::COMMAND, Key::Num3);
pub const BROWSER: Shortcut = Shortcut::new(With::COMMAND.plus(With::ALT), Key::B);
pub const INSPECTOR: Shortcut = Shortcut::new(With::COMMAND.plus(With::ALT), Key::I);
pub const DOCK: Shortcut = Shortcut::new(With::COMMAND.plus(With::ALT), Key::L);
pub const RESYNC: Shortcut = Shortcut::new(With::COMMAND, Key::R);
pub const QUEUE: Shortcut = Shortcut::new(With::COMMAND.plus(With::SHIFT), Key::S);
}
const WINDOWED: bool = !cfg!(target_arch = "wasm32");
#[cfg(target_arch = "wasm32")]
pub(crate) const GUIDE: &str = "docs/";
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const GUIDE: &str = "https://drawbar.app/docs/";
fn keyed(ctx: &egui::Context, shortcut: egui::KeyboardShortcut) -> String {
match WINDOWED {
true => ctx.format_shortcut(&shortcut),
false => String::new(),
}
}
fn searched(before: &str, after: &str) -> bool {
before != after
}
fn drop_down(ui: &mut egui::Ui, title: &str, contents: impl FnOnce(&mut egui::Ui)) {
ui.menu_button(title, |ui| {
ui.set_min_width(MENU);
contents(ui);
});
}
fn item(ui: &mut egui::Ui, label: &str, shortcut: Option<egui::KeyboardShortcut>) -> bool {
let mut button = egui::Button::new(label);
if let Some(shortcut) = shortcut {
button = button.shortcut_text(keyed(ui.ctx(), shortcut));
}
let clicked = ui.add(button).clicked();
if clicked {
ui.close();
}
clicked
}
fn page_click(page: Page, showing: bool) -> Act {
match showing {
true => Act::ToggleDock(Dock::Bottom),
false => Act::ShowPage(page),
}
}
pub fn marked(
ui: &mut egui::Ui,
label: &str,
on: bool,
shortcut: Option<egui::KeyboardShortcut>,
) -> bool {
let tint = match on {
true => accent(ui.visuals()),
false => egui::Color32::TRANSPARENT,
};
let mut button = egui::Button::image_and_text(sized(Glyph::Check, CHECK, tint), label)
.image_tint_follows_text_color(false);
if let Some(shortcut) = shortcut {
button = button.shortcut_text(keyed(ui.ctx(), shortcut));
}
let clicked = ui.add(button).clicked();
if clicked {
ui.close();
}
clicked
}
fn action(ui: &mut egui::Ui, glyph: Glyph, label: &str, accented: bool) -> egui::Response {
ui.scope(|ui| {
flat(ui);
let visuals = ui.visuals();
let quiet = visuals.widgets.inactive.fg_stroke.color;
let (mark, ink) = match accented {
true => (accent(visuals), visuals.widgets.active.fg_stroke.color),
false => (quiet, quiet),
};
ui.add(
egui::Button::image_and_text(
sized(glyph, GLYPH, mark),
egui::RichText::new(label).text_style(ui_text()).color(ink),
)
.image_tint_follows_text_color(false)
.corner_radius(2.0)
.min_size(egui::vec2(0.0, BUTTON)),
)
})
.inner
}
pub(crate) fn new_button(ui: &mut egui::Ui, glyph: Glyph, ink: egui::Color32, acts: &mut Vec<Act>) {
ui.scope(|ui| {
flat(ui);
ui.menu_image_button(sized(glyph, GLYPH, ink), |ui| new_menu(ui, acts))
.response
.on_hover_text("something new on this computer");
});
}
fn glyph_button(ui: &mut egui::Ui, glyph: Glyph, on: bool, hint: &str) -> egui::Response {
ui.scope(|ui| {
flat(ui);
let widgets = &mut ui.visuals_mut().widgets;
if on {
widgets.inactive.weak_bg_fill = widgets.active.weak_bg_fill;
}
let visuals = ui.visuals();
let ink = match on {
true => visuals.widgets.active.fg_stroke.color,
false => visuals.widgets.inactive.fg_stroke.color,
};
ui.add(
egui::Button::image(sized(glyph, GLYPH, ink))
.image_tint_follows_text_color(false)
.corner_radius(2.0)
.min_size(egui::vec2(24.0, BUTTON)),
)
})
.inner
.on_hover_text(hint)
}
impl DrawbarApp {
pub(crate) fn attached(&self) -> bool {
self.device.state.connected()
}
pub(crate) fn titlebar(
&mut self,
ctx: &egui::Context,
frame: &mut eframe::Frame,
acts: &mut Vec<Act>,
) {
let fill = ctx.style().visuals.window_fill;
egui::TopBottomPanel::top("titlebar")
.resizable(false)
.exact_height(TITLEBAR)
.frame(bare(fill))
.show(ctx, |ui| {
edge(ui, Side::Bottom);
along(ui, |ui| {
icon(ui, Glyph::SlidersVertical, 14.0, accent(ui.visuals()));
ui.label(egui::RichText::new("drawbar").font(egui::FontId::new(12.0, bold())));
self.shortcuts(ui, acts);
ui.scope(|ui| {
flat(ui);
self.menus(ui, frame, acts);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
flat(ui);
self.theme_chip(ui, frame);
self.instrument_chip(ui);
});
});
});
}
fn instrument_chip(&self, ui: &mut egui::Ui) {
let Some(product) = self.device.state.product() else {
return;
};
let visuals = ui.visuals();
let ink = visuals.widgets.inactive.fg_stroke.color;
let lit = crate::app::good(visuals);
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(6, 2))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = GAP;
icon(ui, Glyph::Keyboard, GLYPH, ink);
ui.label(
egui::RichText::new(product)
.text_style(ui_text())
.color(ink),
);
crate::app::dot(ui, lit, 9.0).on_hover_text("attached");
});
}
fn theme_chip(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let glyph = match ui.visuals().dark_mode {
true => Glyph::Moon,
false => Glyph::Sun,
};
let ink = ui.visuals().widgets.inactive.fg_stroke.color;
let picked = ui
.add(
egui::Button::image_and_text(
sized(glyph, GLYPH, ink),
egui::RichText::new(self.theme.label())
.text_style(ui_text())
.color(ink),
)
.image_tint_follows_text_color(false)
.corner_radius(2.0)
.min_size(egui::vec2(0.0, BUTTON)),
)
.on_hover_text(self.theme.hint())
.clicked();
if picked {
self.pick_theme(ui.ctx(), frame, self.theme.next());
}
}
fn pick_theme(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame, theme: ThemeChoice) {
self.theme = theme;
ctx.set_theme(theme.preference());
if let Some(storage) = frame.storage_mut() {
storage.set_string(ThemeChoice::KEY, theme.stored().to_string());
}
}
fn shortcuts(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
let hit = |shortcut: &egui::KeyboardShortcut| {
ui.input_mut(|input| input.consume_shortcut(shortcut))
};
if hit(&key::OPEN) {
acts.push(Act::OpenFiles);
}
if hit(&key::EXPORT) {
if let Some(id) = self.tabs.active() {
acts.push(Act::Export(id));
}
}
if hit(&key::BROWSER) {
acts.push(Act::ToggleDock(Dock::Browser));
}
if hit(&key::INSPECTOR) {
acts.push(Act::ToggleDock(Dock::Inspector));
}
if hit(&key::DOCK) {
acts.push(Act::ToggleDock(Dock::Bottom));
}
if hit(&key::QUEUE) && self.attached() {
acts.push(Act::ShowPage(Page::Queue));
}
if hit(&key::RESYNC) && self.attached() {
acts.push(Act::Resync);
}
if WINDOWED && hit(&key::CLOSE) {
acts.push(Act::CloseTab);
}
if WINDOWED && hit(&key::QUIT) {
acts.push(Act::Quit);
}
if WINDOWED && self.attached() && hit(&key::KEYBOARD) {
acts.push(Act::ShowTab(Spot::Keyboard));
}
if WINDOWED && hit(&key::DOCUMENT) {
if let Some(id) = self.tabs.last_document() {
acts.push(Act::ShowTab(Spot::Document(id)));
}
}
if hit(&key::SAVE) {
if let Some(id) = self.tabs.active() {
acts.push(Act::SaveDoc(id));
}
}
}
fn menus(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame, acts: &mut Vec<Act>) {
drop_down(ui, "File", |ui| self.file_menu(ui, acts));
drop_down(ui, "View", |ui| self.view_menu(ui, frame, acts));
drop_down(ui, "Instrument", |ui| self.instrument_menu(ui, acts));
drop_down(ui, "Help", |ui| {
if item(ui, "User guide", None) {
ui.ctx().open_url(egui::OpenUrl::new_tab(GUIDE));
}
if item(ui, "What's new", None) {
self.whats_new(ui.ctx());
}
ui.separator();
if item(ui, "Copy activity log", None) {
acts.push(Act::CopyLog);
}
if item(ui, "About drawbar", None) {
self.about_open = true;
}
});
}
fn file_menu(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
if item(ui, "Open…", Some(key::OPEN)) {
acts.push(Act::OpenFiles);
}
ui.menu_button("New", |ui| new_menu(ui, acts));
ui.separator();
if let Some(id) = self.tabs.active() {
if item(ui, "Save", Some(key::SAVE)) {
acts.push(Act::SaveDoc(id));
}
let unsaved = self
.workspace
.get(id)
.is_some_and(crate::workspace::LocalEntity::is_unsaved);
if unsaved && item(ui, "Revert to saved", None) {
acts.push(Act::Revert(id));
}
if item(ui, "Export…", Some(key::EXPORT)) {
acts.push(Act::Export(id));
}
ui.separator();
}
let closable = self
.tabs
.showing()
.is_some_and(|spot| spot != Spot::Library);
if closable && item(ui, "Close tab", Some(key::CLOSE)) {
acts.push(Act::CloseTab);
}
if WINDOWED && item(ui, "Quit", Some(key::QUIT)) {
acts.push(Act::Quit);
}
}
fn view_menu(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame, acts: &mut Vec<Act>) {
let showing = self.tabs.showing();
if self.attached()
&& marked(
ui,
"Keyboard",
showing == Some(Spot::Keyboard),
Some(key::KEYBOARD),
)
{
acts.push(Act::ShowTab(Spot::Keyboard));
}
if let Some(id) = self.tabs.last_document() {
if marked(
ui,
"Document",
showing == Some(Spot::Document(id)),
Some(key::DOCUMENT),
) {
acts.push(Act::ShowTab(Spot::Document(id)));
}
}
ui.separator();
for (label, dock, shortcut) in [
("Browser panel", Dock::Browser, key::BROWSER),
("Inspector panel", Dock::Inspector, key::INSPECTOR),
("Bottom dock", Dock::Bottom, key::DOCK),
] {
if marked(ui, label, self.shell.open(dock), Some(shortcut)) {
acts.push(Act::ToggleDock(dock));
}
}
ui.separator();
ui.menu_button("Theme", |ui| {
for choice in [ThemeChoice::System, ThemeChoice::Light, ThemeChoice::Dark] {
if marked(ui, choice.label(), self.theme == choice, None) {
self.pick_theme(ui.ctx(), frame, choice);
}
}
});
}
fn instrument_menu(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
if !self.attached() {
if item(ui, "Connect…", None) {
acts.push(Act::Connect);
}
return;
}
if item(ui, "Disconnect", None) {
acts.push(Act::Disconnect);
}
ui.separator();
if item(ui, "Read everything", Some(key::RESYNC)) {
acts.push(Act::Resync);
}
if let Some(class) = self.open_class() {
if item(ui, &format!("Read {} again", folder(class)), None) {
acts.push(Act::ReadAgain(class));
}
}
ui.separator();
if item(ui, "Review send queue…", Some(key::QUEUE)) {
acts.push(Act::ShowPage(Page::Queue));
}
let waiting = self.queue.len();
if waiting > 0 && item(ui, &format!("Send all ({waiting})"), None) {
acts.push(Act::AskSendAll);
}
if waiting > 0 && item(ui, "Clear send queue", None) {
acts.push(Act::ClearQueue);
}
let queued: Vec<u64> = self
.browser
.picked()
.locals()
.into_iter()
.filter(|id| self.queue.holds(*id))
.collect();
if !queued.is_empty() && item(ui, "Remove from queue", None) {
acts.extend(queued.into_iter().map(Act::Unqueue));
}
}
fn open_class(&self) -> Option<ObjectClass> {
let id = self.tabs.active()?;
let (class, _) = self.workspace.get(id)?.origin.slot()?;
Some(class)
}
pub(crate) fn toolbar(&mut self, ctx: &egui::Context, acts: &mut Vec<Act>) {
let fill = ctx.style().visuals.panel_fill;
egui::TopBottomPanel::top("toolbar")
.resizable(false)
.exact_height(TOOLBAR)
.frame(bare(fill))
.show(ctx, |ui| {
edge(ui, Side::Bottom);
along(ui, |ui| {
if glyph_button(ui, Glyph::FolderOpen, false, "open files…").clicked() {
acts.push(Act::OpenFiles);
}
let ink = ui.visuals().widgets.inactive.fg_stroke.color;
new_button(ui, Glyph::FilePlus2, ink, acts);
let open = self.tabs.active();
if glyph_button(ui, Glyph::Save, false, "save the open document").clicked() {
if let Some(id) = open {
acts.push(Act::SaveDoc(id));
}
}
rule(ui, 16.0);
self.instrument_actions(ui, acts);
self.omnibox(ui, acts);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
for (glyph, dock, hint) in [
(Glyph::PanelRight, Dock::Inspector, "the inspector"),
(Glyph::PanelBottom, Dock::Bottom, "the bottom dock"),
(Glyph::PanelLeft, Dock::Browser, "the browser"),
] {
if glyph_button(ui, glyph, self.shell.open(dock), hint).clicked() {
acts.push(Act::ToggleDock(dock));
}
}
});
});
});
}
fn instrument_actions(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
if !self.attached() {
return;
}
if action(ui, Glyph::RefreshCw, "Read", false).clicked() {
acts.push(Act::Resync);
}
let waiting = self.queue.len();
let label = match waiting {
0 => "Send".to_string(),
n => format!("Send {n}"),
};
if action(ui, Glyph::Upload, &label, waiting > 0).clicked() && waiting > 0 {
acts.push(Act::AskSendAll);
}
rule(ui, 16.0);
}
fn omnibox(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
const TOGGLES: f32 = 3.0 * 24.0 + 3.0 * GAP + PAD;
let width = (ui.available_width() - TOGGLES).max(OMNIBOX);
let border = ui.visuals().widgets.noninteractive.bg_stroke.color;
let paper = ui.visuals().extreme_bg_color;
let before = self.shell.omnibox.clone();
ui.scope(|ui| {
ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::new(1.0_f32, border);
ui.add_sized(
egui::vec2(width, BUTTON),
egui::TextEdit::singleline(&mut self.shell.omnibox)
.id(egui::Id::new(SEARCH))
.background_color(paper)
.vertical_align(egui::Align::Center)
.hint_text(egui::RichText::new("Search…").text_style(ui_text())),
);
});
if searched(&before, &self.shell.omnibox) {
acts.push(Act::ShowTab(Spot::Library));
}
}
pub(crate) fn status_bar(&mut self, ctx: &egui::Context, acts: &mut Vec<Act>) {
let fill = ctx.style().visuals.panel_fill;
egui::TopBottomPanel::bottom("status")
.resizable(false)
.exact_height(STATUS)
.frame(bare(fill))
.show(ctx, |ui| {
edge(ui, Side::Top);
along(ui, |ui| {
let said = match &self.device.state.in_flight {
Some(words) => {
ui.spinner();
egui::RichText::new(&words.doing).size(11.0)
}
None => {
let (level, text) = self.log.status();
let tint = level.color(ui.visuals());
let glyph = match level {
Level::Info => Glyph::CircleCheck,
Level::Warn | Level::Error => Glyph::CircleAlert,
};
icon(ui, glyph, GLYPH, tint);
egui::RichText::new(text).size(11.0).color(tint)
}
};
if ui
.add(egui::Label::new(said).sense(egui::Sense::click()))
.on_hover_text("the whole activity log")
.clicked()
{
acts.push(Act::ShowPage(Page::Log));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut first = true;
for class in [ObjectClass::Sample, ObjectClass::Program] {
let unit = self.device.state.allocation_unit(class);
let Some(room) = occupancy(class, &self.device.state.inventory, unit)
else {
continue;
};
if !first {
rule(ui, 12.0);
}
first = false;
ui.label(
egui::RichText::new(format!("{} {room}", folder(class)))
.monospace()
.size(10.0)
.weak(),
);
}
});
});
});
}
pub(crate) fn bottom_dock(&mut self, ctx: &egui::Context, acts: &mut Vec<Act>) {
let open = self.shell.dock_open;
let fill = ctx.style().visuals.panel_fill;
let shut = egui::TopBottomPanel::bottom("dock_shut")
.resizable(false)
.exact_height(DOCK)
.frame(bare(fill));
let most = (ctx.available_rect().height() - CENTRE_TALL).max(DOCK + BODY_LEAST);
let full = egui::TopBottomPanel::bottom("dock")
.resizable(true)
.default_height(DOCK + self.shell.dock_body)
.height_range((DOCK + BODY_LEAST)..=most)
.frame(bare(fill));
egui::TopBottomPanel::show_animated_between(ctx, open, shut, full, |ui, how| {
claim(ui);
edge(ui, Side::Top);
self.dock_header(ui, acts);
if how < 1.0 {
return;
}
match self.page() {
Page::Queue => self.queue_page(ui, acts),
Page::Log => self.log.ui(ui),
}
});
if let Some(rect) = laid_out(ctx, "dock") {
self.shell.dock_body = rect.height() - DOCK;
}
}
fn dock_header(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
let waiting = self.queue.len();
let pages: &[Page] = match self.attached() {
true => &[Page::Queue, Page::Log],
false => &[Page::Log],
};
let mut picked = None;
let mut clear = false;
strip(ui, |ui| {
flat(ui);
if chevron(ui, self.shell.dock_open).clicked() {
acts.push(Act::ToggleDock(Dock::Bottom));
}
let ink = crate::app::caption(ui.visuals());
for page in pages.iter().copied() {
let on = self.shell.dock_open && self.page() == page;
if ui
.selectable_label(on, caps(page.title()).color(ink))
.clicked()
{
picked = Some(page_click(page, on));
}
}
let behind = crate::queue::Behind::of(&self.workspace, &self.device.state, &self.queue);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (index, (said, mark)) in behind.parts().into_iter().enumerate() {
if index > 0 {
ui.label(crate::queue::aside("·", crate::app::caption(ui.visuals())));
}
ui.label(crate::queue::aside(
&said,
crate::library::mark_ink(mark, ui.visuals()),
))
.on_hover_text(crate::library::mark_words(mark));
}
});
if ui
.add_enabled(
behind.changed > 0,
egui::Button::new(behind.action()).small(),
)
.on_disabled_hover_text("nothing here differs from what the instrument holds")
.clicked()
{
acts.push(Act::QueueChanged);
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| match self.page() {
Page::Log => clear = ui.small_button("Clear").clicked(),
Page::Queue => {
if waiting > 0 {
if action(ui, Glyph::Upload, "Send all", true).clicked() {
acts.push(Act::AskSendAll);
}
if ui
.small_button("Clear")
.on_hover_text("stop waiting to send any of it")
.clicked()
{
acts.push(Act::ClearQueue);
}
}
}
},
);
});
acts.extend(picked);
if clear {
self.log.clear();
}
}
fn page(&self) -> Page {
match self.attached() {
true => self.shell.page,
false => Page::Log,
}
}
fn queue_page(&mut self, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
crate::queue::page(
ui,
&mut self.queue,
&self.workspace,
&self.device.state,
acts,
);
}
pub(crate) fn browser_dock(&mut self, ctx: &egui::Context, acts: &mut Vec<Act>) {
self.shell.filter.keep_kinds(&crate::browser::kinds_present(
&self.workspace,
&self.device.state,
));
let open = self.shell.browser_open;
let fill = ctx.style().visuals.panel_fill;
let shut = egui::SidePanel::left("browser_shut")
.resizable(false)
.exact_width(SHUT)
.frame(bare(fill));
let most =
(ctx.available_rect().width() - self.inspector_room() - CENTRE_WIDE).max(SIDE_LEAST);
let full = egui::SidePanel::left("browser")
.resizable(true)
.default_width(self.shell.browser_width)
.width_range(SIDE_LEAST..=most)
.frame(bare(fill));
egui::SidePanel::show_animated_between(ctx, open, shut, full, |ui, how| {
claim(ui);
edge(ui, Side::Right);
if how < 1.0 {
if reopen(ui, Glyph::PanelLeftOpen, "show the browser").clicked() {
acts.push(Act::ToggleDock(Dock::Browser));
}
return;
}
dock_header(ui, "browser");
acts.extend(self.browser.ui(
ui,
&self.workspace,
&self.device,
&self.queue,
&self.shell.filter,
));
});
if let Some(rect) = laid_out(ctx, "browser") {
self.shell.browser_width = rect.width();
}
}
fn inspector_room(&self) -> f32 {
match self.shell.inspector_open {
true => self.shell.inspector_width,
false => SHUT,
}
}
pub(crate) fn inspector_dock(&mut self, ctx: &egui::Context, acts: &mut Vec<Act>) {
let open = self.shell.inspector_open;
let fill = ctx.style().visuals.panel_fill;
let shut = egui::SidePanel::right("inspector_shut")
.resizable(false)
.exact_width(SHUT)
.frame(bare(fill));
let most = (ctx.available_rect().width() - CENTRE_WIDE).max(SIDE_LEAST);
let full = egui::SidePanel::right("inspector")
.resizable(true)
.default_width(self.shell.inspector_width)
.width_range(SIDE_LEAST..=most)
.frame(bare(fill));
egui::SidePanel::show_animated_between(ctx, open, shut, full, |ui, how| {
claim(ui);
edge(ui, Side::Left);
if how < 1.0 {
if reopen(ui, Glyph::PanelRightOpen, "show the inspector").clicked() {
acts.push(Act::ToggleDock(Dock::Inspector));
}
return;
}
acts.extend(crate::inspector::ui(
ui,
&mut self.shell,
&mut self.browser,
&self.workspace,
&self.device,
&self.queue,
));
});
if let Some(rect) = laid_out(ctx, "inspector") {
self.shell.inspector_width = rect.width();
}
}
}
fn claim(ui: &mut egui::Ui) {
ui.set_min_size(ui.max_rect().size());
}
fn laid_out(ctx: &egui::Context, id: &str) -> Option<egui::Rect> {
egui::containers::panel::PanelState::load(ctx, egui::Id::new(id)).map(|state| state.rect)
}
fn reopen(ui: &mut egui::Ui, glyph: Glyph, hint: &str) -> egui::Response {
let ink = ui.visuals().widgets.inactive.fg_stroke.color;
let rect = ui.max_rect();
let box_ = egui::Rect::from_center_size(
egui::pos2(rect.center().x, rect.top() + DOCK / 2.0),
egui::Vec2::splat(GLYPH),
);
crate::icon::painted(ui, glyph, box_, ink);
ui.interact(box_, ui.id().with("reopen"), egui::Sense::click())
.on_hover_text(hint)
}
pub fn too_small(screen: egui::Vec2) -> bool {
screen.x < LEAST.x || screen.y < LEAST.y
}
pub fn too_small_notice(ctx: &egui::Context) {
let fill = ctx.style().visuals.panel_fill;
egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(fill)
.inner_margin(egui::Margin::symmetric(16, 0)),
)
.show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() / 3.0);
ui.label(egui::RichText::new(TOO_SMALL).size(18.0).strong());
ui.add_space(GAP);
ui.label(TOO_SMALL_WHY);
ui.add_space(GAP * 2.0);
crate::splash::link(ui, "User guide", GUIDE);
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Fake;
use eframe::{App, Storage};
const SCREEN: egui::Vec2 = egui::vec2(900.0, 540.0);
const REGIONS: [&str; 6] = [
"titlebar",
"toolbar",
"status",
"dock",
"browser",
"inspector",
];
fn app(ctx: &egui::Context, storage: Option<&dyn eframe::Storage>) -> DrawbarApp {
let mut cc = eframe::CreationContext::_new_kittest(ctx.clone());
cc.storage = storage;
DrawbarApp::new(&cc)
}
fn attach(app: &mut DrawbarApp) {
app.device
.pretend_scanned(ObjectClass::Program, 1, &["Africa Split"]);
}
struct Painted {
centre: egui::Rect,
panels: Vec<(String, egui::Rect)>,
words: Vec<String>,
}
impl Painted {
fn wrote(&self, word: &str) -> bool {
self.words.iter().any(|said| said == word)
}
fn region(&self, want: &str) -> Option<egui::Rect> {
self.panels
.iter()
.find(|(id, _)| id == want)
.map(|(_, rect)| *rect)
}
}
fn drawn(ctx: &egui::Context, app: &mut DrawbarApp) -> Painted {
drawn_at(ctx, app, SCREEN)
}
fn drawn_at(ctx: &egui::Context, app: &mut DrawbarApp, screen: egui::Vec2) -> Painted {
frame_of(ctx, app, screen, Vec::new())
}
fn frame_of(
ctx: &egui::Context,
app: &mut DrawbarApp,
screen: egui::Vec2,
events: Vec<egui::Event>,
) -> Painted {
let mut frame = eframe::Frame::_new_kittest();
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)),
..Default::default()
};
let mut centre = egui::Rect::NOTHING;
let output = ctx.run(input, |ctx| {
app.update(ctx, &mut frame);
centre = ctx.available_rect();
});
let panels = REGIONS
.iter()
.filter_map(|id| {
let state = egui::containers::panel::PanelState::load(ctx, egui::Id::new(*id))?;
Some((id.to_string(), state.rect))
})
.collect();
Painted {
centre,
panels,
words: crate::tabs::words(&output),
}
}
#[test]
fn a_screen_short_of_the_least_room_is_gated_in_either_dimension_alone() {
assert!(!too_small(LEAST), "the least the shell lays out in");
assert!(!too_small(SCREEN), "the window the design is drawn to");
assert!(
too_small(LEAST - egui::vec2(1.0, 0.0)),
"a point too narrow"
);
assert!(too_small(LEAST - egui::vec2(0.0, 1.0)), "a point too short");
assert!(too_small(egui::vec2(390.0, 844.0)));
assert!(too_small(egui::vec2(844.0, 390.0)));
assert!(too_small(egui::Vec2::ZERO));
}
#[test]
fn at_the_least_room_it_claims_every_region_fits_and_the_centre_keeps_its_own() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
app.shell.dock_open = true;
attach(&mut app);
let _ = drawn_at(&ctx, &mut app, LEAST);
let painted = drawn_at(&ctx, &mut app, LEAST);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, LEAST);
assert_eq!(painted.panels.len(), REGIONS.len(), "every region drew");
for (id, rect) in &painted.panels {
assert!(
screen.contains_rect(*rect),
"{id} is outside the window: {rect:?}"
);
}
let centre = painted.centre;
assert!(centre.width() >= CENTRE_WIDE, "the centre: {centre:?}");
assert!(centre.height() >= CENTRE_TALL, "the centre: {centre:?}");
}
#[test]
fn the_page_gates_where_the_shell_does_and_says_the_same_thing() {
let page = include_str!("../index.html");
let gate = format!("@media (width < {}px), (height < {}px)", LEAST.x, LEAST.y);
assert!(page.contains(&gate), "index.html does not gate at `{gate}`");
assert!(page.contains(TOO_SMALL), "index.html: {TOO_SMALL:?}");
assert!(
page.contains(TOO_SMALL_WHY),
"index.html: {TOO_SMALL_WHY:?}"
);
}
#[test]
fn the_favicon_is_the_titlebar_mark_in_both_accents() {
let favicon = include_str!("../favicon.svg");
let mark = include_str!("../assets/icons/sliders-vertical.svg");
for line in mark.lines().filter(|l| l.trim_start().starts_with("<line")) {
assert!(
favicon.contains(line),
"favicon.svg lacks `{}`",
line.trim()
);
}
let hex = |visuals: egui::Visuals| {
let [r, g, b, _] = accent(&visuals).to_array();
format!("#{r:02x}{g:02x}{b:02x}")
};
let light = format!("stroke=\"{}\"", hex(egui::Visuals::light()));
let dark = format!("stroke: {};", hex(egui::Visuals::dark()));
assert!(favicon.contains(&light), "favicon.svg lacks `{light}`");
assert!(favicon.contains(&dark), "favicon.svg lacks `{dark}`");
}
#[test]
fn the_notice_says_what_is_wrong_and_offers_the_guide() {
let ctx = egui::Context::default();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(390.0, 844.0),
)),
..Default::default()
};
let output = ctx.run(input, too_small_notice);
let said = crate::tabs::words(&output);
assert!(said.iter().any(|word| word == TOO_SMALL), "{said:?}");
assert!(said.iter().any(|word| word == TOO_SMALL_WHY), "{said:?}");
assert!(said.iter().any(|word| word == "User guide"), "{said:?}");
}
#[test]
fn at_900_by_540_every_region_fits_and_the_centre_survives() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
app.shell.dock_open = true;
attach(&mut app);
let _ = drawn(&ctx, &mut app);
let painted = drawn(&ctx, &mut app);
let (centre, panels) = (painted.centre, &painted.panels);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN);
assert_eq!(panels.len(), REGIONS.len(), "every region drew: {panels:?}");
for (id, rect) in panels {
assert!(
screen.contains_rect(*rect),
"{id} is outside the window: {rect:?}"
);
}
let at = |want: &str| painted.region(want).unwrap();
assert_eq!(at("titlebar").height(), TITLEBAR);
assert_eq!(at("toolbar").height(), TOOLBAR);
assert_eq!(at("status").height(), STATUS);
assert_eq!(at("dock").height(), DOCK + DOCK_BODY);
assert_eq!(at("browser").width(), BROWSER);
assert_eq!(at("inspector").width(), INSPECTOR);
let header = at("dock").split_top_bottom_at_y(at("dock").top() + DOCK).0;
assert!(screen.contains_rect(header), "the dock header: {header:?}");
assert!(centre.width() > 0.0, "the centre: {centre:?}");
assert!(centre.height() > 0.0, "the centre: {centre:?}");
}
#[test]
fn flipping_the_theme_leaves_every_region_where_it_was() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
app.shell.dock_open = true;
attach(&mut app);
ctx.set_theme(egui::ThemePreference::Dark);
let _ = drawn(&ctx, &mut app);
let dark = drawn(&ctx, &mut app);
ctx.set_theme(egui::ThemePreference::Light);
let _ = drawn(&ctx, &mut app);
let light = drawn(&ctx, &mut app);
assert_eq!(dark.centre, light.centre);
assert_eq!(dark.panels, light.panels);
}
#[test]
fn no_instrument_means_no_instrument_controls() {
const ONLY_WITH_ONE: [&str; 6] =
["Read", "Send", "SEND QUEUE", "INSTRUMENT", "ROOM", "INFO"];
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
app.shell.dock_open = true;
let _ = drawn(&ctx, &mut app);
let alone = drawn(&ctx, &mut app);
assert!(!app.attached(), "nothing was attached");
for control in ONLY_WITH_ONE {
assert!(
!alone.wrote(control),
"{control} is painted with none attached"
);
}
assert!(alone.wrote("ACTIVITY LOG"), "the log is the one page left");
assert!(alone.wrote("SELECTION"), "the inspector still answers");
assert!(alone.region("inspector").is_some(), "{:?}", alone.panels);
attach(&mut app);
let _ = drawn(&ctx, &mut app);
let answering = drawn(&ctx, &mut app);
for control in ONLY_WITH_ONE {
assert!(
answering.wrote(control),
"{control} is missing with one attached"
);
}
assert!(answering.wrote("SELECTION"));
assert_eq!(
answering.region("inspector"),
alone.region("inspector"),
"the dock is the same width either way"
);
}
#[test]
fn a_change_in_the_omnibox_is_what_brings_the_library_forward() {
assert!(searched("", "a"), "the first keystroke");
assert!(searched("afr", "afri"));
assert!(searched("afri", "afr"), "and a backspace");
assert!(searched("afr", ""), "and clearing it");
assert!(!searched("afr", "afr"));
assert!(!searched("", ""));
}
#[test]
fn typing_a_search_with_a_document_in_front_brings_the_library_forward() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
let id = app
.workspace
.create(crate::workspace::Fresh::Program, &mut app.log)
.unwrap();
app.tabs.open(id);
let _ = drawn(&ctx, &mut app);
assert_eq!(app.tabs.showing(), Some(Spot::Document(id)));
ctx.memory_mut(|memory| memory.request_focus(egui::Id::new(SEARCH)));
let _ = frame_of(
&ctx,
&mut app,
SCREEN,
vec![egui::Event::Text("afr".into())],
);
assert_eq!(app.shell.omnibox, "afr");
assert_eq!(app.tabs.showing(), Some(Spot::Library));
app.tabs.show(Spot::Document(id));
let _ = drawn(&ctx, &mut app);
assert_eq!(app.tabs.showing(), Some(Spot::Document(id)));
}
#[test]
fn the_send_queue_shortcut_never_falls_through_to_save() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
let id = app
.workspace
.create(crate::workspace::Fresh::Program, &mut app.log)
.unwrap();
let bytes = app.workspace.get(id).unwrap().bytes.clone();
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
app.workspace.replace_bytes(id, edited, &mut app.log);
app.tabs.open(id);
assert!(app.workspace.get(id).unwrap().is_unsaved());
let pressed = || egui::Event::Key {
key: egui::Key::S,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT),
};
let _ = drawn(&ctx, &mut app);
let _ = frame_of(&ctx, &mut app, SCREEN, vec![pressed()]);
assert!(!app.attached(), "nothing was attached");
assert!(
app.workspace.get(id).unwrap().is_unsaved(),
"there is no queue to review, and there is no save either"
);
attach(&mut app);
let _ = drawn(&ctx, &mut app);
let _ = frame_of(&ctx, &mut app, SCREEN, vec![pressed()]);
assert!(app.shell.dock_open && app.shell.page == Page::Queue);
assert!(
app.workspace.get(id).unwrap().is_unsaved(),
"reviewing the queue is not saving"
);
}
#[test]
fn the_read_everything_shortcut_is_taken_with_nothing_attached() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
let pressed = || egui::Event::Key {
key: egui::Key::R,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::COMMAND,
};
let reload = |event: &egui::Event| match event {
egui::Event::Key { key, .. } => *key == egui::Key::R,
_ => false,
};
let left = |ctx: &egui::Context| ctx.input(|input| input.events.iter().any(reload));
let _ = drawn(&ctx, &mut app);
let _ = frame_of(&ctx, &mut app, SCREEN, vec![pressed()]);
assert!(!app.attached(), "nothing was attached");
assert!(!left(&ctx), "⌘R reached the tab with nothing attached");
attach(&mut app);
let _ = drawn(&ctx, &mut app);
let _ = frame_of(&ctx, &mut app, SCREEN, vec![pressed()]);
assert!(!left(&ctx), "⌘R reached the tab with one attached");
}
#[test]
fn the_docks_come_back_where_the_last_session_left_them() {
let mut store = Fake::default();
{
let ctx = egui::Context::default();
let mut before = app(&ctx, None);
before.shell.browser_open = false;
before.shell.show_page(Page::Log);
before.save(&mut store);
}
let ctx = egui::Context::default();
let after = app(&ctx, Some(&store));
assert!(!after.shell.browser_open);
assert!(after.shell.inspector_open);
assert!(after.shell.dock_open);
assert_eq!(after.shell.page, Page::Log);
}
#[test]
fn a_dock_toggles_and_a_page_opens_the_dock_it_is_on() {
let mut shell = Shell::default();
for dock in [Dock::Browser, Dock::Inspector, Dock::Bottom] {
let was = shell.open(dock);
shell.toggle(dock);
assert_eq!(shell.open(dock), !was, "{dock:?}");
}
shell.dock_open = false;
shell.show_page(Page::Log);
assert!(shell.dock_open && shell.page == Page::Log);
}
#[test]
fn the_title_of_the_page_showing_shuts_the_dock() {
assert!(matches!(
page_click(Page::Queue, true),
Act::ToggleDock(Dock::Bottom)
));
assert!(matches!(
page_click(Page::Queue, false),
Act::ShowPage(Page::Queue)
));
}
#[test]
fn the_layout_comes_back_as_it_was_left() {
let mut store = Fake::default();
let before = Shell {
browser_open: false,
inspector_open: true,
dock_open: true,
room_open: false,
info_open: true,
browser_width: 301.0,
inspector_width: 199.0,
dock_body: 260.0,
page: Page::Log,
omnibox: "typed and not kept".into(),
filter: Filter::default(),
};
before.keep(&mut store);
let mut after = Shell::default();
after.restore(&store);
assert!(!after.browser_open);
assert!(after.inspector_open);
assert!(after.dock_open);
assert!(!after.room_open, "a shut inspector panel comes back shut");
assert!(after.info_open, "and an open one comes back open");
assert_eq!(after.browser_width, 301.0);
assert_eq!(after.inspector_width, 199.0);
assert_eq!(after.dock_body, 260.0);
assert_eq!(after.page, Page::Log);
assert!(after.omnibox.is_empty(), "a search is not a layout");
}
#[test]
fn a_size_outside_what_a_dock_opens_to_comes_back_as_the_default() {
let mut store = Fake::default();
store.set_string(
Shell::KEY,
format!(
"{}\nbrowser_width\t12\ninspector_width\twide\ndock_body\tNaN\n",
Shell::VERSION
),
);
let mut shell = Shell::default();
shell.restore(&store);
assert_eq!(shell.browser_width, BROWSER, "under the least it opens to");
assert_eq!(shell.inspector_width, INSPECTOR, "not a number");
assert_eq!(shell.dock_body, DOCK_BODY, "not a size");
}
#[test]
fn docks_wider_than_the_window_still_leave_the_centre_its_room() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
app.shell.dock_open = true;
app.shell.browser_width = 5_000.0;
app.shell.inspector_width = 5_000.0;
app.shell.dock_body = 5_000.0;
attach(&mut app);
let _ = drawn(&ctx, &mut app);
let painted = drawn(&ctx, &mut app);
assert!(
painted.centre.width() >= CENTRE_WIDE,
"the centre: {:?}",
painted.centre
);
assert!(
painted.centre.height() >= CENTRE_TALL,
"the centre: {:?}",
painted.centre
);
assert!(app.shell.browser_width >= SIDE_LEAST);
assert!(app.shell.dock_body >= BODY_LEAST);
}
#[test]
fn a_dock_drawn_narrower_writes_the_size_it_was_drawn_at() {
let ctx = egui::Context::default();
let mut app = app(&ctx, None);
app.shell.dock_open = true;
app.shell.browser_width = 190.0;
app.shell.dock_body = 130.0;
attach(&mut app);
let _ = drawn(&ctx, &mut app);
let painted = drawn(&ctx, &mut app);
assert_eq!(painted.region("browser").unwrap().width(), 190.0);
assert_eq!(painted.region("dock").unwrap().height(), DOCK + 130.0);
assert_eq!(app.shell.browser_width, 190.0);
assert_eq!(app.shell.dock_body, 130.0);
}
#[test]
fn an_unknown_version_leaves_the_default_layout() {
let mut store = Fake::default();
store.set_string(
Shell::KEY,
"drawbar docks 99\nbrowser\t0\ndock\t1\n".to_string(),
);
let mut shell = Shell::default();
shell.restore(&store);
assert!(shell.browser_open, "the default stands");
assert!(!shell.dock_open);
}
#[test]
fn an_empty_or_partial_store_is_a_store() {
let mut shell = Shell::default();
shell.restore(&Fake::default());
assert!(shell.browser_open && shell.inspector_open && !shell.dock_open);
let mut store = Fake::default();
store.set_string(
Shell::KEY,
format!("{}\ninspector\t0\nrail\t1\n", Shell::VERSION),
);
shell.restore(&store);
assert!(!shell.inspector_open);
assert!(shell.browser_open, "a field nobody wrote keeps its default");
}
}