This commit is contained in:
Moritz Ruth 2024-01-31 01:23:56 +01:00
parent 1904e3e96a
commit b5a7ab3c6b
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
71 changed files with 921 additions and 1297 deletions

View file

@ -0,0 +1,15 @@
[package]
name = "deckster_shared"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0.195", features = ["derive", "rc"] }
serde_with = "3.4.0"
thiserror = "1.0.56"
derive_more = "0.99.17"
rgb = "0.8.37"
enum-ordinalize = "4.3.0"
enum-map = "3.0.0-beta.2"
im = { version = "15.1.0", features = ["serde"] }
parse-display = "0.8.2"

View file

@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::path::{KeyPath, KnobPath};
use crate::style::{KeyStyle, KnobStyle};
#[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum RotationDirection {
Clockwise,
Counterclockwise,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum KnobEvent {
Press,
ButtonDown,
ButtonUp,
Rotate { direction: RotationDirection },
VisibilityChange { is_visible: bool },
}
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum KeyTouchEventKind {
Start,
Move,
End,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum KeyEvent {
Press,
Touch { touch_id: u8, x: u16, y: u16, kind: KeyTouchEventKind },
VisibilityChange { is_visible: bool },
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum HandlerEvent {
Knob { path: KnobPath, event: KnobEvent },
Key { path: KeyPath, event: KeyEvent },
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "kebab-case")]
pub enum HandlerCommand {
SetActivePages { key_page_id: String, knob_page_id: String },
SetKeyStyle { path: KeyPath, value: Option<KeyStyle> },
SetKnobStyle { path: KnobPath, value: Option<KnobStyle> },
SetKnobValue { path: KnobPath, value: Option<f32> },
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct InitialHandlerMessage<KeyConfig: Clone, KnobConfig: Clone> {
pub key_configs: im::HashMap<KeyPath, (Box<str>, KeyConfig)>,
pub knob_configs: im::HashMap<KnobPath, (Box<str>, KnobConfig)>,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum HandlerInitializationResultMessage {
Ready,
Error { error: HandlerInitializationError },
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Error)]
pub enum HandlerInitializationError {
#[error("The provided mode string is invalid: {message}")]
InvalidModeString { message: Box<str> },
#[error("The provided handler config is invalid: {message}")]
InvalidConfig {
supports_keys: bool,
supports_knobs: bool,
message: Box<str>,
},
#[error("{message}")]
Other { message: Box<str> },
}

View file

@ -0,0 +1,89 @@
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use crate::image_filter::{ImageFilter, ImageFilterFromStringError};
#[derive(Debug, Eq, PartialEq, Hash, Clone, SerializeDisplay, DeserializeFromStr)]
pub struct IconDescriptor {
pub source: IconDescriptorSource,
pub filter: ImageFilter,
}
#[derive(Debug, thiserror::Error)]
pub enum IconDescriptorFromStrError {
#[error("Not a valid icon identifier: {0}")]
InvalidIconPackSource(String),
#[error("The image filter is invalid: {0}")]
InvalidImageFilter(#[source] ImageFilterFromStringError),
#[error("The image filter is missing the closing ']'")]
MissingImageFilterClosingBracket,
}
impl FromStr for IconDescriptor {
type Err = IconDescriptorFromStrError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (raw_source, raw_filter) = s.split_once('[').unwrap_or((s, ""));
let source = if let Some(raw_source) = raw_source.strip_prefix('@') {
let (pack_id, icon_id) = raw_source
.split_once('/')
.ok_or_else(|| IconDescriptorFromStrError::InvalidIconPackSource(raw_source.to_string()))?;
IconDescriptorSource::IconPack {
pack_id: pack_id.to_owned(),
icon_id: icon_id.to_owned(),
}
} else {
IconDescriptorSource::Path(PathBuf::from(raw_source))
};
let filter: ImageFilter = if raw_filter.is_empty() {
ImageFilter::default()
} else {
let mut raw_filter = raw_filter.to_owned();
if raw_filter.pop().expect("emptiness was eliminated a few lines earlier") != ']' {
return Err(IconDescriptorFromStrError::MissingImageFilterClosingBracket);
}
ImageFilter::from_str(&raw_filter).map_err(IconDescriptorFromStrError::InvalidImageFilter)?
};
Ok(IconDescriptor { source, filter })
}
}
impl Display for IconDescriptor {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}[{}]", self.source, self.filter))
}
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Serialize, Deserialize)]
pub enum IconDescriptorSource {
IconPack { pack_id: String, icon_id: String },
Path(PathBuf),
}
impl IconDescriptorSource {
pub fn pack_id(&self) -> Option<&String> {
match self {
IconDescriptorSource::IconPack { pack_id, .. } => Some(pack_id),
_ => None,
}
}
}
impl Display for IconDescriptorSource {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
IconDescriptorSource::IconPack { pack_id, icon_id } => f.write_fmt(format_args!("@{}/{}", pack_id, icon_id)),
IconDescriptorSource::Path(path) => f.write_str(&path.to_string_lossy()),
}
}
}

