use eframe::egui::{self, Color32, RichText};
use crate::hurl::KvRow;
use crate::i18n::Strings;
use super::theme::{GuiTheme, method_color};
pub const METHODS: [&str; 8] = [
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE",
];
pub fn split_key_width(ui: &egui::Ui, reserved: f32) -> f32 {
let usable = (ui.available_width() - reserved).max(120.0);
let max_key = usable * 0.5;
(usable * 0.40).clamp(90.0_f32.min(max_key), max_key)
}
pub fn sized_key(
ui: &mut egui::Ui,
key_w: f32,
text: &mut String,
hint: &str,
color: Color32,
) -> egui::Response {
let h = ui.spacing().interact_size.y;
ui.add_sized(
[key_w, h],
egui::TextEdit::singleline(text)
.hint_text(hint)
.text_color(color),
)
}
pub fn selectable<'a>(
ui: &mut egui::Ui,
selected: bool,
atoms: impl egui::IntoAtoms<'a>,
) -> egui::Response {
ui.add(egui::Button::selectable(selected, atoms).frame_when_inactive(true))
}
pub fn panel_header(
ui: &mut egui::Ui,
theme: &GuiTheme,
title: impl Into<String>,
add_buttons: impl FnOnce(&mut egui::Ui),
) {
let title = title.into();
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
add_buttons(ui);
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(
egui::Label::new(RichText::new(title).strong().color(theme.text)).truncate(),
);
});
});
});
}
pub fn tree_header<R>(
ui: &mut egui::Ui,
id_salt: impl std::hash::Hash + std::fmt::Debug,
default_open: bool,
label: RichText,
add_body: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::Response {
tree_header_marked(ui, id_salt, default_open, false, label, None, add_body)
}
pub fn tree_header_marked<R>(
ui: &mut egui::Ui,
id_salt: impl std::hash::Hash + std::fmt::Debug,
default_open: bool,
force_open: bool,
label: RichText,
highlight: Option<egui::Color32>,
add_body: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::Response {
let id = egui::Id::new(id_salt);
let mut state = egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
id,
default_open,
);
if force_open && !state.is_open() {
state.set_open(true);
}
let open = state.is_open();
let band = highlight.map(|_| ui.painter().add(egui::Shape::Noop));
let header = ui
.horizontal(|ui| {
ui.set_min_width(ui.available_width());
let caret = if open {
super::icons::CARET_DOWN
} else {
super::icons::CARET_RIGHT
};
ui.add_sized(
egui::vec2(ui.spacing().icon_width, ui.spacing().interact_size.y),
egui::Label::new(caret).selectable(false),
);
ui.add(egui::Label::new(label).truncate().selectable(false));
})
.response
.interact(egui::Sense::click());
if let (Some(band), Some(color)) = (band, highlight) {
let rect = header.rect.expand2(egui::vec2(0.0, 2.0));
let mut shapes = vec![egui::Shape::rect_filled(
rect,
3.0,
color.gamma_multiply(0.22),
)];
shapes.push(egui::Shape::rect_filled(
egui::Rect::from_min_size(rect.min, egui::vec2(3.0, rect.height())),
1.0,
color,
));
ui.painter().set(band, egui::Shape::Vec(shapes));
}
if header.clicked() {
state.toggle(ui);
}
state.show_body_indented(&header, ui, add_body);
header
}
pub fn method_badge(ui: &mut egui::Ui, theme: &GuiTheme, method: &str) {
let col = method_color(method, theme.dim);
ui.label(RichText::new(method).strong().monospace().color(col));
}
pub fn method_combo(
ui: &mut egui::Ui,
theme: &GuiTheme,
id: impl std::hash::Hash + std::fmt::Debug,
method: &mut String,
) -> bool {
let mut changed = false;
let col = method_color(method, theme.dim);
egui::ComboBox::from_id_salt(id)
.selected_text(RichText::new(method.clone()).strong().color(col))
.width(96.0)
.show_ui(ui, |ui| {
for m in METHODS {
if selectable(
ui,
method == m,
RichText::new(m).color(method_color(m, theme.dim)),
)
.clicked()
{
*method = m.to_string();
changed = true;
}
}
});
changed
}
fn column_header(ui: &mut egui::Ui, theme: &GuiTheme, text: &str) {
ui.label(RichText::new(text).strong().color(theme.dim));
}
fn sized_header(ui: &mut egui::Ui, theme: &GuiTheme, text: &str, w: f32) {
let h = ui.spacing().interact_size.y;
ui.allocate_ui_with_layout(
egui::vec2(w, h),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.set_min_width(w);
column_header(ui, theme, text);
},
);
}
fn kv_widths(ui: &egui::Ui) -> (f32, f32, f32, f32) {
let check = ui.spacing().interact_size.y + 4.0;
let fixed = check + ui.spacing().interact_size.y + 8.0 + 3.0 * 8.0;
let free = (ui.available_width() - fixed).max(240.0);
let key = free * 0.28;
let val = free * 0.38;
(check, key, val, free - key - val)
}
pub fn kv_editor(
ui: &mut egui::Ui,
theme: &GuiTheme,
s: &Strings,
id: impl std::hash::Hash + std::fmt::Debug,
rows: &mut Vec<KvRow>,
key_hint: &str,
val_hint: &str,
key_label: &str,
val_label: &str,
) -> bool {
let mut changed = false;
let mut remove: Option<usize> = None;
let (check_w, key_w, val_w, desc_w) = kv_widths(ui);
let row_h = ui.spacing().interact_size.y;
egui::Grid::new(id)
.num_columns(4)
.spacing([8.0, 4.0])
.striped(true)
.min_col_width(0.0)
.show(ui, |ui| {
sized_header(ui, theme, "\u{2713}", check_w);
sized_header(ui, theme, key_label, key_w);
sized_header(ui, theme, val_label, val_w);
sized_header(ui, theme, s.hdr_description, desc_w);
ui.end_row();
for i in 0..rows.len() {
if ui
.add_sized(
[check_w, row_h],
egui::Checkbox::without_text(&mut rows[i].enabled),
)
.changed()
{
changed = true;
}
let row_color = if rows[i].enabled {
theme.text
} else {
theme.dim
};
let k = sized_key(ui, key_w, &mut rows[i].key, key_hint, row_color);
if k.changed() {
changed = true;
}
let v = sized_key(ui, val_w, &mut rows[i].value, val_hint, row_color);
if v.changed() {
changed = true;
}
ui.allocate_ui_with_layout(
egui::vec2(desc_w, row_h),
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.set_min_width(desc_w);
if ui
.button(RichText::new(super::icons::CLOSE).color(theme.err))
.on_hover_text(s.gui_remove)
.clicked()
{
remove = Some(i);
}
let d = ui.add(
egui::TextEdit::singleline(&mut rows[i].desc)
.hint_text(s.gui_hint_description)
.text_color(theme.dim)
.desired_width(f32::INFINITY),
);
if d.changed() {
changed = true;
}
},
);
ui.end_row();
}
});
if let Some(i) = remove {
rows.remove(i);
changed = true;
}
if ui.button(s.gui_add).clicked() {
rows.push(KvRow::toggled(String::new(), String::new(), true));
changed = true;
}
changed
}
pub fn pair_editor(
ui: &mut egui::Ui,
theme: &GuiTheme,
s: &Strings,
id: impl std::hash::Hash + std::fmt::Debug,
rows: &mut Vec<(String, String)>,
key_hint: &str,
val_hint: &str,
key_label: &str,
val_label: &str,
) -> bool {
let mut changed = false;
let mut remove: Option<usize> = None;
let key_w = split_key_width(ui, 42.0);
egui::Grid::new(id)
.num_columns(2)
.spacing([8.0, 4.0])
.striped(true)
.min_col_width(0.0)
.show(ui, |ui| {
column_header(ui, theme, key_label);
column_header(ui, theme, val_label);
ui.end_row();
for i in 0..rows.len() {
let k = sized_key(ui, key_w, &mut rows[i].0, key_hint, theme.text);
if k.changed() {
changed = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(RichText::new(super::icons::CLOSE).color(theme.err))
.clicked()
{
remove = Some(i);
}
let v = ui.add(
egui::TextEdit::singleline(&mut rows[i].1)
.hint_text(val_hint)
.desired_width(f32::INFINITY),
);
if v.changed() {
changed = true;
}
});
ui.end_row();
}
});
if let Some(i) = remove {
rows.remove(i);
changed = true;
}
if ui.button(s.gui_add).clicked() {
rows.push((String::new(), String::new()));
changed = true;
}
changed
}
pub fn section_tabs<T: PartialEq + Copy>(
ui: &mut egui::Ui,
theme: &GuiTheme,
current: &mut T,
tabs: &[(T, &str)],
) {
ui.horizontal_wrapped(|ui| {
for (value, label) in tabs {
let selected = *current == *value;
let mut text = RichText::new(*label);
text = if selected {
text.strong().color(theme.text)
} else {
text.color(theme.dim)
};
if selectable(ui, selected, text).clicked() {
*current = *value;
}
}
});
}
pub fn count_suffix(n: usize) -> String {
if n == 0 {
String::new()
} else {
format!(" ({n})")
}
}
pub fn status_color(theme: &GuiTheme, status: u16) -> Color32 {
match status {
200..=299 => theme.ok,
400..=599 => theme.err,
_ => theme.pending,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn screen() -> egui::Rect {
egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(900.0, 600.0))
}
fn a_frame() -> egui::RawInput {
egui::RawInput {
screen_rect: Some(screen()),
..Default::default()
}
}
fn click_at(input: &mut egui::RawInput, pos: egui::Pos2) {
input.events.push(egui::Event::PointerMoved(pos));
for pressed in [true, false] {
input.events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
}
}
#[test]
fn a_dialog_stops_clicks_reaching_the_app_behind_it() {
let button_pos = egui::pos2(20.0, 20.0);
let clicked_behind = |with_dialog: bool| {
let ctx = egui::Context::default();
let mut clicked = false;
for pass in 0..2 {
let mut input = a_frame();
if pass == 1 {
click_at(&mut input, button_pos);
}
let _ = ctx.run_ui(input, |ui| {
if ui.button("behind").clicked() {
clicked = true;
}
if with_dialog {
let ctx = ui.ctx().clone();
dialog(&ctx, "In the way", None, |ui| {
ui.label("answer me");
});
}
});
}
clicked
};
assert!(
clicked_behind(false),
"the test's own button is clickable with no dialog up"
);
assert!(
!clicked_behind(true),
"the same click must not reach it through an open dialog"
);
}
#[test]
fn a_dialog_opens_centred_and_can_still_be_dragged_aside() {
let ctx = egui::Context::default();
let draw = |input: egui::RawInput| {
let _ = ctx.run_ui(input, |ui| {
let ctx = ui.ctx().clone();
dialog(&ctx, "Draggable", None, |ui| {
ui.label("body");
});
});
egui::AreaState::load(&ctx, egui::Id::new(Some("Draggable")))
.expect("the dialog was drawn")
.rect()
};
draw(a_frame());
let centred = draw(a_frame());
assert!(
(centred.center().x - screen().center().x).abs() < 2.0
&& (centred.center().y - screen().center().y).abs() < 2.0,
"it opens in the middle: {centred:?}"
);
let grab = egui::pos2(centred.center().x, centred.min.y + 6.0);
let mut press = a_frame();
press.events.push(egui::Event::PointerMoved(grab));
press.events.push(egui::Event::PointerButton {
pos: grab,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
draw(press);
let mut drag = a_frame();
drag.events
.push(egui::Event::PointerMoved(grab - egui::vec2(200.0, 0.0)));
let moved = draw(drag);
assert!(
moved.center().x < centred.center().x - 100.0,
"dragging the title bar moves it: {moved:?} vs {centred:?}"
);
}
fn header_fills(highlight: Option<egui::Color32>) -> Vec<egui::Color32> {
let ctx = egui::Context::default();
let mut fills = Vec::new();
for _ in 0..3 {
let out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(300.0, 200.0),
)),
..Default::default()
},
|ui| {
tree_header_marked(
ui,
"env-row",
false,
false,
RichText::new("dev"),
highlight,
|_ui| {},
);
},
);
fills.clear();
fn collect(shape: &egui::Shape, out: &mut Vec<egui::Color32>) {
match shape {
egui::Shape::Rect(r) if r.fill != egui::Color32::TRANSPARENT => {
out.push(r.fill)
}
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
out.shapes
.iter()
.for_each(|s| collect(&s.shape, &mut fills));
}
fills
}
#[test]
fn a_marked_tree_header_paints_a_band_in_the_highlight_colour() {
let mark = egui::Color32::from_rgb(0x3d, 0xd6, 0x8c);
let marked = header_fills(Some(mark));
assert!(
marked.contains(&mark),
"the solid leading bar uses the highlight colour: {marked:?}"
);
assert!(
marked
.iter()
.any(|c| *c != mark && c.r() > 0 && c.g() > c.r() && c.g() > c.b()),
"a translucent band of the same hue sits behind the row: {marked:?}"
);
let plain = header_fills(None);
assert!(
!plain.contains(&mark),
"an unmarked row paints no highlight: {plain:?}"
);
assert!(
plain.len() < marked.len(),
"the marking is the only difference between the two rows"
);
}
fn measure_key(screen_w: f32) -> (f32, f32) {
let ctx = egui::Context::default();
let mut key_w = 0.0;
let mut rendered = 0.0;
let mut text = "Content-Type".to_string();
let mut value = "application/json".to_string();
for _ in 0..4 {
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(screen_w, 400.0),
)),
..Default::default()
},
|ui| {
key_w = split_key_width(ui, 72.0);
egui::Grid::new("t")
.num_columns(3)
.min_col_width(0.0)
.show(ui, |ui| {
ui.checkbox(&mut true, "");
rendered = sized_key(ui, key_w, &mut text, "", Color32::PLACEHOLDER)
.rect
.width();
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let _ = ui.button("x");
ui.add(
egui::TextEdit::singleline(&mut value)
.desired_width(f32::INFINITY),
);
},
);
ui.end_row();
});
},
);
}
(key_w, rendered)
}
fn kv_table_width(n: usize) -> f32 {
let ctx = egui::Context::default();
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let s = Strings::for_language(&crate::i18n::Language::English);
let mut rows: Vec<KvRow> = (0..n)
.map(|i| KvRow::new(&format!("Header-{i}"), "a value"))
.collect();
let mut w = 0.0;
for _ in 0..4 {
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 400.0),
)),
..Default::default()
},
|ui| {
let before = ui.min_rect().width();
kv_editor(
ui, &theme, &s, "kv", &mut rows, "name", "value", "Header", "Value",
);
w = ui.min_rect().width() - before;
},
);
}
w
}
#[test]
fn empty_and_filled_tables_lay_out_their_columns_identically() {
let empty = kv_table_width(0);
let filled = kv_table_width(2);
assert!(
(empty - filled).abs() < 1.0,
"empty table was {empty} wide, filled was {filled}"
);
}
#[test]
fn the_description_column_gets_a_readable_share() {
let ctx = egui::Context::default();
let mut got = (0.0, 0.0, 0.0, 0.0);
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 400.0),
)),
..Default::default()
},
|ui| got = kv_widths(ui),
);
let (check, key, val, desc) = got;
assert!(desc > 150.0, "description was only {desc} wide");
assert!(key > 150.0 && val > key, "key {key}, value {val}");
let total = check + key + val + desc + 3.0 * 8.0 + 24.0;
assert!(total <= 900.0, "columns sum to {total}, wider than the row");
}
#[test]
fn key_field_renders_at_the_computed_split_width() {
let (key_w, rendered) = measure_key(600.0);
assert!(key_w > 150.0, "split width should be substantial: {key_w}");
assert!(
(rendered - key_w).abs() < 2.0,
"key rendered {rendered}, expected ~{key_w}"
);
}
fn body_drawn(force_open: bool, id: &'static str) -> bool {
let ctx = egui::Context::default();
let mut drawn = false;
for _ in 0..3 {
drawn = false;
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(300.0, 200.0),
)),
..Default::default()
},
|ui| {
tree_header_marked(
ui,
id,
false,
force_open,
RichText::new("dev"),
None,
|_ui| {
drawn = true;
},
);
},
);
}
drawn
}
#[test]
fn a_collapsed_row_can_be_opened_by_its_caller_rather_than_by_a_click() {
assert!(
!body_drawn(false, "env-closed"),
"a default-closed row starts closed"
);
assert!(
body_drawn(true, "env-revealed"),
"asking to reveal it should open it with no click involved"
);
}
}
pub(crate) struct DialogFrame<R> {
pub inner: Option<R>,
pub dismissed: bool,
}
impl<R> DialogFrame<R> {
pub fn inner_or(self, default: R) -> R {
self.inner.unwrap_or(default)
}
}
pub(crate) fn dialog<R>(
ctx: &egui::Context,
title: &str,
min_width: Option<f32>,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> DialogFrame<R> {
dialog_with(ctx, title, min_width, None, true, add)
}
pub(crate) fn dialog_modeless<R>(
ctx: &egui::Context,
title: &str,
min_width: Option<f32>,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> DialogFrame<R> {
dialog_with(ctx, title, min_width, None, false, add)
}
pub(crate) fn dialog_resizable<R>(
ctx: &egui::Context,
title: &str,
default_size: [f32; 2],
add: impl FnOnce(&mut egui::Ui) -> R,
) -> DialogFrame<R> {
dialog_with(ctx, title, None, Some(default_size), true, add)
}
fn dialog_with<R>(
ctx: &egui::Context,
title: &str,
min_width: Option<f32>,
default_size: Option<[f32; 2]>,
modal: bool,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> DialogFrame<R> {
if modal {
shade(ctx, title);
}
let mut open = true;
let mut window = egui::Window::new(title)
.collapsible(false)
.resizable(default_size.is_some())
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.input(|i| i.content_rect()).center())
.order(egui::Order::Foreground)
.open(&mut open);
if let Some(size) = default_size {
window = window.default_size(size);
}
let inner = window
.show(ctx, |ui| {
if let Some(w) = min_width {
ui.set_min_width(w);
}
add(ui)
})
.and_then(|r| r.inner);
let esc = modal && ctx.input(|i| i.key_pressed(egui::Key::Escape));
DialogFrame {
inner,
dismissed: !open || esc,
}
}
fn shade(ctx: &egui::Context, title: &str) {
let screen = ctx.input(|i| i.content_rect());
egui::Area::new(egui::Id::new(("paperboy-dialog-shade", title)))
.order(egui::Order::Middle)
.fixed_pos(screen.min)
.interactable(true)
.show(ctx, |ui| {
ui.painter()
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(96));
ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
});
}