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 {
wrapping_field_font_id(ui, width, text, hint, color, font, None)
}
pub fn wrapping_field_font_id(
ui: &mut egui::Ui,
width: f32,
text: &mut String,
hint: &str,
color: Color32,
font: egui::TextStyle,
id: Option<egui::Id>,
) -> 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| {
let mut edit = egui::TextEdit::multiline(text)
.hint_text(hint)
.text_color(color)
.desired_width(text_w)
.desired_rows(1)
.return_key(None)
.font(font.clone());
if let Some(id) = id {
edit = edit.id(id);
}
ui.add(edit)
};
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 bad_key = crate::hurl::key_problem(&rows[i].key).is_some()
&& !rows[i].key.trim().is_empty();
let bad_value = crate::hurl::value_problem(&rows[i].value).is_some();
let row_color = if bad_key || bad_value {
theme.err
} else 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;
}
if bad_key {
k.on_hover_text(s.gui_bad_key_warning);
}
let v = wrapping_field(ui, val_w, &mut rows[i].value, val_hint, row_color);
if v.changed() {
changed = true;
}
if bad_value {
v.clone().on_hover_text(s.gui_bad_value_warning);
}
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
}
fn neighbour_key(neighbour: &str, i: usize, dup: usize) -> egui::Id {
if neighbour.trim().is_empty() {
egui::Id::new(("row", i))
} else {
egui::Id::new(("neighbour", neighbour, dup))
}
}
pub fn computed_editor(
ui: &mut egui::Ui,
theme: &GuiTheme,
s: &Strings,
req: egui::Id,
rows: &mut Vec<(String, String)>,
vars: &[String],
) -> 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(req.with("computed"), |ui| {
table_rows(ui, |ui| {
table_row(ui, |ui| {
sized_header(ui, theme, s.generated_name, key_w);
column_header(ui, theme, s.generated_expr);
});
for i in 0..rows.len() {
table_row(ui, |ui| {
let name_dup = rows[..i]
.iter()
.filter(|(_, e)| e.trim() == rows[i].1.trim())
.count();
let expr_dup = rows[..i]
.iter()
.filter(|(n, _)| n.trim() == rows[i].0.trim())
.count();
let name_anchor = neighbour_key(&rows[i].1, i, name_dup);
let expr_anchor = neighbour_key(&rows[i].0, i, expr_dup);
let name_id = req.with(("computed-name", name_anchor));
let expr_id = req.with(("computed-expr", expr_anchor));
let name = rows[i].0.trim();
let bad_name = (!name.is_empty() && !crate::hurl::is_variable_name(name))
|| (name.is_empty() && !rows[i].1.trim().is_empty());
let name_color = if bad_name { theme.err } else { theme.text };
let k = wrapping_field_font_id(
ui,
key_w,
&mut rows[i].0,
s.generated_name,
name_color,
egui::TextStyle::Body,
Some(name_id),
);
if k.changed() {
changed = true;
}
if bad_name {
k.on_hover_text(s.gui_generated_bad_name);
}
let val_w =
(ui.available_width() - x_w - ui.spacing().item_spacing.x).max(40.0);
let mut in_scope: Vec<String> = vars.to_vec();
in_scope.extend(
rows[..i]
.iter()
.map(|(n, _)| n.trim())
.filter(|n| !n.is_empty())
.map(str::to_string),
);
let mut sugg = suggest_state(ui, s, expr_id, &rows[i].1, &in_scope);
if sugg.take_keys(ui) {
changed = true;
}
sugg.apply(ui.ctx(), expr_id, &mut rows[i].1);
let field = wrapping_field_font_id(
ui,
val_w,
&mut rows[i].1,
s.gui_generated_expr_hint,
theme.text,
egui::TextStyle::Monospace,
Some(expr_id),
);
if field.changed() {
changed = true;
}
if sugg.show(ui, theme, &field, expr_id, &mut rows[i].1) {
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;
}
let faults = crate::generators::check(rows);
if !faults.is_empty() {
ui.add_space(6.0);
ui.label(
RichText::new(s.gui_generated_faults)
.color(theme.err)
.strong(),
);
for line in crate::i18n::summarise_gen_errors(s, &faults) {
ui.label(RichText::new(line).color(theme.err));
}
}
changed
}
#[derive(Clone, PartialEq)]
struct Suggestion {
text: String,
kind: SuggestKind,
note: &'static str,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SuggestKind {
Signature,
Example,
Variable,
}
#[derive(Clone)]
struct Suggest {
id: egui::Id,
had_focus: bool,
word: String,
rows: Vec<Suggestion>,
sel: usize,
dismissed_for: Option<String>,
forced: bool,
accepted: Option<Suggestion>,
}
impl Default for Suggest {
fn default() -> Self {
Self {
id: egui::Id::NULL,
had_focus: false,
word: String::new(),
rows: Vec::new(),
sel: 0,
dismissed_for: None,
forced: false,
accepted: None,
}
}
}
fn caret_of(ctx: &egui::Context, id: egui::Id) -> Option<usize> {
let state = egui::TextEdit::load_state(ctx, id)?;
state.cursor.char_range().map(|r| r.primary.index.0)
}
fn suggest_state(ui: &egui::Ui, s: &Strings, id: egui::Id, text: &str, vars: &[String]) -> Suggest {
let mut st: Suggest = ui
.data(|d| d.get_temp::<Suggest>(id.with("suggest")))
.unwrap_or_default();
st.accepted = None;
st.id = id;
let mut focused = ui.memory(|m| m.has_focus(id));
if !focused && st.had_focus && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.ctx().memory_mut(|m| m.request_focus(id));
focused = true;
st.dismissed_for =
Some(crate::generators::typed_word_at(text, caret_of(ui.ctx(), id)).prefix);
}
st.had_focus = focused;
if !focused {
st.forced = false;
}
let typed = crate::generators::typed_word_at(text, caret_of(ui.ctx(), id));
st.word = typed.prefix.clone();
let browse = st.forced || st.word.is_empty();
st.rows = if focused {
suggestions(s, &typed, browse, vars)
} else {
Vec::new()
};
if st.dismissed_for.as_deref() != Some(st.word.as_str()) {
st.dismissed_for = None;
}
st.sel = st.sel.min(st.rows.len().saturating_sub(1));
st
}
fn suggestions(
s: &Strings,
w: &crate::generators::TypedWord,
browse: bool,
vars: &[String],
) -> Vec<Suggestion> {
let lower = w.prefix.to_ascii_lowercase();
let mut out: Vec<Suggestion> = vars
.iter()
.filter(|v| browse || v.to_ascii_lowercase().starts_with(&lower))
.map(|v| Suggestion {
text: v.clone(),
kind: SuggestKind::Variable,
note: s.gui_generated_var_note,
})
.collect();
for row in
crate::generators::suggestions_for_word(&w.prefix, &w.whole, browse).unwrap_or_default()
{
let f = crate::generators::function_for_suggestion(row);
let signature = f.is_some_and(|f| f.signature == row);
out.push(Suggestion {
text: row.to_string(),
kind: if signature {
SuggestKind::Signature
} else {
SuggestKind::Example
},
note: f.map(|f| s.gen_description(f.name)).unwrap_or(""),
});
}
out
}
impl Suggest {
fn open(&self) -> bool {
!self.rows.is_empty() && (self.forced || self.dismissed_for.is_none())
}
fn take_keys(&mut self, ui: &egui::Ui) -> bool {
if self.had_focus
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space))
{
self.forced = true;
self.sel = 0;
}
if !self.open() {
return false;
}
let n = self.rows.len();
let (mut down, mut up, mut accept, mut dismiss) = (false, false, false, false);
ui.input_mut(|i| {
use egui::{Key, Modifiers};
down = i.consume_key(Modifiers::NONE, Key::ArrowDown);
up = i.consume_key(Modifiers::NONE, Key::ArrowUp);
accept = i.consume_key(Modifiers::NONE, Key::Enter)
|| i.consume_key(Modifiers::NONE, Key::Tab);
dismiss = i.consume_key(Modifiers::NONE, Key::Escape);
});
if down {
self.sel = (self.sel + 1) % n;
}
if up {
self.sel = (self.sel + n - 1) % n;
}
if dismiss {
self.dismissed_for = Some(self.word.clone());
self.forced = false;
ui.ctx().memory_mut(|m| m.request_focus(self.id));
}
if accept {
self.accepted = self.rows.get(self.sel).cloned();
}
self.accepted.is_some()
}
fn apply(&mut self, ctx: &egui::Context, id: egui::Id, text: &mut String) {
let Some(row) = self.accepted.take() else {
return;
};
self.accept(ctx, id, text, &row);
}
fn accept(&mut self, ctx: &egui::Context, id: egui::Id, text: &mut String, row: &Suggestion) {
accept_suggestion(ctx, id, text, row);
ctx.memory_mut(|m| m.request_focus(id));
self.rows.clear();
self.forced = false;
self.dismissed_for = Some(String::new());
}
fn show(
&mut self,
ui: &egui::Ui,
theme: &GuiTheme,
field: &egui::Response,
id: egui::Id,
text: &mut String,
) -> bool {
let open = self.open();
let mut picked: Option<Suggestion> = None;
let mut hovered: Option<usize> = None;
if open {
egui::Popup::from_response(field)
.id(id.with("suggest-popup"))
.open(true)
.align(egui::RectAlign::BOTTOM_START)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
egui::ScrollArea::vertical()
.max_height(240.0)
.show(ui, |ui| {
for (k, row) in self.rows.iter().enumerate() {
let label = match row.kind {
SuggestKind::Signature => RichText::new(&row.text).monospace(),
SuggestKind::Example => {
RichText::new(format!(" {}", row.text))
.monospace()
.color(theme.dim)
}
SuggestKind::Variable => {
RichText::new(&row.text).monospace().color(theme.computed)
}
};
let hit = ui.add(egui::Button::selectable(k == self.sel, label));
if hit.hovered() {
hovered = Some(k);
}
if hit.clicked() {
picked = Some(row.clone());
}
}
});
if let Some(k) = hovered.filter(|k| *k != self.sel) {
self.sel = k;
ui.ctx().request_repaint();
}
let row = self.rows.get(self.sel);
let preview = row
.map(|r| preview_of(text, caret_of(ui.ctx(), id), r))
.filter(|p| p != text && Some(p.as_str()) != row.map(|r| r.text.as_str()));
let note = row.map(|r| r.note).filter(|n| !n.is_empty());
if preview.is_some() || note.is_some() {
ui.separator();
}
if let Some(preview) = preview {
ui.label(
RichText::new(format!("\u{2192} {preview}"))
.monospace()
.color(theme.computed),
);
}
if let Some(note) = note {
ui.label(RichText::new(note).color(theme.dim));
}
});
}
let took = picked.is_some();
if let Some(row) = picked {
self.accept(ui.ctx(), id, text, &row);
}
ui.ctx()
.data_mut(|d| d.insert_temp(id.with("suggest"), self.clone()));
took
}
}
fn preview_of(text: &str, caret: Option<usize>, row: &Suggestion) -> String {
match row.kind {
SuggestKind::Signature => match crate::generators::function_for_suggestion(&row.text) {
Some(f) => insert_call(text, caret, f).0,
None => text.to_string(),
},
_ => splice(text, caret, &row.text).0,
}
}
fn accept_suggestion(ctx: &egui::Context, id: egui::Id, text: &mut String, row: &Suggestion) {
match row.kind {
SuggestKind::Signature => {
let Some(f) = crate::generators::function_for_suggestion(&row.text) else {
return;
};
write_call(ctx, id, text, f);
}
_ => write_text(ctx, id, text, &row.text),
}
}
fn write_call(
ctx: &egui::Context,
id: egui::Id,
text: &mut String,
f: &crate::generators::GenFunction,
) {
insert_at_caret(ctx, id, text, |text, caret| insert_call(text, caret, f));
}
fn write_text(ctx: &egui::Context, id: egui::Id, text: &mut String, call: &str) {
insert_at_caret(ctx, id, text, |text, caret| {
let (out, start, _) = splice(text, caret, call);
let at = start + call.chars().count();
(out, at, at)
});
}
fn insert_at_caret(
ctx: &egui::Context,
id: egui::Id,
text: &mut String,
edit: impl Fn(&str, Option<usize>) -> (String, usize, usize),
) {
use egui::text::{CCursor, CCursorRange};
let Some(mut state) = egui::TextEdit::load_state(ctx, id) else {
(*text, _, _) = edit(text, None);
return;
};
let range = state.cursor.char_range();
let at_end = CCursorRange::one(CCursor::new(text.chars().count()));
let mut undoer = state.undoer();
undoer.add_undo(&(range.unwrap_or(at_end), text.clone()));
state.set_undoer(undoer);
let (out, from, to) = edit(text, range.map(|r| r.primary.index.0));
*text = out;
state.cursor.set_char_range(Some(CCursorRange::two(
CCursor::new(from),
CCursor::new(to),
)));
egui::TextEdit::store_state(ctx, id, state);
}
fn splice(text: &str, caret: Option<usize>, with: &str) -> (String, usize, usize) {
let w = crate::generators::typed_word_at(text, caret);
(splice_range(text, w.start, w.end, with), w.start, w.end)
}
fn splice_range(text: &str, start: usize, end: usize, with: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out: String = chars[..start.min(chars.len())].iter().collect();
out.push_str(with);
out.extend(chars[end.min(chars.len())..].iter());
out
}
fn insert_call(
text: &str,
caret: Option<usize>,
f: &crate::generators::GenFunction,
) -> (String, usize, usize) {
let mut args: Vec<String> = arg_names(f.signature)
.into_iter()
.map(str::to_string)
.collect();
let w = crate::generators::typed_word_at(text, caret);
let wrapping = crate::generators::can_wrap(f) && !w.wrapped.is_empty();
if wrapping {
match args.first_mut() {
Some(first) => *first = w.wrapped.clone(),
None => args.push(w.wrapped.clone()),
}
}
let call = if wrapping || f.min_args > 0 {
format!("{}({})", f.name, args.join(", "))
} else {
f.name.to_string()
};
let end = if wrapping { w.wrap_end } else { w.end };
let out = splice_range(text, w.start, end, &call);
let start = w.start;
if !wrapping && f.min_args == 0 {
let at = start + call.chars().count();
return (out, at, at);
}
let filled = usize::from(wrapping);
let Some(arg) = args.get(filled) else {
let at = start + call.chars().count();
return (out, at, at);
};
let before: usize = args[..filled].iter().map(|a| a.chars().count() + 2).sum();
let from = start + f.name.chars().count() + 1 + before;
let to = from + arg.chars().count();
(out, from, to)
}
fn arg_names(signature: &str) -> Vec<&str> {
let Some(open) = signature.find('(') else {
return Vec::new();
};
let inner = signature[open + 1..].trim_end_matches(')');
inner
.split(',')
.map(|a| a.trim().trim_matches(|c| c == '[' || c == ']'))
.filter(|a| !a.is_empty())
.collect()
}
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 content = ctx.input(|i| i.content_rect());
window = window.max_size([
(content.width() - 48.0).max(240.0),
(content.height() - 48.0).max(160.0),
]);
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());
});
}
#[cfg(test)]
mod function_menu_tests {
use super::{egui, insert_call};
use crate::generators::function;
#[test]
fn a_chosen_function_lands_at_the_caret_with_its_argument_selected() {
let f = function("sha256").expect("sha256 is a generator function");
let (text, from, to) = insert_call("base64()", Some(7), f);
assert_eq!(text, "base64(sha256(text))");
assert_eq!(
&text[from..to],
"text",
"the argument name is selected, so typing fills it in"
);
}
#[test]
fn every_argument_is_written_in_and_the_first_is_selected() {
let f = function("hmac_sha256").expect("hmac_sha256 is a generator function");
let (text, from, to) = insert_call("", None, f);
assert_eq!(text, "hmac_sha256(key, message)");
assert_eq!(&text[from..to], "key");
}
#[test]
fn a_half_typed_name_is_replaced_rather_than_doubled() {
let f = function("uuid").expect("uuid is a generator function");
let (text, from, to) = insert_call("id = uu", Some(7), f);
assert_eq!(text, "id = uuid");
assert_eq!((from, to), (9, 9));
}
#[test]
fn choosing_a_function_leaves_something_for_ctrl_z_to_undo() {
use egui::text::{CCursor, CCursorRange};
let ctx = egui::Context::default();
let id = egui::Id::new("expr");
let mut state = egui::widgets::text_edit::TextEditState::default();
state
.cursor
.set_char_range(Some(CCursorRange::one(CCursor::new(7))));
egui::TextEdit::store_state(&ctx, id, state);
let mut text = "base64()".to_string();
let f = function("sha256").expect("sha256 is a generator function");
super::write_call(&ctx, id, &mut text, f);
assert_eq!(text, "base64(sha256(text))");
let state = egui::TextEdit::load_state(&ctx, id).expect("state was stored");
assert_eq!(
state
.cursor
.char_range()
.map(|r| (r.secondary.index.0, r.primary.index.0)),
Some((14, 18)),
"the argument written in is selected, ready to be typed over"
);
let now = (
CCursorRange::one(CCursor::new(18)),
"base64(sha256(text))".to_string(),
);
assert_eq!(
state.undoer().undo(&now).map(|(_, t)| t.as_str()),
Some("base64()"),
"one undo goes back to the expression as it was"
);
}
#[test]
fn a_field_never_clicked_into_appends() {
let f = function("uuid").expect("uuid is a generator function");
let (text, from, to) = insert_call("", None, f);
assert_eq!(text, "uuid");
assert_eq!((from, to), (4, 4));
}
}