use std::{borrow::Cow, fmt};
use egui::{Rect as EguiRect, Vec2 as EguiVec2};
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{
Deserialize, Serialize,
de::{self, Deserializer},
ser::Serializer,
};
use crate::registry::viewport_id_to_string;
fn sanitize_f32(value: f32) -> f32 {
if value.is_finite() { value } else { 0.0 }
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Pos2 {
pub x: f32,
pub y: f32,
}
impl From<egui::Pos2> for Pos2 {
fn from(pos: egui::Pos2) -> Self {
Self {
x: sanitize_f32(pos.x),
y: sanitize_f32(pos.y),
}
}
}
impl From<Pos2> for egui::Pos2 {
fn from(pos: Pos2) -> Self {
egui::pos2(pos.x, pos.y)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Vec2 {
pub x: f32,
pub y: f32,
}
impl From<EguiVec2> for Vec2 {
fn from(vec: EguiVec2) -> Self {
Self {
x: sanitize_f32(vec.x),
y: sanitize_f32(vec.y),
}
}
}
impl From<Vec2> for EguiVec2 {
fn from(vec: Vec2) -> Self {
egui::vec2(vec.x, vec.y)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Rect {
pub min: Pos2,
pub max: Pos2,
}
impl Rect {
pub fn center(self) -> Pos2 {
Pos2 {
x: (self.min.x + self.max.x) * 0.5,
y: (self.min.y + self.max.y) * 0.5,
}
}
}
impl From<EguiRect> for Rect {
fn from(rect: EguiRect) -> Self {
Self {
min: Pos2::from(rect.min),
max: Pos2::from(rect.max),
}
}
}
impl From<Rect> for EguiRect {
fn from(rect: Rect) -> Self {
Self::from_min_max(rect.min.into(), rect.max.into())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct Modifiers {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
pub command: bool,
}
impl From<egui::Modifiers> for Modifiers {
fn from(modifiers: egui::Modifiers) -> Self {
Self {
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
command: modifiers.command,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FixtureSpec {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<Anchor>,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Anchor {
pub widget_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub viewport_id: Option<String>,
pub check: AnchorCheck,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub enum AnchorCheck {
Visible,
Label(String),
Value(WidgetValue),
ScrollReady,
ScrollAt {
offset: Vec2,
tolerance: f32,
},
}
impl FixtureSpec {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
anchors: Vec::new(),
}
}
pub fn anchor(self, widget_id: impl Into<String>) -> Self {
self.push_anchor(widget_id.into(), None, AnchorCheck::Visible)
}
pub fn anchor_in(self, widget_id: impl Into<String>, viewport: egui::ViewportId) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport_id_to_string(viewport)),
AnchorCheck::Visible,
)
}
pub fn anchor_label(self, widget_id: impl Into<String>, text: impl Into<String>) -> Self {
self.push_anchor(widget_id.into(), None, AnchorCheck::Label(text.into()))
}
pub fn anchor_label_in(
self,
widget_id: impl Into<String>,
text: impl Into<String>,
viewport: egui::ViewportId,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport_id_to_string(viewport)),
AnchorCheck::Label(text.into()),
)
}
pub fn anchor_value(self, widget_id: impl Into<String>, value: WidgetValue) -> Self {
self.push_anchor(widget_id.into(), None, AnchorCheck::Value(value))
}
pub fn anchor_value_in(
self,
widget_id: impl Into<String>,
value: WidgetValue,
viewport: egui::ViewportId,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport_id_to_string(viewport)),
AnchorCheck::Value(value),
)
}
pub fn anchor_scroll(self, widget_id: impl Into<String>) -> Self {
self.push_anchor(widget_id.into(), None, AnchorCheck::ScrollReady)
}
pub fn anchor_scroll_in(
self,
widget_id: impl Into<String>,
viewport: egui::ViewportId,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport_id_to_string(viewport)),
AnchorCheck::ScrollReady,
)
}
pub fn anchor_scroll_at(
self,
widget_id: impl Into<String>,
offset: impl Into<Vec2>,
tolerance: f32,
) -> Self {
self.push_anchor(
widget_id.into(),
None,
AnchorCheck::ScrollAt {
offset: offset.into(),
tolerance,
},
)
}
pub fn anchor_scroll_at_in(
self,
widget_id: impl Into<String>,
offset: impl Into<Vec2>,
tolerance: f32,
viewport: egui::ViewportId,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport_id_to_string(viewport)),
AnchorCheck::ScrollAt {
offset: offset.into(),
tolerance,
},
)
}
pub fn validate(&self, require_anchors: bool) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("fixture name must not be empty".to_string());
}
for (index, anchor) in self.anchors.iter().enumerate() {
anchor
.validate()
.map_err(|error| format!("fixture {} anchor {}: {error}", self.name, index + 1))?;
}
if require_anchors && self.anchors.is_empty() {
return Err(format!(
"fixture {} must declare at least one readiness anchor",
self.name
));
}
Ok(())
}
pub fn describe_readiness(&self) -> String {
if self.anchors.is_empty() {
return "No readiness anchors declared.".to_string();
}
self.anchors
.iter()
.map(Anchor::describe)
.collect::<Vec<_>>()
.join("; ")
}
fn push_anchor(
mut self,
widget_id: String,
viewport_id: Option<String>,
check: AnchorCheck,
) -> Self {
self.anchors.push(Anchor {
widget_id,
viewport_id,
check,
});
self
}
}
impl Anchor {
pub fn describe(&self) -> String {
let target = match &self.viewport_id {
Some(viewport_id) => format!("{} in {}", self.widget_id, viewport_id),
None => self.widget_id.clone(),
};
format!("{target} {}", self.check)
}
pub fn validate(&self) -> Result<(), String> {
if self.widget_id.trim().is_empty() {
return Err("widget_id must not be empty".to_string());
}
if let Some(viewport_id) = &self.viewport_id
&& viewport_id.trim().is_empty()
{
return Err("viewport_id must not be empty when provided".to_string());
}
match &self.check {
AnchorCheck::Label(text) if text.is_empty() => {
Err("label anchors must not be empty".to_string())
}
AnchorCheck::ScrollAt { tolerance, .. }
if !tolerance.is_finite() || *tolerance <= 0.0 =>
{
Err("scroll_at tolerance must be finite and greater than 0".to_string())
}
_ => Ok(()),
}
}
}
impl fmt::Display for AnchorCheck {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Visible => f.write_str("visible"),
Self::Label(text) => write!(f, "label == \"{text}\""),
Self::Value(value) => match value {
WidgetValue::Text(text) => write!(f, "value == \"{text}\""),
_ => write!(f, "value == {}", value.to_text()),
},
Self::ScrollReady => f.write_str("scroll_ready"),
Self::ScrollAt { offset, tolerance } => write!(
f,
"scroll_at ({:.1}, {:.1}) ± {:.2}",
offset.x, offset.y, tolerance
),
}
}
}
impl From<Modifiers> for egui::Modifiers {
fn from(modifiers: Modifiers) -> Self {
Self {
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
command: modifiers.command,
mac_cmd: modifiers.command,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WidgetRef {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub viewport_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[allow(missing_docs)]
pub enum WidgetRole {
Button,
Link,
Image,
Label,
TextEdit,
Slider,
Checkbox,
ComboBox,
Radio,
DragValue,
Toggle,
Selectable,
Separator,
Spinner,
ScrollArea,
MenuButton,
CollapsingHeader,
Window,
ProgressBar,
ColorPicker,
#[default]
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub enum WidgetValue {
Bool(bool),
Float(f64),
Int(i64),
Text(String),
}
impl WidgetValue {
pub fn to_text(&self) -> String {
match self {
Self::Bool(v) => v.to_string(),
Self::Float(v) => v.to_string(),
Self::Int(v) => v.to_string(),
Self::Text(v) => v.clone(),
}
}
}
impl<'de> Deserialize<'de> for WidgetValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
widget_value_from_json(value).map_err(de::Error::custom)
}
}
impl Serialize for WidgetValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Bool(value) => serializer.serialize_bool(*value),
Self::Float(value) => serializer.serialize_f64(*value),
Self::Int(value) => serializer.serialize_i64(*value),
Self::Text(value) => serializer.serialize_str(value),
}
}
}
#[doc(hidden)]
impl JsonSchema for WidgetRole {
fn schema_name() -> Cow<'static, str> {
"WidgetRole".into()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
schemars::json_schema!({
"type": "string",
"enum": [
"button",
"link",
"image",
"label",
"text_edit",
"slider",
"checkbox",
"combo_box",
"radio",
"drag_value",
"toggle",
"selectable",
"separator",
"spinner",
"scroll_area",
"menu_button",
"collapsing_header",
"window",
"progress_bar",
"color_picker",
"unknown"
]
})
}
}
#[doc(hidden)]
impl JsonSchema for WidgetValue {
fn schema_name() -> Cow<'static, str> {
"WidgetValue".into()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
schemars::json_schema!({
"oneOf": [
{ "type": "boolean" },
{ "type": "integer" },
{ "type": "number" },
{ "type": "string" }
]
})
}
}
fn widget_value_from_json(value: serde_json::Value) -> Result<WidgetValue, String> {
match value {
serde_json::Value::Object(map) => {
if map.len() != 1 {
return Err("WidgetValue must include exactly one field".to_string());
}
let (key, value) = map.into_iter().next().expect("map entry");
match key.as_str() {
"bool" => match value {
serde_json::Value::Bool(value) => Ok(WidgetValue::Bool(value)),
_ => Err("WidgetValue bool must be a boolean".to_string()),
},
"float" => match value {
serde_json::Value::Number(number) => number
.as_f64()
.map(WidgetValue::Float)
.ok_or_else(|| "WidgetValue float must be a number".to_string()),
_ => Err("WidgetValue float must be a number".to_string()),
},
"int" => match value {
serde_json::Value::Number(number) => number
.as_i64()
.or_else(|| number.as_u64().and_then(|value| i64::try_from(value).ok()))
.map(WidgetValue::Int)
.ok_or_else(|| "WidgetValue int must be an integer".to_string()),
_ => Err("WidgetValue int must be an integer".to_string()),
},
"text" => match value {
serde_json::Value::String(value) => Ok(WidgetValue::Text(value)),
_ => Err("WidgetValue text must be a string".to_string()),
},
_ => Err("WidgetValue field must be one of bool, float, int, text".to_string()),
}
}
serde_json::Value::Bool(value) => Ok(WidgetValue::Bool(value)),
serde_json::Value::Number(number) => {
if let Some(value) = number.as_i64() {
Ok(WidgetValue::Int(value))
} else if let Some(value) = number.as_u64() {
i64::try_from(value)
.map(WidgetValue::Int)
.map_err(|_| "WidgetValue int is out of range".to_string())
} else if let Some(value) = number.as_f64() {
Ok(WidgetValue::Float(value))
} else {
Err("WidgetValue number must be int or float".to_string())
}
}
serde_json::Value::String(value) => {
let trimmed = value.trim();
if trimmed.starts_with('{')
&& let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed)
{
return widget_value_from_json(parsed);
}
Ok(WidgetValue::Text(value))
}
_ => Err("WidgetValue must be bool, number, string, or tagged object".to_string()),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WidgetLayout {
pub desired_size: Vec2,
pub actual_size: Vec2,
pub clip_rect: Rect,
pub clipped: bool,
pub overflow: bool,
pub available_rect: Rect,
pub visible_fraction: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ScrollAreaMeta {
pub offset: Vec2,
pub viewport_size: Vec2,
pub content_size: Vec2,
pub max_offset: Vec2,
}
impl ScrollAreaMeta {
pub fn new(offset: Vec2, viewport_size: Vec2, content_size: Vec2) -> Self {
Self {
offset,
viewport_size,
content_size,
max_offset: Vec2 {
x: (content_size.x - viewport_size.x).max(0.0),
y: (content_size.y - viewport_size.y).max(0.0),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WidgetRange {
pub min: f64,
pub max: f64,
}
impl WidgetRange {
pub fn contains(self, value: f64) -> bool {
self.min <= value && value <= self.max
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RoleState {
ScrollArea {
offset: Vec2,
viewport_size: Vec2,
content_size: Vec2,
},
Slider {
range: WidgetRange,
},
DragValue {
range: Option<WidgetRange>,
},
ComboBox {
options: Vec<String>,
},
Button {
selected: bool,
},
Checkbox {
indeterminate: bool,
},
TextEdit {
multiline: bool,
password: bool,
},
}
impl RoleState {
pub fn scroll_state(&self) -> Option<ScrollAreaMeta> {
match self {
Self::ScrollArea {
offset,
viewport_size,
content_size,
} => Some(ScrollAreaMeta::new(*offset, *viewport_size, *content_size)),
_ => None,
}
}
pub fn range(&self) -> Option<WidgetRange> {
match self {
Self::Slider { range } => Some(*range),
Self::DragValue { range } => *range,
_ => None,
}
}
pub fn options(&self) -> Option<&[String]> {
match self {
Self::ComboBox { options } => Some(options),
_ => None,
}
}
pub fn selected(&self) -> Option<bool> {
match self {
Self::Button { selected } => Some(*selected),
_ => None,
}
}
pub fn indeterminate(&self) -> Option<bool> {
match self {
Self::Checkbox { indeterminate } => Some(*indeterminate),
_ => None,
}
}
pub fn text_edit(&self) -> Option<(bool, bool)> {
match self {
Self::TextEdit {
multiline,
password,
} => Some((*multiline, *password)),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{RoleState, Vec2, WidgetValue};
#[test]
fn widget_value_deserializes_tagged_object() {
let value: WidgetValue = serde_json::from_value(serde_json::json!({"bool": false}))
.expect("deserialize tagged bool");
assert_eq!(value, WidgetValue::Bool(false));
}
#[test]
fn widget_value_deserializes_stringified_object() {
let value: WidgetValue = serde_json::from_value(serde_json::json!("{\"bool\": false}"))
.expect("deserialize stringified bool");
assert_eq!(value, WidgetValue::Bool(false));
}
#[test]
fn widget_value_deserializes_plain_text() {
let value: WidgetValue =
serde_json::from_value(serde_json::json!("hello")).expect("deserialize text");
assert_eq!(value, WidgetValue::Text("hello".to_string()));
}
#[test]
fn widget_value_serialization() {
let v = WidgetValue::Bool(true);
assert_eq!(serde_json::to_string(&v).unwrap(), "true");
}
#[test]
fn scroll_area_meta_computes_max_offset() {
let scroll = RoleState::ScrollArea {
offset: Vec2 { x: 2.0, y: 3.0 },
viewport_size: Vec2 { x: 100.0, y: 40.0 },
content_size: Vec2 { x: 180.0, y: 150.0 },
}
.scroll_state()
.expect("scroll metadata");
assert_eq!(scroll.max_offset.x, 80.0);
assert_eq!(scroll.max_offset.y, 110.0);
}
#[test]
fn scroll_area_meta_clamps_negative_max_offset() {
let scroll = RoleState::ScrollArea {
offset: Vec2 { x: 0.0, y: 0.0 },
viewport_size: Vec2 { x: 100.0, y: 40.0 },
content_size: Vec2 { x: 80.0, y: 20.0 },
}
.scroll_state()
.expect("scroll metadata");
assert_eq!(scroll.max_offset.x, 0.0);
assert_eq!(scroll.max_offset.y, 0.0);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WidgetRegistryEntry {
pub id: String,
#[serde(skip_serializing, skip_deserializing)]
#[schemars(skip)]
pub explicit_id: bool,
#[serde(skip_serializing, skip_deserializing)]
#[schemars(skip)]
pub native_id: u64,
pub viewport_id: String,
#[serde(skip_serializing, skip_deserializing)]
#[schemars(skip)]
pub layer_id: String,
pub rect: Rect,
pub interact_rect: Rect,
pub role: WidgetRole,
pub label: Option<String>,
pub value: Option<WidgetValue>,
pub layout: Option<WidgetLayout>,
#[serde(default)]
pub role_state: Option<RoleState>,
pub parent_id: Option<String>,
pub enabled: bool,
pub visible: bool,
pub focused: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WidgetState {
pub rect: Rect,
pub interact_rect: Rect,
pub role: WidgetRole,
pub label: Option<String>,
pub value: Option<WidgetValue>,
pub value_text: String,
pub layout: Option<WidgetLayout>,
#[serde(rename = "scroll_state")]
pub scroll: Option<ScrollAreaMeta>,
pub range: Option<WidgetRange>,
pub options: Option<Vec<String>>,
pub selected: Option<bool>,
pub indeterminate: Option<bool>,
pub multiline: Option<bool>,
pub password: Option<bool>,
pub enabled: bool,
pub visible: bool,
pub focused: bool,
}
impl From<&WidgetRegistryEntry> for WidgetState {
fn from(entry: &WidgetRegistryEntry) -> Self {
let value_text = entry
.value
.as_ref()
.map(|v| v.to_text())
.unwrap_or_default();
let scroll = entry.role_state.as_ref().and_then(RoleState::scroll_state);
let range = entry.role_state.as_ref().and_then(RoleState::range);
let options = entry
.role_state
.as_ref()
.and_then(RoleState::options)
.map(<[String]>::to_vec);
let selected = entry.role_state.as_ref().and_then(RoleState::selected);
let indeterminate = entry.role_state.as_ref().and_then(RoleState::indeterminate);
let (multiline, password) = entry
.role_state
.as_ref()
.and_then(RoleState::text_edit)
.map_or((None, None), |(multiline, password)| {
(Some(multiline), Some(password))
});
Self {
rect: entry.rect,
interact_rect: entry.interact_rect,
role: entry.role.clone(),
label: entry.label.clone(),
value: entry.value.clone(),
value_text,
layout: entry.layout.clone(),
scroll,
range,
options,
selected,
indeterminate,
multiline,
password,
enabled: entry.enabled,
visible: entry.visible,
focused: entry.focused,
}
}
}