WIP expose button type picker

This commit is contained in:
Keavon Chambers
2026-05-02 00:53:58 -07:00
parent 696b625a3e
commit 47f2936ef3
10 changed files with 225 additions and 37 deletions

View File

@@ -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 = &parameter_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);

View File

@@ -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(&parameter_expose_button.menu_list_children);
}
_ => {}
};

View File

@@ -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,

View File

@@ -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")]
{

View File

@@ -53,7 +53,59 @@ pub fn commit_value<T>(_: &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<Type>,
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<MenuListEntry> = 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<Widget
name,
description,
input_type,
resolved_type,
valid_types,
is_subnetwork,
is_wired,
blank_assist,
exposable,
network_interface,
@@ -132,7 +206,7 @@ pub fn start_widgets(parameter_widgets_info: ParameterWidgetsInfo) -> Vec<Widget
};
let mut widgets = Vec::with_capacity(6);
if exposable {
widgets.push(expose_widget(node_id, index, input_type, input.is_exposed()));
widgets.push(expose_widget(node_id, index, input_type, resolved_type, valid_types, is_subnetwork, is_wired, input.is_exposed()));
}
widgets.push(TextLabel::new(name).tooltip_description(description).widget_instance());
@@ -2342,7 +2416,7 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper
let mut unit_suffix = None;
let input_type = match implementation {
DocumentNodeImplementation::ProtoNode(proto_node_identifier) => '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<Type>,
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,
}

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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: {

View File

@@ -1,27 +1,44 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import MenuList from "/src/components/floating-menus/MenuList.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import type { FrontendGraphDataType, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { FrontendGraphDataType, ActionShortcut, MenuListEntry } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ selectedEntryValuePath: string[] }>();
let self: MenuList;
// Content
export let exposed: boolean;
export let dataType: FrontendGraphDataType;
// Children
export let menuListChildren: MenuListEntry[][] = [];
export let menuListChildrenHash: bigint = 0n;
// Tooltips
export let tooltipLabel: string | undefined = undefined;
export let tooltipDescription: string | undefined = undefined;
export let tooltipShortcut: ActionShortcut | undefined = undefined;
// Callbacks
export let action: (e?: MouseEvent) => void;
function onClick(e: MouseEvent) {
// Focus the target so that keyboard inputs are sent to the dropdown
if (e.target instanceof HTMLElement) e.target.focus();
// Open the menu list floating menu
if (self) self.open = true;
}
</script>
<LayoutRow class="parameter-expose-button">
<button
class:exposed
class:open={self?.open}
style:--data-type-color={`var(--color-data-${dataType.toLowerCase()})`}
style:--data-type-color-dim={`var(--color-data-${dataType.toLowerCase()}-dim)`}
on:click={action}
on:click={onClick}
data-tooltip-label={tooltipLabel}
data-tooltip-description={tooltipDescription}
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
data-floating-menu-spawner
tabindex="-1"
>
{#if !exposed}
@@ -42,6 +59,16 @@
</svg>
{/if}
</button>
<MenuList
on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)}
open={false}
entries={menuListChildren}
entriesHash={menuListChildrenHash}
direction="Bottom"
type="Popover"
drawIcon={true}
bind:this={self}
/>
</LayoutRow>
<style lang="scss">
@@ -49,6 +76,7 @@
display: flex;
align-items: center;
flex: 0 0 auto;
position: relative;
max-height: 24px;
button {
@@ -70,7 +98,8 @@
fill: var(--data-type-color);
}
&:hover {
&:hover,
&.open {
.outline {
fill: var(--data-type-color);
}
@@ -80,5 +109,12 @@
}
}
}
// Anchor the floating menu's tail to the bottom-center of the small button.
// Scoped to the direct child so nested submenu floating menus aren't affected.
> :global(.floating-menu) {
left: 50%;
bottom: 0;
}
}
</style>

View File

@@ -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 },