use std::{
any::Any,
borrow::Cow,
collections::{BTreeMap, BTreeSet},
error::Error,
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;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ViewportNameError {
pub code: String,
pub message: String,
}
impl ViewportNameError {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl fmt::Display for ViewportNameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for ViewportNameError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ViewportSelParseError {
pub code: String,
pub message: String,
}
impl ViewportSelParseError {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl fmt::Display for ViewportSelParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for ViewportSelParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewportSel {
kind: ViewportSelKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ViewportSelKind {
Root,
Id(egui::ViewportId),
RawId(u64),
Name(String),
}
impl ViewportSel {
pub fn root() -> Self {
Self {
kind: ViewportSelKind::Root,
}
}
pub fn id(id: egui::ViewportId) -> Self {
Self {
kind: ViewportSelKind::Id(id),
}
}
pub fn name(name: impl Into<String>) -> Result<Self, ViewportNameError> {
let name = name.into();
validate_viewport_name(&name)?;
Ok(Self {
kind: ViewportSelKind::Name(name),
})
}
pub fn parse(selector: impl AsRef<str>) -> Result<Self, ViewportSelParseError> {
let selector = selector.as_ref();
if selector.trim().is_empty() {
return Err(ViewportSelParseError::new(
"empty_viewport_selector",
"viewport selector must not be empty",
));
}
if selector == "root" {
return Ok(Self::root());
}
if let Some(raw) = selector.strip_prefix("vp:") {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(ViewportSelParseError::new(
"invalid_viewport_id",
format!("viewport id selector `{selector}` must be `vp:<hex>`"),
));
}
let raw_id = u64::from_str_radix(raw, 16).map_err(|_| {
ViewportSelParseError::new(
"invalid_viewport_id",
format!("viewport id selector `{selector}` must be `vp:<hex>`"),
)
})?;
return Ok(Self {
kind: ViewportSelKind::RawId(raw_id),
});
}
validate_viewport_name(selector).map_err(|error| {
ViewportSelParseError::new(
error.code,
format!("invalid viewport name: {}", error.message),
)
})?;
Ok(Self {
kind: ViewportSelKind::Name(selector.to_string()),
})
}
pub fn to_selector_string(&self) -> String {
match &self.kind {
ViewportSelKind::Root => "root".to_string(),
ViewportSelKind::Id(id) => viewport_id_to_string(*id),
ViewportSelKind::RawId(raw_id) => format!("vp:{raw_id:x}"),
ViewportSelKind::Name(name) => name.clone(),
}
}
}
impl From<egui::ViewportId> for ViewportSel {
fn from(value: egui::ViewportId) -> Self {
Self::id(value)
}
}
pub fn validate_viewport_name(name: &str) -> Result<(), ViewportNameError> {
if name.trim().is_empty() {
return Err(ViewportNameError::new(
"empty_viewport_name",
"viewport name must not be empty",
));
}
if name == "root" || name.starts_with("vp:") {
return Err(ViewportNameError::new(
"reserved_viewport_name",
format!("viewport name `{name}` is reserved"),
));
}
Ok(())
}
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 preconditions: Vec<Anchor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<Anchor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<FixtureParam>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, 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, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub enum AnchorCheck {
Visible,
Label(String),
Value(WidgetValue),
ScrollReady,
ScrollAt {
offset: Vec2,
tolerance: f32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ParamKind {
Bool,
Int,
Float,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FixtureParam {
pub name: String,
pub kind: ParamKind,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<WidgetValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<WidgetValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FixtureParams(BTreeMap<String, WidgetValue>);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FixtureCall {
pub name: String,
pub params: FixtureParams,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FixtureResponse {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub values: BTreeMap<String, WidgetValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<Anchor>,
}
pub type FixtureResult = Result<FixtureResponse, FixtureError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FixtureError {
pub code: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl FixtureSpec {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
preconditions: Vec::new(),
anchors: Vec::new(),
params: Vec::new(),
tags: Vec::new(),
}
}
pub fn precondition(self, widget_id: impl Into<String>) -> Self {
self.push_precondition(widget_id.into(), None, AnchorCheck::Visible)
}
pub fn precondition_in(
self,
widget_id: impl Into<String>,
viewport: impl Into<ViewportSel>,
) -> Self {
self.push_precondition(
widget_id.into(),
Some(viewport.into().to_selector_string()),
AnchorCheck::Visible,
)
}
pub fn precondition_value(self, widget_id: impl Into<String>, value: WidgetValue) -> Self {
self.push_precondition(widget_id.into(), None, AnchorCheck::Value(value))
}
pub fn precondition_value_in(
self,
widget_id: impl Into<String>,
value: WidgetValue,
viewport: impl Into<ViewportSel>,
) -> Self {
self.push_precondition(
widget_id.into(),
Some(viewport.into().to_selector_string()),
AnchorCheck::Value(value),
)
}
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: impl Into<ViewportSel>) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport.into().to_selector_string()),
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: impl Into<ViewportSel>,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport.into().to_selector_string()),
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: impl Into<ViewportSel>,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport.into().to_selector_string()),
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: impl Into<ViewportSel>,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport.into().to_selector_string()),
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: impl Into<ViewportSel>,
) -> Self {
self.push_anchor(
widget_id.into(),
Some(viewport.into().to_selector_string()),
AnchorCheck::ScrollAt {
offset: offset.into(),
tolerance,
},
)
}
pub fn param(mut self, param: FixtureParam) -> Self {
self.params.push(param);
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
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());
}
if self.description.trim().is_empty() {
return Err(format!(
"fixture {} description must not be empty",
self.name
));
}
let mut param_names = BTreeSet::new();
for (index, param) in self.params.iter().enumerate() {
param
.validate()
.map_err(|error| format!("fixture {} param {}: {error}", self.name, index + 1))?;
if !param_names.insert(param.name.as_str()) {
return Err(format!(
"fixture {} duplicate param name: {}",
self.name, param.name
));
}
}
let mut tags = BTreeSet::new();
for tag in &self.tags {
if tag.trim().is_empty() {
return Err(format!("fixture {} tag must not be empty", self.name));
}
if !tags.insert(tag.as_str()) {
return Err(format!("fixture {} duplicate tag: {tag}", self.name));
}
}
for (index, anchor) in self.preconditions.iter().enumerate() {
anchor.validate().map_err(|error| {
format!("fixture {} precondition {}: {error}", self.name, index + 1)
})?;
}
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 validate_params(
&self,
mut supplied: BTreeMap<String, WidgetValue>,
) -> Result<FixtureParams, FixtureError> {
let mut values = BTreeMap::new();
if let Some(name) = supplied
.keys()
.find(|name| !self.params.iter().any(|param| param.name == **name))
.cloned()
{
return Err(FixtureError::new(
"unknown_param",
format!("unknown param {name:?} for fixture {}", self.name),
)
.details(serde_json::json!({
"fixture": self.name,
"param": name,
"allowed": self.params.iter().map(|param| param.name.as_str()).collect::<Vec<_>>(),
})));
}
for param in &self.params {
let value = match supplied.remove(¶m.name) {
Some(value) => value,
None => match ¶m.default {
Some(value) => value.clone(),
None => {
return Err(FixtureError::new(
"missing_param",
format!(
"missing required param {:?} for fixture {}",
param.name, self.name
),
)
.details(serde_json::json!({
"fixture": self.name,
"param": param.name,
"kind": param.kind.as_str(),
})));
}
},
};
let value = param.normalize_value(value)?;
values.insert(param.name.clone(), value);
}
Ok(FixtureParams(values))
}
pub fn describe_readiness(&self) -> String {
let preconditions = self
.preconditions
.iter()
.map(Anchor::describe)
.collect::<Vec<_>>();
let anchors = self
.anchors
.iter()
.map(Anchor::describe)
.collect::<Vec<_>>();
match (preconditions.is_empty(), anchors.is_empty()) {
(true, true) => "No readiness anchors declared.".to_string(),
(true, false) => anchors.join("; "),
(false, true) => format!("preconditions: {}", preconditions.join("; ")),
(false, false) => format!(
"preconditions: {}; anchors: {}",
preconditions.join("; "),
anchors.join("; ")
),
}
}
fn push_precondition(
mut self,
widget_id: String,
viewport_id: Option<String>,
check: AnchorCheck,
) -> Self {
self.preconditions.push(Anchor {
widget_id,
viewport_id,
check,
});
self
}
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 FixtureParam {
pub fn bool(name: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(name, ParamKind::Bool, description)
}
pub fn int(name: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(name, ParamKind::Int, description)
}
pub fn float(name: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(name, ParamKind::Float, description)
}
pub fn text(name: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(name, ParamKind::Text, description)
}
fn new(name: impl Into<String>, kind: ParamKind, description: impl Into<String>) -> Self {
Self {
name: name.into(),
kind,
description: description.into(),
default: None,
choices: Vec::new(),
min: None,
max: None,
}
}
pub fn default(mut self, value: impl Into<WidgetValue>) -> Self {
self.default = Some(self.normalize_literal(value.into()));
self
}
pub fn choices<I, V>(mut self, values: I) -> Self
where
I: IntoIterator<Item = V>,
V: Into<WidgetValue>,
{
self.choices = values
.into_iter()
.map(Into::into)
.map(|value| self.normalize_literal(value))
.collect();
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("name must not be empty".to_string());
}
if self.description.trim().is_empty() {
return Err(format!("param {} description must not be empty", self.name));
}
if (self.min.is_some() || self.max.is_some())
&& !matches!(self.kind, ParamKind::Int | ParamKind::Float)
{
return Err(format!(
"param {} has a range but is not numeric",
self.name
));
}
if let (Some(min), Some(max)) = (self.min, self.max) {
if !min.is_finite() || !max.is_finite() {
return Err(format!("param {} range must be finite", self.name));
}
if min > max {
return Err(format!("param {} range min exceeds max", self.name));
}
}
if let Some(default) = &self.default {
self.normalize_value(default.clone())
.map_err(|error| error.message)?;
}
for choice in &self.choices {
self.normalize_value(choice.clone())
.map_err(|error| error.message)?;
}
Ok(())
}
fn normalize_literal(&self, value: WidgetValue) -> WidgetValue {
match (self.kind, value) {
(ParamKind::Float, WidgetValue::Int(value)) => WidgetValue::Float(value as f64),
(_, value) => value,
}
}
fn validate_value(&self, value: &WidgetValue) -> Result<(), FixtureError> {
if !self.kind.matches(value) {
return Err(FixtureError::new(
"invalid_param_type",
format!(
"param {:?} expected {}, got {}",
self.name,
self.kind.as_str(),
value.kind_name()
),
)
.details(serde_json::json!({
"param": self.name,
"expected": self.kind.as_str(),
"actual": value.kind_name(),
})));
}
if !self.choices.is_empty() && !self.choices.iter().any(|choice| choice == value) {
return Err(FixtureError::new(
"invalid_param_choice",
format!(
"param {:?} value is not one of its allowed choices",
self.name
),
)
.details(serde_json::json!({
"param": self.name,
"value": value,
"choices": self.choices,
})));
}
if let Some(number) = value.as_f64() {
if let Some(min) = self.min
&& number < min
{
return Err(FixtureError::new(
"param_below_min",
format!("param {:?} must be >= {min}", self.name),
)
.details(serde_json::json!({
"param": self.name,
"value": value,
"min": min,
})));
}
if let Some(max) = self.max
&& number > max
{
return Err(FixtureError::new(
"param_above_max",
format!("param {:?} must be <= {max}", self.name),
)
.details(serde_json::json!({
"param": self.name,
"value": value,
"max": max,
})));
}
}
Ok(())
}
fn normalize_value(&self, value: WidgetValue) -> Result<WidgetValue, FixtureError> {
let value = self.normalize_literal(value);
self.validate_value(&value)?;
Ok(value)
}
}
impl ParamKind {
fn as_str(self) -> &'static str {
match self {
Self::Bool => "bool",
Self::Int => "int",
Self::Float => "float",
Self::Text => "text",
}
}
fn matches(self, value: &WidgetValue) -> bool {
matches!(
(self, value),
(Self::Bool, WidgetValue::Bool(_))
| (Self::Int, WidgetValue::Int(_))
| (Self::Float, WidgetValue::Float(_))
| (Self::Text, WidgetValue::Text(_))
)
}
}
impl FixtureParams {
pub fn bool(&self, name: &str) -> bool {
match self.0.get(name) {
Some(WidgetValue::Bool(value)) => *value,
Some(_) => panic!("fixture param {name:?} is not a bool"),
None => panic!("fixture param {name:?} is not declared"),
}
}
pub fn int(&self, name: &str) -> i64 {
match self.0.get(name) {
Some(WidgetValue::Int(value)) => *value,
Some(_) => panic!("fixture param {name:?} is not an int"),
None => panic!("fixture param {name:?} is not declared"),
}
}
pub fn float(&self, name: &str) -> f64 {
match self.0.get(name) {
Some(WidgetValue::Float(value)) => *value,
Some(_) => panic!("fixture param {name:?} is not a float"),
None => panic!("fixture param {name:?} is not declared"),
}
}
pub fn text(&self, name: &str) -> &str {
match self.0.get(name) {
Some(WidgetValue::Text(value)) => value,
Some(_) => panic!("fixture param {name:?} is not text"),
None => panic!("fixture param {name:?} is not declared"),
}
}
pub fn get(&self, name: &str) -> Option<&WidgetValue> {
self.0.get(name)
}
pub fn as_map(&self) -> &BTreeMap<String, WidgetValue> {
&self.0
}
pub fn into_map(self) -> BTreeMap<String, WidgetValue> {
self.0
}
}
impl FixtureResponse {
pub fn new() -> Self {
Self::default()
}
pub fn value(mut self, name: impl Into<String>, value: impl Into<WidgetValue>) -> Self {
self.values.insert(name.into(), value.into());
self
}
pub fn anchor(mut self, anchor: Anchor) -> Self {
self.anchors.push(anchor);
self
}
}
impl FixtureError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: None,
}
}
pub fn details<T: serde::Serialize>(mut self, details: T) -> Self {
self.details = serde_json::to_value(details).ok();
self
}
pub(crate) fn handler_panic(name: &str, panic: &(dyn Any + Send)) -> Self {
Self::new(
"panic",
format!(
"fixture handler {name:?} panicked: {}",
panic_message(panic)
),
)
}
}
fn panic_message(panic: &(dyn Any + Send)) -> String {
if let Some(message) = panic.downcast_ref::<&'static str>() {
(*message).to_string()
} else if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else {
"unknown panic payload".to_string()
}
}
impl Anchor {
pub fn visible(widget_id: impl Into<String>) -> Self {
Self {
widget_id: widget_id.into(),
viewport_id: None,
check: AnchorCheck::Visible,
}
}
pub fn label(widget_id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
widget_id: widget_id.into(),
viewport_id: None,
check: AnchorCheck::Label(label.into()),
}
}
pub fn value(widget_id: impl Into<String>, value: impl Into<WidgetValue>) -> Self {
Self {
widget_id: widget_id.into(),
viewport_id: None,
check: AnchorCheck::Value(value.into()),
}
}
pub fn scroll_ready(widget_id: impl Into<String>) -> Self {
Self {
widget_id: widget_id.into(),
viewport_id: None,
check: AnchorCheck::ScrollReady,
}
}
pub fn scroll_at(
widget_id: impl Into<String>,
offset: impl Into<Vec2>,
tolerance: f32,
) -> Self {
Self {
widget_id: widget_id.into(),
viewport_id: None,
check: AnchorCheck::ScrollAt {
offset: offset.into(),
tolerance,
},
}
}
pub fn in_viewport(mut self, viewport: impl Into<ViewportSel>) -> Self {
self.viewport_id = Some(viewport.into().to_selector_string());
self
}
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(),
}
}
fn kind_name(&self) -> &'static str {
match self {
Self::Bool(_) => "bool",
Self::Float(_) => "float",
Self::Int(_) => "int",
Self::Text(_) => "text",
}
}
fn as_f64(&self) -> Option<f64> {
match self {
Self::Float(value) => Some(*value),
Self::Int(value) => Some(*value as f64),
Self::Bool(_) | Self::Text(_) => None,
}
}
}
impl From<bool> for WidgetValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<i64> for WidgetValue {
fn from(value: i64) -> Self {
Self::Int(value)
}
}
impl From<f64> for WidgetValue {
fn from(value: f64) -> Self {
Self::Float(value)
}
}
impl From<String> for WidgetValue {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for WidgetValue {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
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 std::collections::BTreeMap;
use super::{
FixtureParam, FixtureSpec, RoleState, Vec2, ViewportSel, WidgetValue,
validate_viewport_name,
};
fn fixture_param_map<const N: usize>(
entries: [(&str, WidgetValue); N],
) -> BTreeMap<String, WidgetValue> {
entries
.into_iter()
.map(|(name, value)| (name.to_string(), value))
.collect()
}
fn param_spec() -> FixtureSpec {
FixtureSpec::new("param.demo", "Parameterized fixture")
.param(
FixtureParam::text("mode", "Mode to apply.")
.default("fast")
.choices(["fast", "slow"]),
)
.param(FixtureParam::float("offset", "Offset in points.").range(0.0, 10.0))
.param(FixtureParam::int("count", "Item count."))
}
#[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 fixture_params_validate_defaults_and_int_to_float() {
let params = param_spec()
.validate_params(fixture_param_map([
("offset", WidgetValue::Int(3)),
("count", WidgetValue::Int(2)),
]))
.expect("valid params");
assert_eq!(params.text("mode"), "fast");
assert_eq!(params.float("offset"), 3.0);
assert_eq!(params.int("count"), 2);
assert_eq!(
params.as_map().get("offset"),
Some(&WidgetValue::Float(3.0))
);
}
#[test]
fn fixture_float_param_choices_normalize_int_literals() {
let spec = FixtureSpec::new("float.choice", "Float choice fixture").param(
FixtureParam::float("scale", "Scale factor.")
.default(1_i64)
.choices([1_i64, 2_i64]),
);
let defaults = spec
.validate_params(BTreeMap::new())
.expect("default params");
assert_eq!(defaults.float("scale"), 1.0);
let explicit = spec
.validate_params(fixture_param_map([("scale", WidgetValue::Int(2))]))
.expect("explicit params");
assert_eq!(explicit.float("scale"), 2.0);
}
#[test]
fn fixture_params_reject_unknown_and_missing_params() {
let unknown = param_spec()
.validate_params(fixture_param_map([
("offset", WidgetValue::Float(3.0)),
("count", WidgetValue::Int(2)),
("extra", WidgetValue::Bool(true)),
]))
.expect_err("unknown param rejected");
assert_eq!(unknown.code, "unknown_param");
let missing = param_spec()
.validate_params(fixture_param_map([(
"mode",
WidgetValue::Text("fast".to_string()),
)]))
.expect_err("missing param rejected");
assert_eq!(missing.code, "missing_param");
}
#[test]
fn fixture_params_reject_type_choice_and_range_errors() {
let wrong_type = param_spec()
.validate_params(fixture_param_map([
("mode", WidgetValue::Text("fast".to_string())),
("offset", WidgetValue::Text("three".to_string())),
("count", WidgetValue::Int(2)),
]))
.expect_err("type rejected");
assert_eq!(wrong_type.code, "invalid_param_type");
let bad_choice = param_spec()
.validate_params(fixture_param_map([
("mode", WidgetValue::Text("medium".to_string())),
("offset", WidgetValue::Float(3.0)),
("count", WidgetValue::Int(2)),
]))
.expect_err("choice rejected");
assert_eq!(bad_choice.code, "invalid_param_choice");
let below_min = param_spec()
.validate_params(fixture_param_map([
("mode", WidgetValue::Text("fast".to_string())),
("offset", WidgetValue::Float(-1.0)),
("count", WidgetValue::Int(2)),
]))
.expect_err("range min rejected");
assert_eq!(below_min.code, "param_below_min");
let above_max = param_spec()
.validate_params(fixture_param_map([
("mode", WidgetValue::Text("fast".to_string())),
("offset", WidgetValue::Float(11.0)),
("count", WidgetValue::Int(2)),
]))
.expect_err("range max rejected");
assert_eq!(above_max.code, "param_above_max");
}
#[test]
fn viewport_selector_parses_canonical_strings() {
assert_eq!(
ViewportSel::parse("root").unwrap().to_selector_string(),
"root"
);
assert_eq!(
ViewportSel::parse("details").unwrap().to_selector_string(),
"details"
);
assert_eq!(
ViewportSel::parse("vp:AB").unwrap().to_selector_string(),
"vp:ab"
);
assert_eq!(
ViewportSel::parse("vp:00ff").unwrap().to_selector_string(),
"vp:ff"
);
}
#[test]
fn viewport_selector_rejects_invalid_raw_ids() {
for selector in ["vp:", "vp:+ff", "vp:zz"] {
assert!(
ViewportSel::parse(selector).is_err(),
"{selector} should be rejected"
);
}
}
#[test]
fn viewport_name_validation_rejects_empty_and_reserved_names() {
for name in ["", " "] {
let error = validate_viewport_name(name).expect_err("empty name rejected");
assert_eq!(error.code, "empty_viewport_name");
}
for name in ["root", "vp:123"] {
let error = validate_viewport_name(name).expect_err("reserved name rejected");
assert_eq!(error.code, "reserved_viewport_name");
}
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
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(),
data: entry.data.clone(),
value_text,
layout: entry.layout.clone(),
scroll,
range,
options,
selected,
indeterminate,
multiline,
password,
enabled: entry.enabled,
visible: entry.visible,
focused: entry.focused,
}
}
}