Compositor: Implement shortcuts for Viewer nodes

Artists often want to quickly switch back and forth between two or more nodes while compositing.

This patch implements two operators `NODE_OT_viewer_shortcut_set` and `NODE_OT_viewer_shortcut_get` that allow users to map a viewer node to a shortcut. For example, pressing `cltr+1` while a node is selected, assigns that node to the shortcut `1`, creates a viewer node if it has none attached and sets that viewer node to active. Pressing `1` will set the active node with shortcut `1` to active.

Shortcuts are saved in DNA to preserve them after saving/loading blend files.

Limitations:
- Only compositor node tree is supported, because shading editor has no viewer node and geometry nodes viewer works differently.

Pull Request: https://projects.blender.org/blender/blender/pulls/123641
This commit is contained in:
Habib Gahbiche 2025-01-29 18:35:26 +01:00
parent 5eb8101efa
commit b51c560f6e
9 changed files with 244 additions and 2 deletions

View File

@ -2277,6 +2277,34 @@ def km_node_editor(params):
("wm.context_toggle", {"type": 'Z', "value": 'PRESS', "alt": True, "shift": True},
{"properties": [("data_path", "space_data.overlay.show_overlays")]}),
*_template_items_context_menu("NODE_MT_context_menu", params.context_menu_event),
# Viewer shortcuts.
("node.viewer_shortcut_get", {"type": 'ONE', "value": 'PRESS'}, {"properties": [("viewer_index", 1)]}),
("node.viewer_shortcut_set", {"type": 'ONE', "value": 'PRESS',
'ctrl': True}, {"properties": [("viewer_index", 1)]}),
("node.viewer_shortcut_get", {"type": 'TWO', "value": 'PRESS'}, {"properties": [("viewer_index", 2)]}),
("node.viewer_shortcut_set", {"type": 'TWO', "value": 'PRESS',
'ctrl': True}, {"properties": [("viewer_index", 2)]}),
("node.viewer_shortcut_get", {"type": 'THREE', "value": 'PRESS'}, {"properties": [("viewer_index", 3)]}),
("node.viewer_shortcut_set", {"type": 'THREE', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 3)]}),
("node.viewer_shortcut_get", {"type": 'FOUR', "value": 'PRESS'}, {"properties": [("viewer_index", 4)]}),
("node.viewer_shortcut_set", {"type": 'FOUR', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 4)]}),
("node.viewer_shortcut_get", {"type": 'FIVE', "value": 'PRESS'}, {"properties": [("viewer_index", 5)]}),
("node.viewer_shortcut_set", {"type": 'FIVE', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 5)]}),
("node.viewer_shortcut_get", {"type": 'SIX', "value": 'PRESS'}, {"properties": [("viewer_index", 6)]}),
("node.viewer_shortcut_set", {"type": 'SIX', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 6)]}),
("node.viewer_shortcut_get", {"type": 'SEVEN', "value": 'PRESS'}, {"properties": [("viewer_index", 7)]}),
("node.viewer_shortcut_set", {"type": 'SEVEN', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 7)]}),
("node.viewer_shortcut_get", {"type": 'EIGHT', "value": 'PRESS'}, {"properties": [("viewer_index", 8)]}),
("node.viewer_shortcut_set", {"type": 'EIGHT', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 8)]}),
("node.viewer_shortcut_get", {"type": 'NINE', "value": 'PRESS'}, {"properties": [("viewer_index", 9)]}),
("node.viewer_shortcut_set", {"type": 'NINE', "value": 'PRESS', 'ctrl': True},
{"properties": [("viewer_index", 9)]}),
])
return keymap

View File