View file

@ -0,0 +1,282 @@
use std::fmt::{Debug, Display, Formatter};
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use thiserror::Error;
use crate::rgb::RGB8Wrapper;
#[derive(Debug, PartialEq, Clone)]
pub struct ImageFilterTransform {
pub scale: f32,
/// Must be in 0..=3
pub clockwise_quarter_rotations: u8,
pub alpha: f32,
}
impl ImageFilterTransform {
pub fn merge_over(&self, other: &ImageFilterTransform) -> ImageFilterTransform {
ImageFilterTransform {
scale: self.scale * other.scale,
alpha: self.alpha * other.alpha,
clockwise_quarter_rotations: (self.clockwise_quarter_rotations + other.clockwise_quarter_rotations) % 4,
}
}
}
#[derive(Debug, Eq, PartialEq, Hash, Clone)]
pub struct ImageFilterDestructive {
pub color: Option<RGB8Wrapper>,
pub grayscale: bool,
pub invert: bool,
}
impl ImageFilterDestructive {
pub fn merge_over(&self, other: &ImageFilterDestructive) -> ImageFilterDestructive {
ImageFilterDestructive {
color: self.color.or(other.color),
grayscale: self.grayscale || other.grayscale,
invert: self.invert ^ other.invert,
}
}
}
#[derive(Debug, PartialEq, Clone, SerializeDisplay, DeserializeFromStr)]
pub struct ImageFilter {
pub transform: ImageFilterTransform,
pub destructive: ImageFilterDestructive,
}
impl ImageFilter {
pub fn merge_over(&self, other: &ImageFilter) -> ImageFilter {
ImageFilter {
transform: self.transform.merge_over(&other.transform),
destructive: self.destructive.merge_over(&other.destructive),
}
}
}
impl Eq for ImageFilter {
fn assert_receiver_is_total_eq(&self) {}
}
impl Hash for ImageFilter {
fn hash<H: Hasher>(&self, hasher: &mut H) {
self.to_string().hash(hasher)
}
}
impl Default for ImageFilter {
fn default() -> Self {
DEFAULT_IMAGE_FILTER.clone()
}
}
const DEFAULT_IMAGE_FILTER: ImageFilter = ImageFilter {
transform: ImageFilterTransform {
scale: 1.0,
clockwise_quarter_rotations: 0,
alpha: 1.0,
},
destructive: ImageFilterDestructive {
color: None,
grayscale: false,
invert: false,
},
};
#[derive(Debug, Error)]
pub enum ImageFilterFromStringError {
#[error("Unknown filter: {name}")]
UnknownFilter { name: String },
#[error("Filter {name} can only be used once.")]
FilterUsedMoreThanOnce { name: String },
#[error("Only the following values are allowed for rotate: -90, 90, 180, 270")]
RotationNotAllowed,
#[error("Filter {filter_name} requires a value ({filter_name}=<value>).")]
FilterValueMissing { filter_name: String },
#[error("Filter {filter_name} does not require or allow a value.")]
FilterValueIgnored { filter_name: String },
#[error("The following value supplied to {filter_name} could not be parsed: {raw_value}")]
FilterValueNotParsable { filter_name: String, raw_value: String },
#[error("{value} is not in the range ({range}) required by {filter_name}.")]
FilterValueNotInRange { filter_name: String, value: String, range: String },
}
impl FromStr for ImageFilter {
type Err = ImageFilterFromStringError;
fn from_str<'a>(s: &str) -> Result<Self, Self::Err> {
fn parse_f32_filter_value(
filter_name: &str,
raw_value: String,
range_string: &str,
check_range: Box<dyn Fn(&f32) -> bool>,
) -> Result<f32, ImageFilterFromStringError> {
let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
filter_name: filter_name.to_string(),
raw_value,
})?;
if !check_range(&value) {
return Err(ImageFilterFromStringError::FilterValueNotInRange {
filter_name: filter_name.to_string(),
range: range_string.to_string(),
value: value.to_string(),
});
}
Ok(value)
}
let mut result = ImageFilter::default();
let mut previous_filter_names: Vec<String> = Vec::new();
let filters: Vec<&str> = s.split('|').map(|f| f.trim()).filter(|s| !s.is_empty()).collect();
if filters.is_empty() {
return Ok(result);
}
for filter in filters {
let split_filter = filter.split_once('=');
let (filter_name, optional_raw_value) = if let Some((filter_name, raw_value)) = split_filter {
(filter_name.to_owned(), Some(raw_value.to_owned()))
} else {
(filter.to_owned(), None)
};
if previous_filter_names.contains(&filter_name) {
return Err(ImageFilterFromStringError::FilterUsedMoreThanOnce { name: filter_name });
} else {
previous_filter_names.push(filter_name.clone());
}
let value_is_present = optional_raw_value.is_some();
let use_bool_value = || -> Result<bool, ImageFilterFromStringError> {
if value_is_present {
Err(ImageFilterFromStringError::FilterValueIgnored {
filter_name: filter_name.clone(),
})
} else {
Ok(true)
}
};
let use_raw_value = || -> Result<String, ImageFilterFromStringError> {
optional_raw_value.ok_or_else(|| ImageFilterFromStringError::FilterValueMissing {
filter_name: filter_name.clone(),
})
};
match filter_name.as_str() {
"scale" => result.transform.scale = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=100", Box::new(|v| (0.0..=100.0).contains(v)))?,
"rotate" => {
let raw_value = use_raw_value()?;
let value = i32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
filter_name: filter_name.to_string(),
raw_value,
})?;
result.transform.clockwise_quarter_rotations = match value {
0 => 0,
90 => 1,
180 => 2,
270 | -90 => 3,
_ => return Err(ImageFilterFromStringError::RotationNotAllowed),
};
}
"alpha" => result.transform.alpha = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..<1", Box::new(|v| (0.0..1.0).contains(v)))?,
"color" => {
let raw_value = use_raw_value()?;
result.destructive.color = Some(
RGB8Wrapper::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
filter_name: filter_name.to_string(),
raw_value,
})?,
)
}
"grayscale" => result.destructive.grayscale = use_bool_value()?,
"invert" => result.destructive.invert = use_bool_value()?,
_ => return Err(ImageFilterFromStringError::UnknownFilter { name: filter_name }),
};
}
Ok(result)
}
}
impl Display for ImageFilter {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut is_first = true;
if self.transform.scale != DEFAULT_IMAGE_FILTER.transform.scale {
if !is_first {
f.write_str("|")?
}
f.write_fmt(format_args!("scale={}", self.transform.scale))?;
is_first = false;
}
if self.transform.clockwise_quarter_rotations != DEFAULT_IMAGE_FILTER.transform.clockwise_quarter_rotations {
if !is_first {
f.write_str("|")?
}
f.write_fmt(format_args!("rotate={}", self.transform.clockwise_quarter_rotations as u16 * 90))?;
is_first = false;
}
if self.transform.alpha != DEFAULT_IMAGE_FILTER.transform.alpha {
if !is_first {
f.write_str("|")?
}
f.write_fmt(format_args!("alpha={}", self.transform.alpha))?;
is_first = false;
}
if let Some(color) = self.destructive.color {
if !is_first {
f.write_str("|")?
}
f.write_fmt(format_args!("color={}", color))?;
is_first = false;
}
if self.destructive.grayscale {
if !is_first {
f.write_str("|")?
}
f.write_str("grayscale")?;
is_first = false;
}
if self.destructive.invert {
if !is_first {
f.write_str("|")?
}
f.write_str("invert")?;
is_first = false;
}
if let Some(color) = self.destructive.color {
if !is_first {
f.write_str("|")?
}
f.write_fmt(format_args!("color={}", color))?;
// is_first = false;
}
Ok(())
}
}

