diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 323d0de113..6b93158afc 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -308,7 +308,31 @@ impl LayoutMessageHandler { Widget::ParameterExposeButton(parameter_expose_button) => { let callback_message = match action { WidgetValueAction::Commit => (parameter_expose_button.on_commit.callback)(&()), - WidgetValueAction::Update => (parameter_expose_button.on_update.callback)(parameter_expose_button), + WidgetValueAction::Update => { + let Some(value_path) = value.as_array() else { + error!("ParameterExposeButton update was not of type: array"); + return; + }; + + // Process the bare button click, since no menu is involved if we're given an empty array. + if value_path.is_empty() { + (parameter_expose_button.on_update.callback)(parameter_expose_button) + } + // Process the menu list entry click, since we have a path to the value of the contained menu entry. + else { + let mut current_submenu = ¶meter_expose_button.menu_list_children; + let mut final_entry: Option<&MenuListEntry> = None; + + for value in value_path.iter().filter_map(|v| v.as_str().map(|s| s.to_string())) { + let Some(next_entry) = current_submenu.iter().flatten().find(|e| e.value == value) else { return }; + + current_submenu = &next_entry.children; + final_entry = Some(next_entry); + } + + (final_entry.unwrap().on_commit.callback)(&()) + } + } }; responses.add(callback_message); diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index a2280f8395..46ad184905 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -914,6 +914,10 @@ impl DiffUpdate { apply_action_shortcut_to_menu_lists(&mut text_button.menu_list_children, action_input_mapping); text_button.menu_list_children_hash = hash_menu_list_entry_sections(&text_button.menu_list_children); } + Widget::ParameterExposeButton(parameter_expose_button) => { + apply_action_shortcut_to_menu_lists(&mut parameter_expose_button.menu_list_children, action_input_mapping); + parameter_expose_button.menu_list_children_hash = hash_menu_list_entry_sections(¶meter_expose_button.menu_list_children); + } _ => {} }; diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index eaabac500d..640a1c38d5 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -92,6 +92,13 @@ pub struct ParameterExposeButton { #[serde(rename = "dataType")] pub data_type: FrontendGraphDataType, + // Children + #[serde(rename = "menuListChildren")] + pub menu_list_children: MenuListEntrySections, + #[serde(rename = "menuListChildrenHash")] + #[widget_builder(skip)] + pub menu_list_children_hash: u64, + // Tooltips #[serde(rename = "tooltipLabel")] pub tooltip_label: String, diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 689a6a1439..f4e879d23e 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -724,7 +724,7 @@ impl LayoutHolder for MenuBarMessageHandler { .icon(if message_logging_verbosity_off { #[cfg(not(target_os = "macos"))] { - "SmallDot".to_string() + "DataSourceValue".to_string() } #[cfg(target_os = "macos")] { @@ -738,7 +738,7 @@ impl LayoutHolder for MenuBarMessageHandler { .icon(if message_logging_verbosity_names { #[cfg(not(target_os = "macos"))] { - "SmallDot".to_string() + "DataSourceValue".to_string() } #[cfg(target_os = "macos")] { @@ -752,7 +752,7 @@ impl LayoutHolder for MenuBarMessageHandler { .icon(if message_logging_verbosity_contents { #[cfg(not(target_os = "macos"))] { - "SmallDot".to_string() + "DataSourceValue".to_string() } #[cfg(target_os = "macos")] { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 4e2db76dfe..1a654a1dc2 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -53,7 +53,59 @@ pub fn commit_value(_: &T) -> Message { DocumentMessage::AddTransaction.into() } -pub fn expose_widget(node_id: NodeId, index: usize, data_type: FrontendGraphDataType, exposed: bool) -> WidgetInstance { +pub fn expose_widget( + node_id: NodeId, + index: usize, + data_type: FrontendGraphDataType, + resolved_type: String, + valid_types: Vec, + is_subnetwork: bool, + is_wired: bool, + exposed: bool, +) -> WidgetInstance { + let input_connector = InputConnector::node(node_id, index); + + // Subnetwork inputs and wired inputs are read-only here: the type follows the subgraph or the upstream wire automatically. + // Only unconnected protonode inputs accept a manual choice that picks among multiple type implementations. + let entries_clickable = !is_subnetwork && !is_wired; + + let type_entries: Vec = if valid_types.is_empty() { + // Fall back to a single read-only entry when no valid types are available (e.g., a type-resolution error). + vec![MenuListEntry::new("type").label(resolved_type.clone()).disabled(true)] + } else { + valid_types + .into_iter() + .enumerate() + .map(|(i, ty)| { + let label = ty.nested_type().to_string(); + let is_active = label == resolved_type; + let is_clickable = entries_clickable && !is_active; + + let mut entry = MenuListEntry::new(format!("type-{i}")).label(label).disabled(!is_clickable); + + // The active entry gets a bullet-point icon (the small filled circle) to mark it as the current selection. + if is_active { + entry = entry.icon("DataSourceValue"); + } + + if is_clickable { + entry = entry.on_commit(move |_| { + NodeGraphMessage::SetInputValue { + node_id, + input_index: index, + value: TaggedValue::from_type_or_none(&ty), + } + .into() + }); + } + + entry + }) + .collect() + }; + + let type_submenu = vec![type_entries]; + ParameterExposeButton::new() .exposed(exposed) .data_type(data_type) @@ -62,14 +114,32 @@ pub fn expose_widget(node_id: NodeId, index: usize, data_type: FrontendGraphData } else { "Expose this parameter as a node input in the graph." }) - .on_update(move |_parameter| Message::Batched { - messages: Box::new([NodeGraphMessage::ExposeInput { - input_connector: InputConnector::node(node_id, index), - set_to_exposed: !exposed, - start_transaction: true, - } - .into()]), - }) + .menu_list_children(vec![vec![ + MenuListEntry::new("value") + .label("Value") + .icon("DataSourceValue") + .children(type_submenu.clone()) + .on_commit(move |_| { + NodeGraphMessage::ExposeInput { + input_connector, + set_to_exposed: false, + start_transaction: true, + } + .into() + }), + MenuListEntry::new("graph") + .label("Graph") + .icon("DataSourceGraph") + .children(type_submenu) + .on_commit(move |_| { + NodeGraphMessage::ExposeInput { + input_connector, + set_to_exposed: true, + start_transaction: true, + } + .into() + }), + ]]) .widget_instance() } @@ -114,6 +184,10 @@ pub fn start_widgets(parameter_widgets_info: ParameterWidgetsInfo) -> Vec Vec 'early_return: { - if let Some(field) = graphene_std::registry::NODE_METADATA + let field_default_type = if let Some(field) = graphene_std::registry::NODE_METADATA .lock() .unwrap() .get(proto_node_identifier) @@ -2352,9 +2426,21 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper display_decimal_places = field.number_display_decimal_places; unit_suffix = field.unit; step = field.number_step; - if let Some(ref default) = field.default_type { - break 'early_return default.clone(); - } + field.default_type.clone() + } else { + None + }; + + // Prefer the input's currently stored value type over the protonode's default. This is what lets a user-chosen + // type (e.g. swapping `f64` for `DVec2`) drive the widget, instead of always rendering the registry default. + if let Some(document_node) = context.network_interface.document_node(&node_id, context.selection_network_path) + && let Some(NodeInput::Value { tagged_value, .. }) = document_node.inputs.get(input_index) + { + break 'early_return tagged_value.ty(); + } + + if let Some(default) = field_default_type { + break 'early_return default; } let Some(implementations) = &interpreted_executor::node_registry::NODE_REGISTRY.get(proto_node_identifier) else { @@ -2822,6 +2908,10 @@ pub struct ParameterWidgetsInfo<'a> { name: String, description: String, input_type: FrontendGraphDataType, + resolved_type: String, + valid_types: Vec, + is_subnetwork: bool, + is_wired: bool, blank_assist: bool, exposable: bool, } @@ -2829,11 +2919,28 @@ pub struct ParameterWidgetsInfo<'a> { impl<'a> ParameterWidgetsInfo<'a> { pub fn new(node_id: NodeId, index: usize, blank_assist: bool, context: &'a mut NodePropertiesContext) -> ParameterWidgetsInfo<'a> { let (name, description) = context.network_interface.displayed_input_name_and_description(&node_id, index, context.selection_network_path); - let input_type = context - .network_interface - .input_type_not_invalid(&InputConnector::node(node_id, index), context.selection_network_path) - .displayed_type(); + let input_connector = InputConnector::node(node_id, index); + let type_source = context.network_interface.input_type_not_invalid(&input_connector, context.selection_network_path); + let input_type = type_source.displayed_type(); + let resolved_type = type_source.resolved_type_node_string(); + + // Start with the contextually-valid set. When that's empty for a protonode (e.g. a fresh node with no downstream + // constraints), fall back to every input type the protonode is registered with so the user can still pick. + let mut valid_types = context.network_interface.complete_valid_input_types(&input_connector, context.selection_network_path); + let implementation = context.network_interface.implementation(&node_id, context.selection_network_path); + let is_subnetwork = matches!(implementation, Some(DocumentNodeImplementation::Network(_))); + if valid_types.is_empty() + && let Some(DocumentNodeImplementation::ProtoNode(protonode_id)) = implementation + && let Some(implementations) = interpreted_executor::node_registry::NODE_REGISTRY.get(protonode_id) + { + valid_types = implementations.keys().filter_map(|node_io| node_io.inputs.get(index).cloned()).collect(); + } + // Dedupe by the displayed type name so repeated implementations sharing the same input type collapse to one entry. + let mut seen_type_names = std::collections::HashSet::new(); + valid_types.retain(|ty| seen_type_names.insert(ty.nested_type().to_string())); + let document_node = context.network_interface.document_node(&node_id, context.selection_network_path); + let is_wired = document_node.and_then(|node| node.inputs.get(index)).is_some_and(|input| matches!(input, NodeInput::Node { .. })); ParameterWidgetsInfo { cached_data: context.cached_data, @@ -2845,6 +2952,10 @@ impl<'a> ParameterWidgetsInfo<'a> { name, description, input_type, + resolved_type, + valid_types, + is_subnetwork, + is_wired, blank_assist, exposable: true, } diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte index 1a8e0279a6..fff46b440e 100644 --- a/frontend/src/components/floating-menus/MenuList.svelte +++ b/frontend/src/components/floating-menus/MenuList.svelte @@ -37,6 +37,8 @@ export let interactive = false; export let scrollableY = false; export let virtualScrolling = false; + // Use `Popover` to draw a tail pointing at the spawner. Defaults to `Dropdown` (no tail), which the menu bar uses. Recursive submenus always use `Dropdown`. + export let type: "Dropdown" | "Popover" = "Dropdown"; // Keep the child references outside of the entries array so as to avoid infinite recursion. let childReferences: MenuList[][] = []; @@ -453,7 +455,7 @@ {open} on:open={({ detail }) => (open = detail)} on:naturalWidth - type="Dropdown" + {type} windowEdgeMargin={0} escapeCloses={false} {direction} diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index dfe286dbcd..996dce4661 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -611,8 +611,8 @@ } } - &.top.dropdown .floating-menu-container, - &.bottom.dropdown .floating-menu-container { + &.top.dropdown > .floating-menu-container, + &.bottom.dropdown > .floating-menu-container { justify-content: left; } @@ -709,22 +709,22 @@ } } - &.top .floating-menu-container { + &.top > .floating-menu-container { justify-content: center; margin-bottom: var(--floating-menu-content-offset); } - &.bottom .floating-menu-container { + &.bottom > .floating-menu-container { justify-content: center; margin-top: var(--floating-menu-content-offset); } - &.left .floating-menu-container { + &.left > .floating-menu-container { align-items: center; margin-right: var(--floating-menu-content-offset); } - &.right .floating-menu-container { + &.right > .floating-menu-container { align-items: center; margin-left: var(--floating-menu-content-offset); } diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 354b5cf67b..c087ab0add 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -167,7 +167,7 @@ component: ParameterExposeButton, getProps: (props, index) => ({ ...props, - action: () => widgetValueCommitAndUpdate(index, undefined, true), + $$events: { selectedEntryValuePath: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) }, }), }, IconButton: { diff --git a/frontend/src/components/widgets/buttons/ParameterExposeButton.svelte b/frontend/src/components/widgets/buttons/ParameterExposeButton.svelte index 8ce109844e..201fa30db6 100644 --- a/frontend/src/components/widgets/buttons/ParameterExposeButton.svelte +++ b/frontend/src/components/widgets/buttons/ParameterExposeButton.svelte @@ -1,27 +1,44 @@ + dispatch("selectedEntryValuePath", detail)} + open={false} + entries={menuListChildren} + entriesHash={menuListChildrenHash} + direction="Bottom" + type="Popover" + drawIcon={true} + bind:this={self} + /> diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 54d3ae01dc..7b3ddae660 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -126,6 +126,9 @@ import Copy from "/../branding/assets/icon-16px-solid/copy.svg"; import Credits from "/../branding/assets/icon-16px-solid/credits.svg"; import CustomColor from "/../branding/assets/icon-16px-solid/custom-color.svg"; import Cut from "/../branding/assets/icon-16px-solid/cut.svg"; +import DataSourceGraph from "/../branding/assets/icon-16px-solid/data-source-graph.svg"; +import DataSourceTimeline from "/../branding/assets/icon-16px-solid/data-source-timeline.svg"; +import DataSourceValue from "/../branding/assets/icon-16px-solid/data-source-value.svg"; import DeselectAll from "/../branding/assets/icon-16px-solid/deselect-all.svg"; import Edit from "/../branding/assets/icon-16px-solid/edit.svg"; import Empty from "/../branding/assets/icon-16px-solid/empty.svg"; @@ -192,7 +195,6 @@ import Save from "/../branding/assets/icon-16px-solid/save.svg"; import SelectAll from "/../branding/assets/icon-16px-solid/select-all.svg"; import SelectParent from "/../branding/assets/icon-16px-solid/select-parent.svg"; import Settings from "/../branding/assets/icon-16px-solid/settings.svg"; -import SmallDot from "/../branding/assets/icon-16px-solid/small-dot.svg"; import StackBottom from "/../branding/assets/icon-16px-solid/stack-bottom.svg"; import StackHollow from "/../branding/assets/icon-16px-solid/stack-hollow.svg"; import StackLower from "/../branding/assets/icon-16px-solid/stack-lower.svg"; @@ -265,6 +267,9 @@ const SOLID_16PX = { Credits: { svg: Credits, size: 16 }, CustomColor: { svg: CustomColor, size: 16 }, Cut: { svg: Cut, size: 16 }, + DataSourceGraph: { svg: DataSourceGraph, size: 16 }, + DataSourceTimeline: { svg: DataSourceTimeline, size: 16 }, + DataSourceValue: { svg: DataSourceValue, size: 16 }, DeselectAll: { svg: DeselectAll, size: 16 }, Edit: { svg: Edit, size: 16 }, Empty: { svg: Empty, size: 16 }, @@ -331,7 +336,6 @@ const SOLID_16PX = { SelectAll: { svg: SelectAll, size: 16 }, SelectParent: { svg: SelectParent, size: 16 }, Settings: { svg: Settings, size: 16 }, - SmallDot: { svg: SmallDot, size: 16 }, Stack: { svg: Stack, size: 16 }, StackBottom: { svg: StackBottom, size: 16 }, StackHollow: { svg: StackHollow, size: 16 },