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 flat_fields<R>(ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.scope(|ui| {
ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::NONE;
content(ui)
})
.inner
}
pub fn table_row<R>(ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
content(ui)
})
.inner
}
pub fn table_rows<R>(ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 4.0;
content(ui)
})
.inner
}
pub fn flat_buttons<R>(ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.scope(|ui| {
ui.spacing_mut().button_padding.y = 0.0;
content(ui)
})
.inner
}
pub fn sized_key(
ui: &mut egui::Ui,
key_w: f32,
text: &mut String,
hint: &str,
color: Color32,
) -> egui::Response {
wrapping_field(ui, key_w, text, hint, color)
}
fn suggest_width(ui: &egui::Ui) -> f32 {
ui.spacing().interact_size.y
}
pub fn suggesting_key(
ui: &mut egui::Ui,
key_w: f32,
text: &mut String,
hint: &str,
color: Color32,
options: &[&'static str],
empty_label: &str,
) -> egui::Response {
let caret_w = suggest_width(ui);
let mut resp = wrapping_field(ui, (key_w - caret_w - 4.0).max(40.0), text, hint, color);
let row_h = ui.spacing().interact_size.y;
let mut picked: Option<&'static str> = None;
flat_buttons(ui, |ui| {
let button = ui.add_sized(
[caret_w, row_h],
egui::Button::new(RichText::new(super::icons::CARET_DOWN).small()),
);
egui::Popup::menu(&button).show(|ui| {
let typed = text.trim().to_ascii_lowercase();
let mut any = false;
egui::ScrollArea::vertical()
.max_height(240.0)
.show(ui, |ui| {
for opt in options {
if !typed.is_empty() && !opt.to_ascii_lowercase().contains(&typed) {
continue;
}
any = true;
if ui.button(*opt).clicked() {
picked = Some(opt);
ui.close();
}
}
});
if !any {
ui.label(empty_label);
}
});
});
if let Some(name) = picked {
*text = name.to_string();
resp.mark_changed();
}
resp
}
pub fn wrapping_field(
ui: &mut egui::Ui,
width: f32,
text: &mut String,
hint: &str,
color: Color32,
) -> egui::Response {
wrapping_field_font(ui, width, text, hint, color, egui::TextStyle::Body)
}
const TEXT_EDIT_MARGIN: f32 = 8.0;
const FIELD_MAX_LINES: f32 = 6.0;
pub fn wrapping_field_font(
ui: &mut egui::Ui,
width: f32,
text: &mut String,
hint: &str,
color: Color32,
font: egui::TextStyle,
) -> egui::Response {
let text_w = (width - TEXT_EDIT_MARGIN).max(16.0);
let font_id = font.resolve(ui.style());
let max_h = ui.ctx().fonts_mut(|f| f.row_height(&font_id)) * FIELD_MAX_LINES + TEXT_EDIT_MARGIN;
let wanted_h = ui
.ctx()
.fonts_mut(|f| f.layout(text.clone(), font_id, color, text_w).size().y)
+ TEXT_EDIT_MARGIN;
let field = |ui: &mut egui::Ui, text: &mut String| {
ui.add(
egui::TextEdit::multiline(text)
.hint_text(hint)
.text_color(color)
.desired_width(text_w)
.desired_rows(1)
.return_key(None)
.font(font.clone()),
)
};
flat_fields(ui, |ui| {
if wanted_h <= max_h {
return ui
.allocate_ui(egui::vec2(width, ui.spacing().interact_size.y), |ui| {
ui.set_width(width);
field(ui, text)
})
.inner;
}
ui.allocate_ui(egui::vec2(width, max_h), |ui| {
ui.set_width(width);
ui.set_height(max_h);
egui::ScrollArea::vertical()
.max_height(max_h)
.auto_shrink([false, false])
.show(ui, |ui| field(ui, text))
.inner
})
.inner
})
}
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 selectable_row<'a>(
ui: &mut egui::Ui,
selected: bool,
atoms: impl egui::IntoAtoms<'a>,
) -> egui::Response {
let state = ui
.ctx()
.read_response(ui.next_auto_id())
.map(|r| r.widget_state())
.unwrap_or_default();
let framed = selected || state != egui::widget_style::WidgetState::Inactive;
let visuals = *ui.visuals().widgets.state(state);
let response = ui.add(egui::Button::selectable(selected, atoms).stroke(egui::Stroke::NONE));
if framed && visuals.bg_stroke.width > 0.0 {
ui.painter().rect_stroke(
response.rect,
visuals.corner_radius,
visuals.bg_stroke,
egui::StrokeKind::Inside,
);
}
response
}
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 const TREE_ROW_SPACING: f32 = 3.0;
pub const TREE_ROW_PADDING: f32 = 2.0;
pub fn tree_rhythm(ui: &mut egui::Ui) {
let spacing = ui.spacing_mut();
spacing.item_spacing.y = TREE_ROW_SPACING;
spacing.button_padding.y = TREE_ROW_PADDING;
}
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 = 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());
let rect = header.rect.expand2(egui::vec2(0.0, 2.0));
let mut shapes = Vec::new();
if header.hovered() {
let visuals = ui.visuals().widgets.hovered;
shapes.push(egui::Shape::rect_filled(
rect,
visuals.corner_radius,
visuals.weak_bg_fill,
));
}
if let Some(color) = highlight {
shapes.push(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,
));
}
if !shapes.is_empty() {
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);
},
);
}
pub fn remove_width(ui: &egui::Ui) -> f32 {
ui.spacing().interact_size.y + 2.0 * ui.spacing().button_padding.x
}
pub fn button_width(ui: &egui::Ui, text: &str) -> f32 {
let font = egui::TextStyle::Button.resolve(ui.style());
let w = ui
.painter()
.layout_no_wrap(text.to_owned(), font, Color32::PLACEHOLDER)
.size()
.x;
w + 2.0 * ui.spacing().button_padding.x
}
fn kv_widths(ui: &egui::Ui) -> (f32, f32, f32, f32) {
let check = ui.spacing().interact_size.y + 4.0;
let fixed = check + remove_width(ui) + 4.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)
}
#[allow(clippy::too_many_arguments)]
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,
extract_label: &str,
extract_row: &mut Option<usize>,
key_options: &[&'static 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;
ui.push_id(id, |ui| {
table_rows(ui, |ui| {
table_row(ui, |ui| {
sized_header(ui, theme, super::icons::PASS, 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.allocate_space(egui::vec2(remove_width(ui), 1.0));
});
for i in 0..rows.len() {
table_row(ui, |ui| {
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 = if key_options.is_empty() {
sized_key(ui, key_w, &mut rows[i].key, key_hint, row_color)
} else {
suggesting_key(
ui,
key_w,
&mut rows[i].key,
key_hint,
row_color,
key_options,
s.gui_suggest_no_matches,
)
};
if k.changed() {
changed = true;
}
let v = wrapping_field(ui, val_w, &mut rows[i].value, val_hint, row_color);
if v.changed() {
changed = true;
}
if !rows[i].value.trim().is_empty() {
v.context_menu(|ui| {
if ui.button(extract_label).clicked() {
*extract_row = Some(i);
ui.close();
}
});
}
let d = wrapping_field(
ui,
desc_w,
&mut rows[i].desc,
s.gui_hint_description,
theme.dim,
);
if d.changed() {
changed = true;
}
let x_w = remove_width(ui);
let hit = flat_buttons(ui, |ui| {
ui.add_sized(
[x_w, row_h],
egui::Button::new(RichText::new(super::icons::CLOSE).color(theme.err)),
)
});
if hit.on_hover_text(s.gui_remove).clicked() {
remove = Some(i);
}
});
}
});
});
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);
let x_w = remove_width(ui);
let row_h = ui.spacing().interact_size.y;
ui.push_id(id, |ui| {
table_rows(ui, |ui| {
table_row(ui, |ui| {
sized_header(ui, theme, key_label, key_w);
column_header(ui, theme, val_label);
});
for i in 0..rows.len() {
table_row(ui, |ui| {
let k = sized_key(ui, key_w, &mut rows[i].0, key_hint, theme.text);
if k.changed() {
changed = true;
}
let val_w = (ui.available_width() - x_w - 8.0).max(40.0);
let v = wrapping_field(ui, val_w, &mut rows[i].1, val_hint, theme.text);
if v.changed() {
changed = true;
}
let hit = flat_buttons(ui, |ui| {
ui.add_sized(
[x_w, row_h],
egui::Button::new(RichText::new(super::icons::CLOSE).color(theme.err)),
)
});
if hit.clicked() {
remove = Some(i);
}
});
}
});
});
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,
});
}
}
fn top_modal_layer_after(modeless: bool) -> Option<egui::LayerId> {
let ctx = egui::Context::default();
for _ in 0..2 {
let _ = ctx.run_ui(a_frame(), |ui| {
let ctx = ui.ctx().clone();
let mut behind = String::new();
ui.add(egui::TextEdit::singleline(&mut behind));
if modeless {
dialog_modeless(&ctx, "Dlg", None, |ui| ui.button("ok"));
} else {
dialog(&ctx, "Dlg", None, |ui| ui.button("ok"));
}
});
}
ctx.memory(|m| m.top_modal_layer())
}
#[test]
fn a_modal_dialog_confines_keyboard_focus_to_its_own_layer() {
let layer = top_modal_layer_after(false);
assert_eq!(
layer.map(|l| l.order),
Some(egui::Order::Foreground),
"the dialog's own layer is the modal one, so Tab can't leave it"
);
assert_ne!(layer, Some(egui::LayerId::background()));
}
#[test]
fn a_modeless_dialog_leaves_the_keyboard_alone() {
assert_eq!(top_modal_layer_after(true), None);
}
#[test]
fn flattening_a_field_does_not_flatten_the_controls_beside_it() {
let ctx = egui::Context::default();
GuiTheme::from_spec(&crate::theme::default_preset()).apply(&ctx);
let mut inside = egui::Stroke::new(9.0, Color32::RED);
let mut after = egui::Stroke::new(9.0, Color32::RED);
let _ = ctx.run_ui(a_frame(), |ui| {
let before = ui.visuals().widgets.inactive.bg_stroke;
assert!(before.width > 0.0, "the app's controls are outlined");
flat_fields(ui, |ui| {
inside = ui.visuals().widgets.inactive.bg_stroke;
});
after = ui.visuals().widgets.inactive.bg_stroke;
});
assert_eq!(inside, egui::Stroke::NONE, "no box around an idle field");
assert!(
after.width > 0.0,
"and everything after it is left alone, got {after:?}"
);
}
#[test]
fn a_long_value_wraps_instead_of_scrolling_out_of_sight() {
let ctx = egui::Context::default();
let measure = |text: &str| -> f32 {
let mut value = text.to_string();
let mut height = 0.0;
for _ in 0..2 {
let _ = ctx.run_ui(a_frame(), |ui| {
height = wrapping_field(ui, 200.0, &mut value, "", Color32::WHITE)
.rect
.height();
});
}
height
};
let short = measure("small");
let long = measure(
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.a-very-long-token-that-will-not-fit in-two-hundred-points-of-width-no-matter-how-small-the-font-is",
);
assert!(
long > short * 2.0,
"a value too long for the field must wrap onto more lines: {short} then {long}"
);
}
#[test]
fn enter_cannot_break_a_value_across_lines() {
let ctx = egui::Context::default();
let mut value = "text/plain".to_string();
let mut input = a_frame();
click_at(&mut input, egui::pos2(60.0, 12.0));
input.events.push(egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
for _ in 0..2 {
let _ = ctx.run_ui(input.clone(), |ui| {
wrapping_field(ui, 200.0, &mut value, "", Color32::WHITE);
});
}
assert_eq!(value, "text/plain", "Enter must not insert a newline");
}
#[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()
+ TEXT_EDIT_MARGIN;
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",
"Extract",
&mut None,
&[],
);
w = ui.min_rect().width() - before;
},
);
}
w
}
fn kv_texts(rows: &mut Vec<KvRow>, key_options: &[&'static str]) -> Vec<String> {
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 out = Vec::new();
for _ in 0..4 {
out.clear();
let full = 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| {
kv_editor(
ui,
&theme,
&s,
"kv",
rows,
"name",
"value",
"Header",
"Value",
"Extract",
&mut None,
key_options,
);
},
);
for cs in &full.shapes {
collect_text(&cs.shape, &mut out);
}
}
out
}
fn collect_text(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => {
for s in v {
collect_text(s, out);
}
}
_ => {}
}
}
#[test]
fn a_key_column_with_a_vocabulary_gets_a_caret_and_one_without_does_not() {
let mut rows = vec![KvRow::new("Accept", "application/json")];
let with = kv_texts(&mut rows, crate::http::COMMON_HEADERS);
assert!(
with.iter().any(|t| t == super::super::icons::CARET_DOWN),
"the headers table offers the list: {with:?}"
);
let mut rows = vec![KvRow::new("page", "2")];
let without = kv_texts(&mut rows, &[]);
assert!(
!without.iter().any(|t| t == super::super::icons::CARET_DOWN),
"a query parameter has nothing to suggest: {without:?}"
);
}
#[test]
fn the_key_vocabulary_is_the_one_both_front_ends_share() {
assert!(crate::http::COMMON_HEADERS.contains(&"Content-Type"));
assert_eq!(
crate::http::filter_headers("auth"),
vec!["Authorization"],
"the caret narrows to what has been typed"
);
}
fn rects_filled(shape: &egui::Shape, fill: Color32, out: &mut Vec<egui::Rect>) {
match shape {
egui::Shape::Rect(r) if r.fill == fill => out.push(r.rect),
egui::Shape::Vec(v) => {
for s in v {
rects_filled(s, fill, out);
}
}
_ => {}
}
}
fn field_rects(theme: &GuiTheme, mut body: impl FnMut(&mut egui::Ui)) -> Vec<egui::Rect> {
let ctx = egui::Context::default();
theme.apply(&ctx);
let mut out = Vec::new();
for _ in 0..4 {
let full = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(1200.0, 600.0),
)),
..Default::default()
},
&mut body,
);
out.clear();
for cs in &full.shapes {
rects_filled(&cs.shape, theme.field(), &mut out);
}
}
out
}
#[test]
fn selecting_a_row_leaves_it_exactly_where_it_was() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
theme.apply(&ctx);
let away = egui::pos2(390.0, 190.0);
let rows = |pointer: egui::Pos2, body: &mut dyn FnMut(&mut egui::Ui, usize)| {
let mut shapes = Vec::new();
for _ in 0..2 {
let full = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 200.0),
)),
events: vec![egui::Event::PointerMoved(pointer)],
..Default::default()
},
|ui| {
for i in 0..3 {
body(ui, i);
}
},
);
shapes = full.shapes;
}
shapes
};
let with = |selected: bool, pointer: egui::Pos2| {
let mut rects = Vec::new();
let shapes = rows(pointer, &mut |ui, i| {
let r = selectable_row(ui, selected && i == 1, "GET /one").rect;
if i == 0 {
rects.clear();
}
rects.push(r);
});
(rects, shapes)
};
let (quiet, _) = with(false, away);
let (picked, picked_shapes) = with(true, away);
assert_eq!(
quiet, picked,
"selecting the middle row moved it or its neighbours"
);
let (hovered, _) = with(false, quiet[0].center());
assert_eq!(quiet, hovered, "hovering a row moved it or its neighbours");
let mut bare = Vec::new();
rows(away, &mut |ui, i| {
let r = ui
.add(egui::Button::selectable(false, "GET /one").frame_when_inactive(false))
.rect;
if i == 0 {
bare.clear();
}
bare.push(r);
});
assert_eq!(
bare, quiet,
"rows grew to make room for a border they aren't drawing"
);
let mut border = None;
for cs in &picked_shapes {
stroked_rects(&cs.shape, &mut |rect, width| {
if width > 0.0 && rect == picked[1] {
border = Some(width);
}
});
}
assert!(
border.is_some(),
"the selected row lost its border: {picked_shapes:?}"
);
}
fn stroked_rects(shape: &egui::Shape, out: &mut dyn FnMut(egui::Rect, f32)) {
match shape {
egui::Shape::Rect(r) if r.stroke.width > 0.0 => out(r.rect, r.stroke.width),
egui::Shape::Vec(v) => {
for s in v {
stroked_rects(s, out);
}
}
_ => {}
}
}
#[test]
fn an_unselected_row_paints_no_chip_but_an_unselected_tab_does() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
theme.apply(&ctx);
let chips = |body: &dyn Fn(&mut egui::Ui)| {
let mut out = Vec::new();
for _ in 0..2 {
let full = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 200.0),
)),
..Default::default()
},
|ui| body(ui),
);
out.clear();
for cs in &full.shapes {
rects_filled(&cs.shape, theme.raised(), &mut out);
}
}
out.len()
};
assert_eq!(
chips(&|ui| {
selectable_row(ui, false, "GET /one");
selectable_row(ui, false, "GET /two");
}),
0,
"an unselected list row is content, not a chip"
);
assert_eq!(
chips(&|ui| {
selectable(ui, false, "Params");
selectable(ui, false, "Headers");
}),
2,
"a segmented control keeps every option framed"
);
}
#[test]
fn the_fields_in_a_row_line_up_with_each_other() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let s = Strings::for_language(&crate::i18n::Language::English);
let mut rows = vec![
KvRow::new("Authorization", "Bearer abc"),
KvRow::new("Accept", "application/json"),
];
let rects = field_rects(&theme, |ui| {
kv_editor(
ui,
&theme,
&s,
"kv",
&mut rows,
"name",
"value",
"Header",
"Value",
"Extract",
&mut None,
&[],
);
});
assert_eq!(rects.len(), 6, "three fields per row, got {rects:?}");
for row in rects.chunks(3) {
let first = row[0];
for (i, r) in row.iter().enumerate() {
assert!(
(r.top() - first.top()).abs() < 0.01,
"field {i} sits at {} but the row starts at {}",
r.top(),
first.top()
);
assert!(
(r.height() - first.height()).abs() < 0.01,
"field {i} is {} tall, the row is {}",
r.height(),
first.height()
);
}
}
}
#[test]
fn the_remove_button_does_not_hang_below_the_row() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let s = Strings::for_language(&crate::i18n::Language::English);
let mut rows = vec![KvRow::new("Accept", "application/json")];
let ctx = egui::Context::default();
theme.apply(&ctx);
let mut button_fill = Color32::TRANSPARENT;
let mut fields = Vec::new();
let mut buttons = Vec::new();
let full = ctx.run_ui(a_frame(), |ui| {
button_fill = ui.visuals().widgets.inactive.weak_bg_fill;
kv_editor(
ui,
&theme,
&s,
"kv",
&mut rows,
"name",
"value",
"Header",
"Value",
"Extract",
&mut None,
&[],
);
});
for cs in &full.shapes {
rects_filled(&cs.shape, theme.field(), &mut fields);
rects_filled(&cs.shape, button_fill, &mut buttons);
}
let field = *fields.first().expect("a field was painted");
let x = buttons
.iter()
.filter(|b| b.top() < field.bottom())
.max_by(|a, b| a.left().total_cmp(&b.left()))
.copied()
.expect("the remove button was painted");
assert!(
x.height() <= field.height() + 0.01,
"the ✕ is {} tall next to a {} field",
x.height(),
field.height()
);
}
#[test]
fn kv_rows_are_not_striped() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let s = Strings::for_language(&crate::i18n::Language::English);
let mut rows = vec![KvRow::new("Accept", "application/json"); 4];
let ctx = egui::Context::default();
theme.apply(&ctx);
let mut stripe = Color32::TRANSPARENT;
let mut found = Vec::new();
let full = ctx.run_ui(a_frame(), |ui| {
stripe = ui.visuals().faint_bg_color;
kv_editor(
ui,
&theme,
&s,
"kv",
&mut rows,
"name",
"value",
"Header",
"Value",
"Extract",
&mut None,
&[],
);
});
for cs in &full.shapes {
rects_filled(&cs.shape, stripe, &mut found);
}
found.retain(|r| r.width() > 300.0);
assert!(found.is_empty(), "row stripes painted: {found:?}");
}
#[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"
);
}
#[test]
fn a_tree_row_lights_up_under_the_pointer() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
theme.apply(&ctx);
let wash = ctx
.style_of(egui::Theme::Dark)
.visuals
.widgets
.hovered
.weak_bg_fill;
let washes = |pointer: egui::Pos2| {
let mut out = Vec::new();
for _ in 0..2 {
let full = 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),
)),
events: vec![egui::Event::PointerMoved(pointer)],
..Default::default()
},
|ui| {
tree_header(ui, "hover-row", false, RichText::new("dev"), |_ui| {});
},
);
out.clear();
for cs in &full.shapes {
rects_filled(&cs.shape, wash, &mut out);
}
}
out.len()
};
assert_eq!(
washes(egui::pos2(280.0, 190.0)),
0,
"a row at rest is plain"
);
assert_eq!(
washes(egui::pos2(40.0, 8.0)),
1,
"the row under the pointer should say so"
);
}
#[test]
fn a_short_value_does_not_reserve_the_room_a_long_one_would() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
theme.apply(&ctx);
let row_height = |value: Option<&str>| {
let mut text = value.unwrap_or_default().to_string();
let mut out = 0.0;
for _ in 0..3 {
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 600.0),
)),
..Default::default()
},
|ui| {
ui.horizontal(|ui| {
let _ = ui.button("POST");
if value.is_some() {
wrapping_field(ui, 200.0, &mut text, "", Color32::WHITE);
}
let _ = ui.button("Send");
out = ui.min_rect().height();
});
},
);
}
out
};
let controls = row_height(None);
let with_url = row_height(Some("{{url}}/create_session"));
assert!(
with_url <= controls + 2.0,
"a one-line URL made its row {with_url}px tall, next to {controls}px of controls"
);
}
#[test]
fn a_very_long_value_stops_growing_and_scrolls_instead() {
let theme = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
theme.apply(&ctx);
let height = |value: &str| {
let mut text = value.to_string();
let mut out = 0.0;
for _ in 0..3 {
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(300.0, 600.0),
)),
..Default::default()
},
|ui| {
ui.scope(|ui| {
wrapping_field(ui, 120.0, &mut text, "", Color32::WHITE);
out = ui.min_rect().height();
});
},
);
}
out
};
let one_line = height("short");
let jwt = height(&"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.".repeat(40));
assert!(
jwt > one_line,
"a value that needs two lines still gets them"
);
assert!(
jwt <= one_line * (FIELD_MAX_LINES + 1.0),
"a huge value took the whole panel ({jwt}px for a {one_line}px row)"
);
}
}
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 shown = window.show(ctx, |ui| {
if let Some(w) = min_width {
ui.set_min_width(w);
}
add(ui)
});
if modal && let Some(r) = &shown {
ctx.memory_mut(|m| m.set_modal_layer(r.response.layer_id));
}
let inner = shown.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());
});
}