View file

@ -0,0 +1,7 @@
pub mod handler_communication;
pub mod icon_descriptor;
pub mod image_filter;
pub mod path;
pub mod rgb;
pub mod state;
pub mod style;

View file

@ -0,0 +1,43 @@
use enum_map::Enum;
use enum_ordinalize::Ordinalize;
use parse_display::{Display, FromStr};
use serde_with::{DeserializeFromStr, SerializeDisplay};
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
/// One-based coordinates of a specific virtual (not physical) key.
#[display("{x}x{y}")]
pub struct KeyPosition {
pub x: u16,
pub y: u16,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
#[display("{page_id}/{position}")]
pub struct KeyPath {
pub page_id: String,
pub position: KeyPosition,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Enum, Ordinalize, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
#[display(style = "kebab-case")]
pub enum KnobPosition {
LeftTop,
LeftMiddle,
LeftBottom,
RightTop,
RightMiddle,
RightBottom,
}
impl KnobPosition {
pub fn is_left(&self) -> bool {
matches!(self, KnobPosition::LeftBottom | KnobPosition::LeftMiddle | KnobPosition::LeftTop)
}
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
#[display("{page_id}/{position}")]
pub struct KnobPath {
pub page_id: String,
pub position: KnobPosition,
}

View file

@ -0,0 +1,108 @@
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use derive_more::{Deref, From, Into};
use rgb::{RGB8, RGBA8};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use thiserror::Error;
#[derive(Debug, Error)]
#[error("The input value does not match the required format.")]
pub struct ParsingError {}
fn parse_rgb8_from_hex_str(s: &str) -> Result<RGB8, ParsingError> {
let first_index = if s.starts_with('#') { 1 } else { 0 };
if s.len() - first_index == 6 {
let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ParsingError {})?;
let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ParsingError {})?;
let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ParsingError {})?;
return Ok(RGB8::new(r, g, b));
}
Err(ParsingError {})
}
fn fmt_rgb8_as_hex_string(v: &RGB8, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{:#04x}{:#04x}{:#04x}", v.r, v.g, v.b))
}
fn parse_rgba8_from_hex_str(s: &str) -> Result<RGBA8, ParsingError> {
let first_index = if s.starts_with('#') { 1 } else { 0 };
if s.len() - first_index == 8 {
let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ParsingError {})?;
let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ParsingError {})?;
let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ParsingError {})?;
let a = u8::from_str_radix(&s[(first_index + 6)..(first_index + 8)], 16).map_err(|_| ParsingError {})?;
return Ok(RGBA8::new(r, g, b, a));
}
Err(ParsingError {})
}
fn fmt_rgba8_as_hex_string(v: &RGBA8, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{:#04x}{:#04x}{:#04x}{:#04x}", v.r, v.g, v.b, v.a))
}
fn parse_rgb8_with_optional_alpha_from_hex_str(s: &str, fallback_alpha: u8) -> Result<RGBA8, ParsingError> {
// optionally +1 for the '#'
match s.len() {
6 | 7 => Ok(parse_rgb8_from_hex_str(s)?.alpha(fallback_alpha)),
8 | 9 => Ok(parse_rgba8_from_hex_str(s)?),
_ => Err(ParsingError {}),
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deref, From, Into, SerializeDisplay, DeserializeFromStr)]
pub struct RGB8Wrapper(RGB8);
impl FromStr for RGB8Wrapper {
type Err = ParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_rgb8_from_hex_str(s).map(RGB8Wrapper)
}
}
impl Display for RGB8Wrapper {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
fmt_rgb8_as_hex_string(&self.0, f)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deref, From, Into, SerializeDisplay, DeserializeFromStr)]
#[repr(transparent)]
pub struct RGBA8Wrapper(pub RGBA8);
impl FromStr for RGBA8Wrapper {
type Err = ParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_rgba8_from_hex_str(s).map(RGBA8Wrapper)
}
}
impl Display for RGBA8Wrapper {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
fmt_rgba8_as_hex_string(&self.0, f)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deref, From, Into, SerializeDisplay, DeserializeFromStr)]
#[repr(transparent)]
pub struct RGB8WithOptionalA(RGBA8);
impl FromStr for RGB8WithOptionalA {
type Err = ParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_rgb8_with_optional_alpha_from_hex_str(s, 0xff).map(RGB8WithOptionalA)
}
}
impl Display for RGB8WithOptionalA {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
fmt_rgba8_as_hex_string(&self.0, f)
}
}

