use std::fmt::Write as _;
use egui::{Pos2, Rect, Response, Ui};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Button,
Toggle,
Field,
Slider,
Knob,
Swatch,
List,
Row,
Table,
Cell,
Header,
Tree,
TreeItem,
Menu,
MenuItem,
Dialog,
Toast,
Search,
Status,
Palette,
Tab,
#[default]
Other,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Element {
pub id: String,
pub label: String,
pub role: Role,
pub rect: [f32; 4],
pub enabled: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected: Option<bool>,
#[serde(skip)]
pub egui_id: Option<u64>,
}
impl Element {
pub fn egui_rect(&self) -> Rect {
let [x, y, w, h] = self.rect;
Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h))
}
pub fn center(&self) -> Pos2 {
self.egui_rect().center()
}
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Inspect {
pub pass: u64,
pub elements: Vec<Element>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Details<'a> {
pub value: Option<&'a str>,
pub selected: Option<bool>,
}
#[derive(Default)]
struct Registry {
recording: Vec<Element>,
len: usize,
published: Vec<Element>,
published_len: usize,
published_pass: u64,
disabled: bool,
}
impl Registry {
fn slot(&mut self) -> Option<&mut Element> {
if self.len == self.recording.len() {
self.recording.push(Element::default());
}
let i = self.len;
self.len += 1;
self.recording.get_mut(i)
}
fn publish(&mut self, pass: u64) {
std::mem::swap(&mut self.recording, &mut self.published);
self.published_len = self.len;
self.published_pass = pass;
self.len = 0;
}
fn live(&self) -> &[Element] {
self.published.get(..self.published_len).unwrap_or(&[])
}
}
pub const MAX_ELEMENTS: usize = 50_000;
#[derive(Default)]
struct Plugin {
registry: Registry,
}
impl egui::Plugin for Plugin {
fn debug_name(&self) -> &'static str {
"ashlar_ui::automation"
}
fn on_begin_pass(&mut self, _ui: &mut Ui) {
self.registry.len = 0;
}
fn on_end_pass(&mut self, ui: &mut Ui) {
if !ui.ctx().will_discard() {
self.registry.publish(ui.ctx().cumulative_pass_nr());
}
}
}
fn with_registry<R>(ctx: &egui::Context, f: impl FnOnce(&mut Registry) -> R) -> Option<R> {
let mut f = Some(f);
if let Some(r) = ctx.with_plugin::<Plugin, _>(|p| f.take().map(|f| f(&mut p.registry))) {
return r;
}
ctx.add_plugin(Plugin::default());
ctx.with_plugin::<Plugin, _>(|p| f.take().map(|f| f(&mut p.registry))).flatten()
}
pub fn install(ctx: &egui::Context) {
ctx.add_plugin(Plugin::default());
}
pub fn set_enabled(ctx: &egui::Context, enabled: bool) {
with_registry(ctx, |r| r.disabled = !enabled);
}
fn record(ui: &Ui, rect: Rect, egui_id: Option<egui::Id>, id: std::fmt::Arguments<'_>, label: &str, role: Role, enabled: bool, details: Details<'_>) {
let visible = rect.intersect(ui.clip_rect());
if !visible.is_positive() || !visible.is_finite() {
return;
}
with_registry(ui.ctx(), |r| {
if r.disabled || r.len >= MAX_ELEMENTS {
return;
}
let Some(e) = r.slot() else { return };
e.id.clear();
let _ = e.id.write_fmt(id);
if e.id.is_empty() {
r.len = r.len.saturating_sub(1);
return;
}
e.label.clear();
e.label.push_str(label);
e.role = role;
e.rect = [visible.min.x, visible.min.y, visible.width(), visible.height()];
e.enabled = enabled;
e.value.clear();
if let Some(v) = details.value {
e.value.push_str(v);
}
e.selected = details.selected;
e.egui_id = egui_id.map(|i| i.value());
});
}
pub fn register(ui: &Ui, response: &Response, id: &str, label: &str, role: Role) {
record(ui, response.rect, Some(response.id), format_args!("{id}"), label, role, response.enabled(), Details::default());
}
pub fn register_with(ui: &Ui, response: &Response, id: &str, label: &str, role: Role, details: Details<'_>) {
record(ui, response.rect, Some(response.id), format_args!("{id}"), label, role, response.enabled(), details);
}
pub fn register_fmt(ui: &Ui, rect: Rect, id: std::fmt::Arguments<'_>, label: &str, role: Role, enabled: bool, details: Details<'_>) {
record(ui, rect, None, id, label, role, enabled, details);
}
pub fn inspect(ctx: &egui::Context) -> Inspect {
with_registry(ctx, |r| Inspect { pass: r.published_pass, elements: r.live().to_vec() }).unwrap_or_default()
}
pub fn find(ctx: &egui::Context, id: &str) -> Option<Element> {
with_registry(ctx, |r| r.live().iter().find(|e| e.id == id).cloned()).flatten()
}
pub fn query(ctx: &egui::Context, prefix: &str) -> Vec<Element> {
with_registry(ctx, |r| r.live().iter().filter(|e| e.id.starts_with(prefix)).cloned().collect()).unwrap_or_default()
}
pub fn click_point(ctx: &egui::Context, id: &str) -> Option<Pos2> {
find(ctx, id).map(|e| e.center())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::run;
fn input() -> egui::RawInput {
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(400.0, 300.0))), ..Default::default() }
}
#[test]
fn registers_visible_elements_and_publishes_complete_passes() {
let ctx = egui::Context::default();
install(&ctx);
let mut rows = 3;
let frame = |rows: usize| {
run(&ctx, input(), |ui| {
let r = ui.button("Save");
register(ui, &r, "toolbar.save", "Save", Role::Button);
register(ui, &r, "", "Save", Role::Button);
for i in 0..rows {
let r = ui.label("row");
register_fmt(
ui,
r.rect,
format_args!("list.row.{i}"),
"row",
Role::Row,
true,
Details { selected: Some(i == 1), ..Default::default() },
);
}
register_fmt(
ui,
Rect::from_min_size(egui::pos2(1000.0, 1000.0), egui::vec2(10.0, 10.0)),
format_args!("hidden"),
"",
Role::Other,
true,
Details::default(),
);
});
};
frame(rows);
let snap = inspect(&ctx);
assert_eq!(snap.elements.len(), 4);
assert_eq!(snap.elements[0].id, "toolbar.save");
assert_eq!(snap.elements[0].role, Role::Button);
assert_eq!(query(&ctx, "list.row.").len(), 3);
assert_eq!(find(&ctx, "list.row.1").and_then(|e| e.selected), Some(true));
assert!(find(&ctx, "hidden").is_none());
let p = click_point(&ctx, "toolbar.save").unwrap();
assert!(Rect::from_min_size(Pos2::ZERO, egui::vec2(400.0, 300.0)).contains(p));
rows = 1;
frame(rows);
assert_eq!(inspect(&ctx).elements.len(), 2, "slots from the larger pass are not reported");
let json = serde_json::to_value(inspect(&ctx)).unwrap();
assert_eq!(json["elements"][0]["id"], "toolbar.save");
assert_eq!(json["elements"][0]["role"], "button");
}
#[test]
fn disabled_recording_and_empty_context_are_harmless() {
let ctx = egui::Context::default();
assert!(inspect(&ctx).elements.is_empty());
set_enabled(&ctx, false);
run(&ctx, input(), |ui| {
let r = ui.button("x");
register(ui, &r, "x", "x", Role::Button);
});
assert!(find(&ctx, "x").is_none());
set_enabled(&ctx, true);
run(&ctx, input(), |ui| {
let r = ui.button("x");
register(ui, &r, "x", "x", Role::Button);
});
assert!(find(&ctx, "x").is_some());
}
}