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 = ui.make_persistent_id(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 openness = state.openness(ui.ctx());
let band = highlight.map(|_| ui.painter().add(egui::Shape::Noop));
let header = ui
.horizontal(|ui| {
ui.set_min_width(ui.available_width());
let icon_w = ui.spacing().icon_width;
let (_rect, icon_resp) =
ui.allocate_exact_size(egui::vec2(icon_w, icon_w), egui::Sense::hover());
egui::collapsing_header::paint_default_icon(ui, openness, &icon_resp);
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, method: &str) {
let col = method_color(method);
ui.label(RichText::new(method).strong().monospace().color(col));
}
pub fn method_combo(
ui: &mut egui::Ui,
id: impl std::hash::Hash + std::fmt::Debug,
method: &mut String,
) -> bool {
let mut changed = false;
let col = method_color(method);
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))).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));
}
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 key_w = split_key_width(ui, 72.0);
let val_w = key_w * 1.5;
egui::Grid::new(id)
.num_columns(4)
.spacing([8.0, 4.0])
.striped(true)
.min_col_width(0.0)
.show(ui, |ui| {
column_header(ui, theme, "\u{2713}");
column_header(ui, theme, key_label);
column_header(ui, theme, val_label);
column_header(ui, theme, s.hdr_description);
ui.end_row();
for i in 0..rows.len() {
if ui.checkbox(&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.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
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 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)
}
#[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"
);
}
}