@ -16,6 +16,7 @@ from bpy.props import (
EnumProperty,
FloatVectorProperty,
StringProperty,
IntProperty,
)
from mathutils import (
Vector,
@ -408,6 +409,104 @@ class NODE_OT_interface_item_remove(NodeInterfaceOperator, Operator):
return {'FINISHED'}
class NODE_OT_viewer_shortcut_set(Operator):
"""Create a compositor viewer shortcut for the selected node by pressing ctrl+1,2,..9"""
bl_idname = "node.viewer_shortcut_set"
bl_label = "Fast Preview"
bl_options = {'REGISTER', 'UNDO'}
viewer_index: IntProperty(
name="Viewer Index",
description="Index corresponding to the shortcut, e.g. number key 1 corresponds to index 1 etc..")
def get_connected_viewer(self, node):
for out in node.outputs:
for link in out.links:
nv = link.to_node
if nv.type == 'VIEWER':
return nv
return None
@classmethod
def poll(self, context):
return context.space_data.tree_type == 'CompositorNodeTree'
def execute(self, context):
nodes = context.space_data.edit_tree.nodes
links = context.space_data.edit_tree.links
selected_nodes = context.selected_nodes
if len(selected_nodes) == 0:
self.report({'ERROR'}, "Select a node to assign a shortcut")
return {'CANCELLED'}
fav_node = selected_nodes[0]
# Only viewer nodes can be set to favorites. However, the user can
# create a new favorite viewer by selecting any node and pressing ctrl+1.
old_active = nodes.active
if fav_node.type == 'VIEWER':
viewer_node = fav_node
else:
viewer_node = self.get_connected_viewer(fav_node)
if not viewer_node:
# Calling link_viewer() if a viewer node is connected will connect the next available socket to the viewer node.
# This behavior is not desired as we want to create a shortcut to the exisiting connected viewer node.
# Therefore link_viewer() is called only when no viewer node is connected.
bpy.ops.node.link_viewer()
viewer_node = self.get_connected_viewer(fav_node)
if not viewer_node:
self.report({'ERROR'}, "Unable to set shortcut, selected node is not a viewer node or does not support viewing")
return {'CANCELLED'}
# Use the node active status to enable this viewer node and disable others.
nodes.active = viewer_node
if old_active.type != 'VIEWER':
nodes.active = old_active
viewer_node.ui_shortcut = self.viewer_index
self.report({'INFO'}, "Assigned shortcut %i to %s" % (self.viewer_index, viewer_node.name))
return {'FINISHED'}
class NODE_OT_viewer_shortcut_get(Operator):
"""Activate a specific compositor viewer node using 1,2,..,9 keys"""
bl_idname = "node.viewer_shortcut_get"
bl_label = "Fast Preview"
bl_options = {'REGISTER', 'UNDO'}
viewer_index: IntProperty(
name="Viewer Index",
description="Index corresponding to the shortcut, e.g. number key 1 corresponds to index 1 etc..")
@classmethod
def poll(self, context):
return context.space_data.tree_type == 'CompositorNodeTree'
def execute(self, context):
nodes = context.space_data.edit_tree.nodes
# Get viewer node with exisiting shortcut.
viewer_node = None
for n in nodes:
if n.type == 'VIEWER' and n.ui_shortcut == self.viewer_index:
viewer_node = n
if not viewer_node:
self.report({'INFO'}, "Shortcut %i is not assigned to a Viewer node yet" % self.viewer_index)
return {'CANCELLED'}
# Use the node active status to enable this viewer node and disable others.
old_active = nodes.active
nodes.active = viewer_node
if old_active.type != "VIEWER":
nodes.active = old_active
return {'FINISHED'}
class NODE_FH_image_node(FileHandler):
bl_idname = "NODE_FH_image_node"
bl_label = "Image node"
@ -438,4 +537,6 @@ classes = (
NODE_OT_interface_item_duplicate,
NODE_OT_interface_item_remove,
NODE_OT_tree_path_parent,
NODE_OT_viewer_shortcut_get,
NODE_OT_viewer_shortcut_set,
)

View File

@ -3674,6 +3674,9 @@ void node_tree_set_output(bNodeTree *ntree)
/* same type, exception for viewer */
const bool tnode_is_output = tnode->type_legacy == CMP_NODE_VIEWER;
const bool compositor_case = is_compositor && tnode_is_output && node_is_output;
const bool has_same_shortcut = compositor_case && node != tnode &&
tnode->custom1 == node->custom1 &&
tnode->custom1 != NODE_VIEWER_SHORTCUT_NONE;
if (tnode->type_legacy == node->type_legacy || compositor_case) {
if (tnode->flag & NODE_DO_OUTPUT) {
output++;
@ -3682,6 +3685,9 @@ void node_tree_set_output(bNodeTree *ntree)
}
}
}
if (has_same_shortcut) {
tnode->custom1 = NODE_VIEWER_SHORTCUT_NONE;
}
}
if (output == 0) {

View File

@ -1283,6 +1283,17 @@ static void do_version_color_to_float_conversion(bNodeTree *node_tree)
}
}
static void do_version_viewer_shortcut(bNodeTree *node_tree)
{
LISTBASE_FOREACH_MUTABLE (bNode *, node, &node_tree->nodes) {
if (node->type_legacy != CMP_NODE_VIEWER) {
continue;
}
/* custom1 was previously used for Tile Order for the Tiled Compositor. */
node->custom1 = NODE_VIEWER_SHORTCUT_NONE;
}
}
static bool all_scenes_use(Main *bmain, const blender::Span<const char *> engines)
{
if (!bmain->scenes.first) {
@ -5807,6 +5818,15 @@ void blo_do_versions_400(FileData *fd, Library * /*lib*/, Main *bmain)
}
}
if (!MAIN_VERSION_FILE_ATLEAST(bmain, 404, 27)) {
FOREACH_NODETREE_BEGIN (bmain, ntree, id) {
if (ntree->type == NTREE_COMPOSIT) {
do_version_viewer_shortcut(ntree);
}
}
FOREACH_NODETREE_END;
}
/* Always run this versioning; meshes are written with the legacy format which always needs to
* be converted to the new format on file load. Can be moved to a subversion check in a larger
* breaking release. */

