mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
WIP expose button type picker
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user