Build radio widget entries from choice type metadata so variants display their icons (#4408)

* Build radio widget entries from choice type metadata so variants display their icons

* Route the node properties enum radio through the shared choice type entry builder
This commit is contained in:
Keavon Chambers
2026-08-04 21:16:14 -07:00
committed by Dennis Kobert
parent ed82c52d8e
commit d19c299f40
12 changed files with 90 additions and 105 deletions

View File

@@ -1,2 +1,2 @@
https://github.com/Keavon/graphite-branded-assets/archive/0d004aa61e6b48d316e8e5db6d59ccc4788f192d.tar.gz https://github.com/Keavon/graphite-branded-assets/archive/6687dc6d3d8552948458d00d35e2bee3a40ab66b.tar.gz
772d64518be43c99977ba56f69e574531c56e83d2df2f42ab066f77f74b0dd1f 7663b4a35f377a5d3363430a725a1d5f634fbdc56adca5e43167786b8fe1b253

View File

@@ -623,14 +623,7 @@ impl ColorPickerMessageHandler {
// Gradient spread (only present when the picker is in gradient mode) // Gradient spread (only present when the picker is in gradient mode)
if self.gradient.is_some() { if self.gradient.is_some() {
let entries = [GradientSpread::Pad, GradientSpread::Reflect, GradientSpread::Repeat] let entries = RadioEntryData::list_from_choice_type(|gradient_spread| ColorPickerMessage::SetGradientSpread { gradient_spread }.into());
.into_iter()
.map(|gradient_spread| {
RadioEntryData::new(format!("{gradient_spread:?}"))
.label(gradient_spread.to_string())
.on_update(move |_| ColorPickerMessage::SetGradientSpread { gradient_spread }.into())
})
.collect();
groups.push(LayoutGroup::row(vec![ groups.push(LayoutGroup::row(vec![
TextLabel::new("Ends").tooltip_label("Gradient Spread").tooltip_description(ENDS_DESCRIPTION).widget_instance(), TextLabel::new("Ends").tooltip_label("Gradient Spread").tooltip_description(ENDS_DESCRIPTION).widget_instance(),

View File

@@ -1,6 +1,7 @@
use crate::messages::frontend::IconName; use crate::messages::frontend::IconName;
use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::message::Message;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use derivative::*; use derivative::*;
use graphene_std::Color; use graphene_std::Color;
@@ -344,6 +345,30 @@ pub struct RadioEntryData {
pub on_commit: WidgetCallback<()>, pub on_commit: WidgetCallback<()>,
} }
impl RadioEntryData {
/// One entry per variant of a choice type enum, shown as the variant's icon when it has one and its text label otherwise.
pub fn list_from_choice_type<E>(to_message: impl Fn(E) -> Message + Clone + Send + Sync + 'static) -> Vec<Self>
where
E: graphene_std::choice_type::ChoiceTypeStatic + 'static,
{
E::list()
.iter()
.flat_map(|section| section.iter())
.map(|(variant, metadata)| {
let to_message = to_message.clone();
let variant = *variant;
let entry = RadioEntryData::new(metadata.name)
.tooltip_label(metadata.label)
.tooltip_description(metadata.description.unwrap_or_default())
.on_update(move |_| to_message(variant));
if let Some(icon) = metadata.icon { entry.icon(icon) } else { entry.label(metadata.label) }
})
.collect()
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)] #[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)] #[derivative(Debug, PartialEq, Default)]

View File

@@ -2649,7 +2649,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
.iter() .iter()
.map(|&gradient_form| { .map(|&gradient_form| {
RadioEntryData::new(format!("{:?}", gradient_form)) RadioEntryData::new(format!("{:?}", gradient_form))
.label(format!("{:?}", gradient_form)) .label(gradient_form.to_string())
.on_update(update_value(move |_| TaggedValue::GradientForm(gradient_form), node_id, GradientFormInput)) .on_update(update_value(move |_| TaggedValue::GradientForm(gradient_form), node_id, GradientFormInput))
.on_commit(commit_value) .on_commit(commit_value)
}) })
@@ -2951,20 +2951,18 @@ pub mod choice {
U: Fn(&E) -> Message + 'static + Send + Sync, U: Fn(&E) -> Message + 'static + Send + Sync,
C: Fn(&()) -> Message + 'static + Send + Sync, C: Fn(&()) -> Message + 'static + Send + Sync,
{ {
let items = E::list() // The entry builder clones one callback across all variants, so each factory yields a single shared handle
.iter() let updater = std::sync::Arc::new(updater_factory());
.flat_map(|section| section.iter()) let committer = std::sync::Arc::new(committer_factory());
.map(|(item, var_meta)| {
let updater = updater_factory(); let items = RadioEntryData::list_from_choice_type(move |variant: E| updater(&variant))
let committer = committer_factory(); .into_iter()
let entry = RadioEntryData::new(var_meta.name) .map(|entry| {
.on_update(move |_| updater(item)) let committer = committer.clone();
.on_commit(committer) entry.on_commit(move |value| committer(value))
.tooltip_label(var_meta.label)
.tooltip_description(var_meta.description.unwrap_or_default());
if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) }
}) })
.collect(); .collect();
RadioInput::new(items).selected_index(Some(current.as_u32())).disabled(self.disabled).widget_instance() RadioInput::new(items).selected_index(Some(current.as_u32())).disabled(self.disabled).widget_instance()
} }
} }

View File

@@ -123,19 +123,9 @@ where
E: ChoiceTypeStatic + 'static, E: ChoiceTypeStatic + 'static,
F: Fn(E) -> Message + 'static + Send + Sync + Clone, F: Fn(E) -> Message + 'static + Send + Sync + Clone,
{ {
let entries = E::list() let entries = RadioEntryData::list_from_choice_type(to_message)
.iter() .into_iter()
.flat_map(|section| section.iter()) .map(|entry| entry.on_commit(|_| DocumentMessage::StartTransaction.into()))
.map(|(value, meta)| {
let to_message = to_message.clone();
let value = *value;
let entry = RadioEntryData::new(meta.name)
.tooltip_label(meta.label)
.tooltip_description(meta.description.unwrap_or_default())
.on_update(move |_| to_message(value))
.on_commit(|_| DocumentMessage::StartTransaction.into());
if let Some(icon) = meta.icon { entry.icon(icon) } else { entry.label(meta.label) }
})
.collect(); .collect();
vec![ vec![
TextLabel::new(label_text).table_align(true).widget_instance(), TextLabel::new(label_text).table_align(true).widget_instance(),

View File

@@ -231,22 +231,13 @@ impl LayoutHolder for GradientTool {
fn layout(&self) -> Layout { fn layout(&self) -> Layout {
let mut widgets: Vec<WidgetInstance> = Vec::new(); let mut widgets: Vec<WidgetInstance> = Vec::new();
let gradient_form = RadioInput::new(vec![ let gradient_form_entries = RadioEntryData::list_from_choice_type(|gradient_form| {
RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { GradientToolMessage::UpdateOptions {
GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::Form(gradient_form),
options: GradientOptionsUpdate::Form(GradientForm::Linear), }
} .into()
.into() });
}), let gradient_form = RadioInput::new(gradient_form_entries).selected_index(Some(self.options.gradient_form as u32)).widget_instance();
RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::Form(GradientForm::Radial),
}
.into()
}),
])
.selected_index(Some((self.options.gradient_form == GradientForm::Radial) as u32))
.widget_instance();
// Display priority: the selected layer's stops, then any user-customized tool default, then the working colors // Display priority: the selected layer's stops, then any user-customized tool default, then the working colors
let stops_value = self.data.current_gradient_stops.clone().or_else(|| self.data.default_gradient_stops.clone()).unwrap_or_else(|| { let stops_value = self.data.current_gradient_stops.clone().or_else(|| self.data.default_gradient_stops.clone()).unwrap_or_else(|| {

View File

@@ -217,26 +217,12 @@ fn create_shape_option_widget(shape_type: ShapeType) -> WidgetInstance {
} }
fn create_arc_type_widget(arc_type: ArcType) -> WidgetInstance { fn create_arc_type_widget(arc_type: ArcType) -> WidgetInstance {
let entries = vec![ let entries = RadioEntryData::list_from_choice_type(|arc_type| {
RadioEntryData::new("Open").label("Open").on_update(move |_| { ShapeToolMessage::UpdateOptions {
ShapeToolMessage::UpdateOptions { options: ShapeOptionsUpdate::ArcType(arc_type),
options: ShapeOptionsUpdate::ArcType(ArcType::Open), }
} .into()
.into() });
}),
RadioEntryData::new("Closed").label("Closed").on_update(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::ArcType(ArcType::Closed),
}
.into()
}),
RadioEntryData::new("Pie").label("Pie").on_update(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::ArcType(ArcType::PieSlice),
}
.into()
}),
];
RadioInput::new(entries).selected_index(Some(arc_type as u32)).widget_instance() RadioInput::new(entries).selected_index(Some(arc_type as u32)).widget_instance()
} }
@@ -307,20 +293,12 @@ fn create_spiral_type_widget(spiral_type: SpiralType) -> WidgetInstance {
} }
fn create_grid_type_widget(grid_type: GridType) -> WidgetInstance { fn create_grid_type_widget(grid_type: GridType) -> WidgetInstance {
let entries = vec![ let entries = RadioEntryData::list_from_choice_type(|grid_type| {
RadioEntryData::new("Rectangular").label("Rectangular").on_update(move |_| { ShapeToolMessage::UpdateOptions {
ShapeToolMessage::UpdateOptions { options: ShapeOptionsUpdate::GridType(grid_type),
options: ShapeOptionsUpdate::GridType(GridType::Rectangular), }
} .into()
.into() });
}),
RadioEntryData::new("Isometric").label("Isometric").on_update(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::GridType(GridType::Isometric),
}
.into()
}),
];
RadioInput::new(entries).selected_index(Some(grid_type as u32)).widget_instance() RadioInput::new(entries).selected_index(Some(grid_type as u32)).widget_instance()
} }

View File

@@ -20,7 +20,6 @@ use crate::messages::tool::utility_types::ToolRefreshOptions;
use graph_craft::application_io::resource::ResourceId; use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput}; use graph_craft::document::{NodeId, NodeInput};
use graphene_std::choice_type::ChoiceTypeStatic;
use graphene_std::color::SRGBA8; use graphene_std::color::SRGBA8;
use graphene_std::renderer::Quad; use graphene_std::renderer::Quad;
use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping}; use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping};
@@ -214,23 +213,12 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &D
.into() .into()
}) })
.widget_instance(); .widget_instance();
let align_entries: Vec<_> = TextAlign::list() let align_entries = RadioEntryData::list_from_choice_type(|align| {
.iter() TextToolMessage::UpdateOptions {
.flat_map(|section| section.iter()) options: TextOptionsUpdate::Align(align),
.map(|(item, var_meta)| { }
let align = *item; .into()
let entry = RadioEntryData::new(var_meta.name) });
.tooltip_label(var_meta.label)
.tooltip_description(var_meta.description.unwrap_or_default())
.on_update(move |_| {
TextToolMessage::UpdateOptions {
options: TextOptionsUpdate::Align(align),
}
.into()
});
if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) }
})
.collect();
let align = RadioInput::new(align_entries).selected_index(Some(tool.options.align as u32)).widget_instance(); let align = RadioInput::new(align_entries).selected_index(Some(tool.options.align as u32)).widget_instance();
vec![ vec![
font, font,

View File

@@ -19,6 +19,10 @@ import Empty12px from "/../branding/assets/icon-12px-solid/empty-12px.svg";
import Failure from "/../branding/assets/icon-12px-solid/failure.svg"; import Failure from "/../branding/assets/icon-12px-solid/failure.svg";
import FullscreenEnter from "/../branding/assets/icon-12px-solid/fullscreen-enter.svg"; import FullscreenEnter from "/../branding/assets/icon-12px-solid/fullscreen-enter.svg";
import FullscreenExit from "/../branding/assets/icon-12px-solid/fullscreen-exit.svg"; import FullscreenExit from "/../branding/assets/icon-12px-solid/fullscreen-exit.svg";
import GradientSpreadClear from "/../branding/assets/icon-12px-solid/gradient-spread-clear.svg";
import GradientSpreadPad from "/../branding/assets/icon-12px-solid/gradient-spread-pad.svg";
import GradientSpreadReflect from "/../branding/assets/icon-12px-solid/gradient-spread-reflect.svg";
import GradientSpreadRepeat from "/../branding/assets/icon-12px-solid/gradient-spread-repeat.svg";
import GridDotted from "/../branding/assets/icon-12px-solid/grid-dotted.svg"; import GridDotted from "/../branding/assets/icon-12px-solid/grid-dotted.svg";
import Grid from "/../branding/assets/icon-12px-solid/grid.svg"; import Grid from "/../branding/assets/icon-12px-solid/grid.svg";
import Info from "/../branding/assets/icon-12px-solid/info.svg"; import Info from "/../branding/assets/icon-12px-solid/info.svg";
@@ -67,6 +71,10 @@ const SOLID_12PX = {
Failure: { svg: Failure, size: 12 }, Failure: { svg: Failure, size: 12 },
FullscreenEnter: { svg: FullscreenEnter, size: 12 }, FullscreenEnter: { svg: FullscreenEnter, size: 12 },
FullscreenExit: { svg: FullscreenExit, size: 12 }, FullscreenExit: { svg: FullscreenExit, size: 12 },
GradientSpreadClear: { svg: GradientSpreadClear, size: 12 },
GradientSpreadPad: { svg: GradientSpreadPad, size: 12 },
GradientSpreadReflect: { svg: GradientSpreadReflect, size: 12 },
GradientSpreadRepeat: { svg: GradientSpreadRepeat, size: 12 },
Grid: { svg: Grid, size: 12 }, Grid: { svg: Grid, size: 12 },
GridDotted: { svg: GridDotted, size: 12 }, GridDotted: { svg: GridDotted, size: 12 },
Info: { svg: Info, size: 12 }, Info: { svg: Info, size: 12 },

View File

@@ -89,7 +89,13 @@ export function destroyTooltipStore() {
// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip // Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip
function onMouseOver(e: MouseEvent) { function onMouseOver(e: MouseEvent) {
const element = (e.target instanceof Element && e.target.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]")) || undefined; const target = (e.target instanceof Element && e.target) || undefined;
let element = target?.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]") || undefined;
// A floating menu renders within the DOM of the widget that spawned it, so a match beyond the menu's own content
// is the spawner's tooltip rather than one belonging to whatever the cursor is actually over
const floatingMenuContent = target?.closest("[data-floating-menu-content]");
if (element && floatingMenuContent && !floatingMenuContent.contains(element)) element = undefined;
update((state) => { update((state) => {
state.visible = false; state.visible = false;

View File

@@ -11,8 +11,10 @@ use glam::{DAffine2, DVec2};
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[widget(Radio)] #[widget(Radio)]
pub enum GradientForm { pub enum GradientForm {
/// Transitions the colors along a straight line.
#[default] #[default]
Linear, Linear,
/// Transitions the colors outward from a center point.
Radial, Radial,
} }
@@ -788,10 +790,13 @@ impl Gradient {
pub enum GradientSpread { pub enum GradientSpread {
/// Extends the end colors outward. /// Extends the end colors outward.
#[default] #[default]
#[icon("GradientSpreadPad")]
Pad, Pad,
/// Loops the gradient by mirroring back-and-forth. /// Loops the gradient by mirroring back-and-forth.
#[icon("GradientSpreadReflect")]
Reflect, Reflect,
/// Loops the gradient as copies of itself. /// Loops the gradient as copies of itself.
#[icon("GradientSpreadRepeat")]
Repeat, Repeat,
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range // TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
} }

View File

@@ -155,9 +155,12 @@ pub enum GridType {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[widget(Radio)] #[widget(Radio)]
pub enum ArcType { pub enum ArcType {
/// Leaves the two ends of the arc unconnected.
#[default] #[default]
Open = 0, Open = 0,
/// Connects the two ends of the arc with a straight line.
Closed, Closed,
/// Connects the two ends of the arc to its center, forming a wedge.
PieSlice, PieSlice,
} }