use iced_nodegraph_sdf::Pattern;
use iced_widget::core::{Color, Theme};
use super::roles::Roles;
use super::{
AnchorStatus, AnchorStyle, CuttingToolStyle, EdgeCurve, EdgeStatus, EdgeStyle, GraphStyle,
MinimapStyle, NodeStatus, NodeStyle, ParticleStyle, PinShape, PinStatus, PinStyle,
SelectionBoxStyle, TilingBackground, ramp,
};
const NODE_CORNER_RADIUS: f32 = 5.0;
const SELECTION_TINT: f32 = 0.12;
const PIN_RADIUS: f32 = 5.0;
const PIN_CUTOUT_RADIUS: f32 = crate::node_graph::PIN_CLICK_THRESHOLD;
pub fn default_node_style(theme: &Theme, status: NodeStatus) -> NodeStyle {
let roles = Roles::of(theme);
let (shadow_alpha, shadow_distance) = if roles.is_dark {
(0.38, 7.0)
} else {
(0.22, 9.0)
};
let base = NodeStyle {
fill_color: roles.body.into(),
corner_radius: NODE_CORNER_RADIUS,
opacity: 1.0,
border_color: roles.border.into(),
border_pattern: Pattern::solid(1.0),
border_outline_width: 0.0,
border_outline_color: Color::TRANSPARENT.into(),
shadow_color: Color::from_rgba(0.0, 0.0, 0.0, shadow_alpha),
shadow_distance,
shadow_offset: (0.0, 3.0),
};
match status {
NodeStatus::Idle => base,
NodeStatus::Selected => NodeStyle {
fill_color: ramp::blend(roles.body, roles.accent, SELECTION_TINT).into(),
border_color: roles.accent.into(),
border_pattern: Pattern::solid(2.0),
border_outline_width: 3.0,
border_outline_color: Color {
a: 0.28,
..roles.accent
}
.into(),
shadow_distance: shadow_distance * 1.6,
..base
},
}
}
pub fn default_pin_style(theme: &Theme, status: PinStatus) -> PinStyle {
let roles = Roles::of(theme);
let base = PinStyle {
color: roles.terminal.into(),
radius: PIN_RADIUS,
shape: PinShape::Circle,
cutout_radius: PIN_CUTOUT_RADIUS,
border_color: Color::TRANSPARENT.into(),
border_width: 0.0,
};
match status {
PinStatus::Idle => base,
PinStatus::ValidTarget => PinStyle {
color: roles.valid.into(),
border_color: Color {
a: 0.4,
..roles.valid
}
.into(),
border_width: PIN_CUTOUT_RADIUS - PIN_RADIUS,
..base
},
}
}
pub fn default_anchor_style(theme: &Theme, status: AnchorStatus) -> AnchorStyle {
let roles = Roles::of(theme);
let base = AnchorStyle {
core_size: crate::node_graph::DEFAULT_CORE_SIZE,
core_radius: 3.0,
core_color: roles.body.into(),
core_border_color: roles.border.into(),
core_border_width: 1.0,
orbit_offset: crate::node_graph::DEFAULT_ORBIT_OFFSET,
orbit_spacing: crate::node_graph::DEFAULT_ORBIT_SPACING,
ring_color: Color {
a: 0.35,
..roles.wire
}
.into(),
ring_width: 1.0,
offered_ring_color: Color {
a: 0.1575,
..roles.wire
}
.into(),
};
match status {
AnchorStatus::Idle => base,
AnchorStatus::Hovered => AnchorStyle {
core_border_color: Color {
a: 0.6,
..roles.accent
}
.into(),
core_border_width: 1.5,
..base
},
AnchorStatus::ValidTarget => AnchorStyle {
core_color: roles.valid.into(),
ring_color: Color {
a: 0.7,
..roles.valid
}
.into(),
offered_ring_color: Color {
a: 0.315,
..roles.valid
}
.into(),
..base
},
}
}
pub fn default_edge_style(theme: &Theme, status: EdgeStatus) -> EdgeStyle {
let roles = Roles::of(theme);
let none = Color::TRANSPARENT;
let base = EdgeStyle {
stroke_color: roles.wire.into(),
pattern: Pattern::solid(2.0),
stroke_outline_width: 0.0,
stroke_outline_color: none.into(),
border_color: none.into(),
border_width: 0.0,
border_gap: 0.5,
border_outline_width: 0.0,
border_outline_color: none.into(),
border_background: none.into(),
shadow_color: none.into(),
shadow_expand: 0.0,
shadow_blur: 0.0,
shadow_offset: (0.0, 0.0),
glow_color: Color {
a: 0.45,
..roles.wire
}
.into(),
glow_width: 6.0,
curve: EdgeCurve::BezierCubic,
};
match status {
EdgeStatus::Idle => base,
EdgeStatus::PendingCut => EdgeStyle {
stroke_color: roles.danger.into(),
glow_color: Color {
a: 0.45,
..roles.danger
}
.into(),
..base
},
}
}
pub fn default_selection_box_style(theme: &Theme) -> SelectionBoxStyle {
let accent = Roles::of(theme).accent;
SelectionBoxStyle {
fill: Color { a: 0.15, ..accent },
border_color: Color { a: 0.75, ..accent },
border_width: 1.5,
}
}
pub fn default_cutting_tool_style(theme: &Theme) -> CuttingToolStyle {
CuttingToolStyle {
color: Roles::of(theme).danger,
width: 3.0,
}
}
pub fn default_particle_style(theme: &Theme) -> ParticleStyle {
ParticleStyle {
color: Roles::of(theme).accent,
radius: 4.0,
}
}
pub fn default_graph_style(theme: &Theme) -> GraphStyle {
let roles = Roles::of(theme);
GraphStyle {
background_color: roles.canvas,
tiling: Some(TilingBackground::grid(40.0, 1.0, roles.grid)),
}
}
pub fn default_minimap_style(theme: &Theme) -> MinimapStyle {
let roles = Roles::of(theme);
MinimapStyle {
background: Color {
a: 0.9,
..roles.body
},
border_color: roles.border,
border_width: 1.0,
node_color: roles.wire,
selected_node_color: roles.accent,
viewport_fill: Color {
a: 0.12,
..roles.terminal
},
viewport_border_color: Color {
a: 0.8,
..roles.terminal
},
viewport_border_width: 1.0,
}
}
#[cfg(test)]
mod tests {
use super::super::ColorQuad;
use super::*;
#[test]
fn selected_node_reads_as_brought_forward() {
for theme in Theme::ALL {
let idle = default_node_style(theme, NodeStatus::Idle);
let sel = default_node_style(theme, NodeStatus::Selected);
assert_ne!(
sel.border_color, idle.border_color,
"{theme}: the border must leave the neutral ramp for the accent",
);
assert_ne!(
sel.fill_color, idle.fill_color,
"{theme}: the body must carry the selection when the border is \
off-screen",
);
assert!(
sel.border_outline_width > 0.0,
"{theme}: a halo ring distinguishes selection at any zoom",
);
assert!(
sel.shadow_distance > idle.shadow_distance,
"{theme}: the shadow deepens, reinforcing the z-promotion",
);
assert_eq!(sel.corner_radius, idle.corner_radius);
assert_eq!(sel.shadow_offset, idle.shadow_offset);
}
}
#[test]
fn a_node_body_is_opaque_in_every_theme() {
for theme in Theme::ALL {
for status in [NodeStatus::Idle, NodeStatus::Selected] {
assert_eq!(
default_node_style(theme, status).opacity,
1.0,
"{theme}: a node body must be opaque",
);
}
}
}
#[test]
fn a_pin_never_borrows_the_selection_accent() {
for theme in Theme::ALL {
let selected = default_node_style(theme, NodeStatus::Selected).border_color;
for status in [PinStatus::Idle, PinStatus::ValidTarget] {
assert_ne!(
default_pin_style(theme, status).color,
selected,
"{theme}: a {status:?} pin wears the selection accent",
);
}
}
}
#[test]
fn a_valid_drop_target_is_visible_in_every_theme() {
for theme in Theme::ALL {
let idle = default_pin_style(theme, PinStatus::Idle);
let valid = default_pin_style(theme, PinStatus::ValidTarget);
assert_ne!(
valid.color, idle.color,
"{theme}: a valid drop target paints like an idle pin",
);
assert!(
valid.border_width > 0.0,
"{theme}: a valid drop target has no halo",
);
}
}
#[test]
fn valid_target_feedback_costs_no_geometry() {
for theme in Theme::ALL {
let idle = default_pin_style(theme, PinStatus::Idle);
let valid = default_pin_style(theme, PinStatus::ValidTarget);
assert_eq!(valid.radius, idle.radius, "{theme}: the indicator resizes");
assert_eq!(valid.shape, idle.shape, "{theme}: the indicator reshapes");
assert_eq!(
valid.cutout_radius, idle.cutout_radius,
"{theme}: the node silhouette changes with pin status",
);
}
}
#[test]
fn a_pin_fits_inside_the_well_it_opens() {
let idle = default_pin_style(&Theme::Dark, PinStatus::Idle);
assert!(
idle.cutout_radius > idle.radius,
"the pin overruns its well"
);
let valid = default_pin_style(&Theme::Dark, PinStatus::ValidTarget);
assert_eq!(
valid.radius + valid.border_width,
valid.cutout_radius,
"the valid-target halo must reach the rim of the well, no further",
);
}
#[test]
fn pending_cut_tints_stroke_with_the_cutting_tool_color() {
let t = Theme::Dark;
let o = default_edge_style(&t, EdgeStatus::PendingCut);
assert_eq!(
o.stroke_color,
ColorQuad::solid(default_cutting_tool_style(&t).color)
);
}
}