use egui::containers::Sides;
use egui::{Button, Color32, CornerRadius, Frame, Margin, RichText, WidgetInfo, WidgetType};
use crate::icons::material_design_icons::{
MDI_ALERT, MDI_CHECK, MDI_CLOSE, MDI_DOCK_RIGHT, MDI_FILE_COG_OUTLINE, MDI_TRAY_ARROW_DOWN, MDI_TRAY_ARROW_UP,
};
use crate::options::cli_phrase;
use crate::ui::mcs;
use crate::ui::sweep::SweepRequest;
use crate::ui::sweep::draft::DraftMode;
use crate::ui::sweep::layout::{add_button, icon_button, segmented, truncated_label};
use crate::ui::sweep::session::SessionState;
pub const HEADER_ID: &str = "henad_sweep_header";
const HEADER_MARGIN_Y: i8 = 2;
pub fn header_height(style: &egui::Style) -> f32 {
let spacing = &style.spacing;
2.0 * spacing.interact_size.y + spacing.item_spacing.y + 2.0 * f32::from(HEADER_MARGIN_Y) + separator_width(style)
}
pub fn header_frame() -> Frame {
Frame::NONE.inner_margin(Margin::symmetric(0, HEADER_MARGIN_Y))
}
pub fn separator_width(style: &egui::Style) -> f32 {
style.visuals.widgets.noninteractive.bg_stroke.width.round()
}
pub struct BuilderHeader<'a> {
pub model_name: &'a str,
pub notification: Option<&'a str>,
pub gpu_refused: bool,
pub cli_command: Option<&'a str>,
pub plan_fits: bool,
}
pub fn builder_header(
ui: &mut egui::Ui,
header: &BuilderHeader<'_>,
mode: &mut DraftMode,
plan_open: &mut bool,
request: &mut Option<SweepRequest>,
) -> bool {
let spec_menu = SpecMenu {
noun: noun(*mode),
save_refusal: None,
load_refusal: None,
};
let ((mode_changed, model_clicked), trailing) = Sides::new().shrink_left().truncate().show(
ui,
|ui| {
let mode_changed = mode_switch(ui, mode);
let model = ui
.add(
Button::new(RichText::new(header.model_name).strong())
.frame(false)
.truncate(),
)
.on_hover_text("Switch models using the Model tab");
(mode_changed, model.clicked())
},
|ui| {
let mut trailing = None;
trailing_controls(ui, &spec_menu, header.plan_fits, plan_open, &mut trailing);
trailing
},
);
if model_clicked {
*request = Some(SweepRequest::ShowModel);
}
if trailing.is_some() {
*request = trailing;
}
let description = if header.gpu_refused {
Line {
text: format!(
"GPU sweeps are unavailable in a browser. Save spec and run it in the desktop app or {}.",
cli_phrase(header.cli_command)
),
color: Some(ui.visuals().error_fg_color),
}
} else {
Line {
text: match mode {
DraftMode::Sweep => "Runs every configuration of a design, each once per replicate.",
DraftMode::Search => "Selects configurations in batches, based on earlier results.",
}
.to_owned(),
color: Some(ui.visuals().weak_text_color()),
}
};
notification_row(ui, header.notification, description, request);
mode_changed
}
pub struct SessionHeader<'a> {
pub state: SessionState,
pub noun: &'static str,
pub model_name: &'a str,
pub notification: Option<&'a str>,
pub resumed: bool,
pub plan_fits: bool,
}
pub fn session_header(
ui: &mut egui::Ui,
header: &SessionHeader<'_>,
plan_open: &mut bool,
request: &mut Option<SweepRequest>,
) {
let load_refusal = if header.state.is_running() {
format!("A {} is running", header.noun)
} else {
format!("Press {} to load a spec", edit_label(header.noun, header.resumed))
};
let spec_menu = SpecMenu {
noun: header.noun,
save_refusal: header
.resumed
.then(|| format!("This {}'s spec is already in the folder's manifest.json", header.noun)),
load_refusal: Some(load_refusal),
};
Sides::new().shrink_left().truncate().show(
ui,
|ui| {
status_badge(ui, header.state);
let noun = if header.noun == "search" { "Search" } else { "Sweep" };
ui.add(egui::Label::new(RichText::new(format!("{noun} of {}", header.model_name)).strong()).truncate());
},
|ui| trailing_controls(ui, &spec_menu, header.plan_fits, plan_open, request),
);
let text = match header.state {
SessionState::Planning => "Building first configuration to plan runs.".to_owned(),
SessionState::Running => "Results will appear as runs finish. Press Show results to open them.".to_owned(),
SessionState::Paused => "Runs in progress are paused. Press Resume to continue.".to_owned(),
SessionState::Finished | SessionState::Aborted | SessionState::Stopped | SessionState::Failed
if header.resumed =>
{
format!("Press New {} to set up another.", header.noun)
}
SessionState::Finished | SessionState::Aborted | SessionState::Stopped | SessionState::Failed => format!(
"Press {} to change the settings and run again.",
edit_label(header.noun, false)
),
};
let line = Line {
text,
color: Some(ui.visuals().weak_text_color()),
};
notification_row(ui, header.notification, line, request);
}
pub fn edit_label(noun: &str, resumed: bool) -> String {
if resumed {
format!("New {noun}")
} else {
format!("Edit {noun}")
}
}
pub fn noun(mode: DraftMode) -> &'static str {
match mode {
DraftMode::Sweep => "sweep",
DraftMode::Search => "search",
}
}
fn mode_switch(ui: &mut egui::Ui, mode: &mut DraftMode) -> bool {
let choices = [
(DraftMode::Sweep, "Sweep", "Run every configuration of a design"),
(
DraftMode::Search,
"Search",
"Optimize an objective, or explore a grid of two outputs",
),
];
segmented(ui, mode, &choices, |_| Ok(()))
}
struct SpecMenu {
noun: &'static str,
save_refusal: Option<String>,
load_refusal: Option<String>,
}
fn trailing_controls(
ui: &mut egui::Ui,
spec_menu: &SpecMenu,
plan_fits: bool,
plan_open: &mut bool,
request: &mut Option<SweepRequest>,
) {
let menu = ui.menu_button(format!("{MDI_FILE_COG_OUTLINE} Spec"), |ui| {
let save = add_button(
ui,
spec_menu.save_refusal.is_none(),
Button::new(format!("{MDI_TRAY_ARROW_DOWN} Save spec")),
"Save spec",
)
.on_hover_text(format!("Save {} to TOML file", spec_menu.noun));
let save = match &spec_menu.save_refusal {
Some(reason) => save.on_disabled_hover_text(reason),
None => save,
};
if save.clicked() {
*request = Some(SweepRequest::SaveSpec);
}
let load = add_button(
ui,
spec_menu.load_refusal.is_none(),
Button::new(format!("{MDI_TRAY_ARROW_UP} Load spec")),
"Load spec",
)
.on_hover_text(
"Load sweep or search from TOML file. Current settings and any Parameters tab values the spec sets will \
be replaced.",
);
let load = match &spec_menu.load_refusal {
Some(reason) => load.on_disabled_hover_text(reason),
None => load,
};
if load.clicked() {
*request = Some(SweepRequest::LoadSpec);
}
});
let menu = menu.response.on_hover_text("Save or load spec file");
menu.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, "Spec"));
if plan_fits {
let toggle = ui
.add(Button::selectable(*plan_open, format!("{MDI_DOCK_RIGHT} Plan")))
.on_hover_text("Show plan beside settings");
if toggle.clicked() {
*plan_open = !*plan_open;
}
let selected = *plan_open;
toggle.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, "Plan"));
}
}
struct Line {
text: String,
color: Option<Color32>,
}
fn notification_row(ui: &mut egui::Ui, notification: Option<&str>, fallback: Line, request: &mut Option<SweepRequest>) {
let line = match notification {
Some(text) if is_failure(text) => Line {
text: format!("{MDI_ALERT} {text}"),
color: Some(ui.visuals().error_fg_color),
},
Some(text) => Line {
text: text.to_owned(),
color: None,
},
None => fallback,
};
let (_, dismissed) = Sides::new().shrink_left().truncate().show(
ui,
|ui| truncated_label(ui, line.text, line.color),
|ui| notification.is_some() && icon_button(ui, MDI_CLOSE, "Dismiss", "Dismiss").clicked(),
);
if dismissed {
*request = Some(SweepRequest::DismissNotification);
}
}
fn is_failure(text: &str) -> bool {
text.contains("failed:")
}
fn status_badge(ui: &mut egui::Ui, state: SessionState) {
let (text, fill, color) = match state {
SessionState::Planning => ("Planning".to_owned(), mcs::GRAY_800, mcs::GRAY_200),
SessionState::Running => ("Running".to_owned(), mcs::BLUE_900, mcs::BLUE_200),
SessionState::Paused => ("Paused".to_owned(), mcs::ORANGE_900, mcs::ORANGE_200),
SessionState::Finished => (format!("{MDI_CHECK} Finished"), mcs::GREEN_900, mcs::GREEN_200),
SessionState::Aborted => ("Aborted".to_owned(), mcs::ORANGE_900, mcs::ORANGE_200),
SessionState::Stopped => ("Stopped".to_owned(), mcs::ORANGE_900, mcs::ORANGE_200),
SessionState::Failed => ("Failed".to_owned(), mcs::RED_900, mcs::RED_200),
};
Frame::new()
.fill(fill)
.corner_radius(CornerRadius::ZERO)
.inner_margin(Margin::symmetric(6, 1))
.show(ui, |ui| ui.colored_label(color, text));
}
#[cfg(test)]
mod tests {
use super::{edit_label, header_height, is_failure};
#[test]
fn the_header_holds_two_rows_and_its_separator() {
let style = egui::Style::default();
let spacing = &style.spacing;
let separator = style.visuals.widgets.noninteractive.bg_stroke.width.round();
assert_eq!(
header_height(&style),
2.0 * spacing.interact_size.y + spacing.item_spacing.y + 4.0 + separator
);
}
#[test]
fn an_ended_session_is_edited_unless_it_resumed_a_folder() {
assert_eq!(edit_label("sweep", false), "Edit sweep");
assert_eq!(edit_label("search", false), "Edit search");
assert_eq!(edit_label("sweep", true), "New sweep");
}
#[test]
fn a_failed_save_or_load_reads_as_a_failure() {
assert!(is_failure("Spec load failed: no such file"));
assert!(is_failure("Save failed: disk full"));
assert!(!is_failure("Loaded sir.toml"));
assert!(!is_failure("Save canceled"));
}
}