View file

@ -0,0 +1,22 @@
use std::collections::HashMap;
use crate::path::{KeyPath, KnobPath};
use crate::style::{KeyStyle, KnobStyle};
#[derive(Debug)]
pub struct Key {
pub path: KeyPath,
pub base_style: KeyStyle,
pub style: Option<KeyStyle>,
}
#[derive(Debug)]
pub struct Knob {
pub path: KnobPath,
pub base_style: KnobStyle,
pub style: Option<KnobStyle>,
pub value: Option<f32>,
}
pub type KeyStyleByStateMap<State> = HashMap<State, KeyStyle>;
pub type KnobStyleByStateMap<State> = HashMap<State, KnobStyle>;

View file

@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use crate::icon_descriptor::IconDescriptor;
use crate::rgb::RGB8WithOptionalA;
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeyStyle {
pub label: Option<String>,
pub icon: Option<IconDescriptor>,
pub border: Option<RGB8WithOptionalA>,
}
impl KeyStyle {
pub fn merge_over(&self, base: &KeyStyle) -> KeyStyle {
KeyStyle {
label: self.label.as_ref().or(base.label.as_ref()).cloned(),
icon: self.icon.as_ref().or(base.icon.as_ref()).cloned(),
border: self.border.or(base.border),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct KnobStyle {
pub label: Option<String>,
pub icon: Option<IconDescriptor>,
pub indicators: Option<KnobIndicators>,
}
impl KnobStyle {
pub fn merge_over(&self, base: &Self) -> Self {
Self {
label: self.label.as_ref().or(base.label.as_ref()).cloned(),
icon: self.icon.as_ref().or(base.icon.as_ref()).cloned(),
indicators: self
.indicators
.as_ref()
.zip(base.indicators.as_ref())
.map(|(a, b)| a.merge_over(b))
.or_else(|| self.indicators.clone())
.or_else(|| base.indicators.clone()),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct KnobIndicators {
pub bar: Option<KnobIndicatorBarConfig>,
pub circle: Option<KnobIndicatorCircleConfig>,
}
impl KnobIndicators {
pub fn merge_over(&self, base: &Self) -> Self {
Self {
bar: self
.bar
.as_ref()
.zip(base.bar.as_ref())
.map(|(a, b)| a.merge_over(b))
.or_else(|| self.bar.clone())
.or_else(|| base.bar.clone()),
circle: self
.circle
.as_ref()
.zip(base.circle.as_ref())
.map(|(a, b)| a.merge_over(b))
.or_else(|| self.circle.clone())
.or_else(|| base.circle.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KnobIndicatorBarConfig {
pub color: Option<RGB8WithOptionalA>,
}
impl KnobIndicatorBarConfig {
pub fn merge_over(&self, base: &Self) -> Self {
Self {
color: self.color.as_ref().or(base.color.as_ref()).cloned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KnobIndicatorCircleConfig {
pub color: Option<RGB8WithOptionalA>,
pub width: Option<u8>,
pub radius: Option<u8>,
}
impl KnobIndicatorCircleConfig {
pub fn merge_over(&self, base: &Self) -> Self {
Self {
color: self.color.as_ref().or(base.color.as_ref()).cloned(),
width: self.width.as_ref().or(base.width.as_ref()).cloned(),
radius: self.radius.as_ref().or(base.radius.as_ref()).cloned(),
}
}
}