View File

@ -3268,6 +3268,36 @@ static void node_draw_extra_info_panel(const bContext &C,
}
}
static short get_viewer_shortcut_icon(const bNode &node)
{
BLI_assert(node.is_type("CompositorNodeViewer"));
switch (node.custom1) {
case NODE_VIEWER_SHORTCUT_NONE:
/* No change by default. */
return node.typeinfo->ui_icon;
case NODE_VIEWER_SHORCTUT_SLOT_1:
return ICON_EVENT_ONEKEY;
case NODE_VIEWER_SHORCTUT_SLOT_2:
return ICON_EVENT_TWOKEY;
case NODE_VIEWER_SHORCTUT_SLOT_3:
return ICON_EVENT_THREEKEY;
case NODE_VIEWER_SHORCTUT_SLOT_4:
return ICON_EVENT_FOURKEY;
case NODE_VIEWER_SHORCTUT_SLOT_5:
return ICON_EVENT_FIVEKEY;
case NODE_VIEWER_SHORCTUT_SLOT_6:
return ICON_EVENT_SIXKEY;
case NODE_VIEWER_SHORCTUT_SLOT_7:
return ICON_EVENT_SEVENKEY;
case NODE_VIEWER_SHORCTUT_SLOT_8:
return ICON_EVENT_EIGHTKEY;
case NODE_VIEWER_SHORCTUT_SLOT_9:
return ICON_EVENT_NINEKEY;
}
return node.typeinfo->ui_icon;
}
static void node_draw_basis(const bContext &C,
TreeDrawContext &tree_draw_ctx,
const View2D &v2d,
@ -3461,6 +3491,25 @@ static void node_draw_basis(const bContext &C,
but, node_toggle_button_cb, POINTER_FROM_INT(node.identifier), (void *)operator_idname);
UI_block_emboss_set(&block, UI_EMBOSS);
}
/* Viewer node shortcuts. */
if (node.is_type("CompositorNodeViewer")) {
short shortcut_icon = get_viewer_shortcut_icon(node);
iconofs -= iconbutw;
UI_block_emboss_set(&block, UI_EMBOSS_NONE);
uiDefIconBut(&block,
UI_BTYPE_BUT,
0,
shortcut_icon,
iconofs,
rct.ymax - NODE_DY,
iconbutw,
UI_UNIT_Y,
nullptr,
0,
0,
"");
UI_block_emboss_set(&block, UI_EMBOSS);
}
node_add_error_message_button(tree_draw_ctx, node, block, rct, iconofs);

View File

@ -739,7 +739,7 @@ static int view_socket(const bContext &C,
/* Try to find a viewer that is already active. */
for (bNode *node : btree.all_nodes()) {
if (is_viewer_node(*node)) {
if (node->flag & NODE_DO_OUTPUT) {
if (node->flag & NODE_DO_OUTPUT && node->custom1 == NODE_VIEWER_SHORTCUT_NONE) {
viewer_node = node;
break;
}
@ -759,7 +759,7 @@ static int view_socket(const bContext &C,
if (viewer_node == nullptr) {
for (bNode *node : btree.all_nodes()) {
if (is_viewer_node(*node)) {
if (is_viewer_node(*node) && node->custom1 == NODE_VIEWER_SHORTCUT_NONE) {
viewer_node = node;
break;
}

View File

@ -353,6 +353,21 @@ typedef struct bNodePanelState {
#endif
} bNodePanelState;
typedef enum eViewerNodeShortcut {
NODE_VIEWER_SHORTCUT_NONE = 0,
/* Users can set custom keys to shortcuts,
* but shortcuts should always be referred to as enums. */
NODE_VIEWER_SHORCTUT_SLOT_1 = 1,
NODE_VIEWER_SHORCTUT_SLOT_2 = 2,
NODE_VIEWER_SHORCTUT_SLOT_3 = 3,
NODE_VIEWER_SHORCTUT_SLOT_4 = 4,
NODE_VIEWER_SHORCTUT_SLOT_5 = 5,
NODE_VIEWER_SHORCTUT_SLOT_6 = 6,
NODE_VIEWER_SHORCTUT_SLOT_7 = 7,
NODE_VIEWER_SHORCTUT_SLOT_8 = 8,
NODE_VIEWER_SHORCTUT_SLOT_9 = 9
} eViewerNodeShortcut;
typedef enum NodeWarningPropagation {
NODE_WARNING_PROPAGATION_ALL = 0,
NODE_WARNING_PROPAGATION_NONE = 1,

View File

@ -1356,6 +1356,20 @@ static void rna_NodeTree_active_node_set(PointerRNA *ptr,
}
}
static void rna_Node_shortcut_node_set(PointerRNA *ptr, int value)
{
bNode *curr_node = static_cast<bNode *>(ptr->data);
bNodeTree &ntree = curr_node->owner_tree();
/* Avoid having two nodes with the same shortcut. */
for (bNode *node : ntree.all_nodes()) {
if (node->is_type("CompositorNodeViewer") && node->custom1 == value) {
node->custom1 = NODE_VIEWER_SHORTCUT_NONE;
}
}
curr_node->custom1 = value;
}
static bNodeLink *rna_NodeTree_link_new(bNodeTree *ntree,
Main *bmain,
ReportList *reports,
@ -9125,6 +9139,14 @@ static void def_cmp_viewer(BlenderRNA * /*brna*/, StructRNA *srna)
"Use Alpha",
"Colors are treated alpha premultiplied, or colors output straight (alpha gets set to 1)");
RNA_def_property_update(prop, NC_NODE | NA_EDITED, "rna_Node_update");
prop = RNA_def_property(srna, "ui_shortcut", PROP_INT, PROP_NONE);
RNA_def_property_int_sdna(prop, nullptr, "custom1");
RNA_def_property_int_funcs(prop, nullptr, "rna_Node_shortcut_node_set", nullptr);
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_override_flag(prop, PROPOVERRIDE_IGNORE);
RNA_def_property_int_default(prop, NODE_VIEWER_SHORTCUT_NONE);
RNA_def_property_update(prop, NC_NODE | ND_DISPLAY, nullptr);
}
static void def_cmp_composite(BlenderRNA * /*brna*/, StructRNA *srna)

View File

@ -40,6 +40,7 @@ static void node_composit_init_viewer(bNodeTree * /*ntree*/, bNode *node)
ImageUser *iuser = MEM_cnew<ImageUser>(__func__);
node->storage = iuser;
iuser->sfra = 1;
node->custom1 = NODE_VIEWER_SHORTCUT_NONE;
node->id = (ID *)BKE_image_ensure_viewer(G.main, IMA_TYPE_COMPOSITE, "Viewer Node");
}