e.g. stands for "exempli gratia" in Latin which means "for example". The best way to make sure it makes sense when writing is to just expand it to "for example". In these cases where the text was "for e.g.", that leaves us with "for for example" which makes no sense. This commit fixes all 110 cases, mostly just just replacing the words with "for example", but also restructuring the text a bit more in a few cases, mostly by moving "e.g." to the beginning of a list in parentheses. Pull Request: https://projects.blender.org/blender/blender/pulls/139596
3701 lines
129 KiB
Python
3701 lines
129 KiB
Python
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
from __future__ import annotations
|
|
|
|
import bpy
|
|
from bpy.types import (
|
|
Menu,
|
|
Operator,
|
|
)
|
|
from bpy.props import (
|
|
BoolProperty,
|
|
CollectionProperty,
|
|
EnumProperty,
|
|
FloatProperty,
|
|
IntProperty,
|
|
StringProperty,
|
|
BoolVectorProperty,
|
|
IntVectorProperty,
|
|
FloatVectorProperty,
|
|
)
|
|
from bpy.app.translations import (
|
|
pgettext_iface as iface_,
|
|
pgettext_n as n_,
|
|
pgettext_tip as tip_,
|
|
pgettext_rpt as rpt_,
|
|
contexts as i18n_contexts,
|
|
)
|
|
|
|
|
|
def _rna_path_prop_search_for_context_impl(context, edit_text, unique_attrs):
|
|
# Use the same logic as auto-completing in the Python console to expand the data-path.
|
|
from bl_console_utils.autocomplete import intellisense
|
|
context_prefix = "context."
|
|
line = context_prefix + edit_text
|
|
cursor = len(line)
|
|
namespace = {"context": context}
|
|
comp_prefix, _, comp_options = intellisense.expand(line=line, cursor=cursor, namespace=namespace, private=False)
|
|
prefix = comp_prefix[len(context_prefix):] # Strip "context."
|
|
for attr in comp_options.split("\n"):
|
|
if attr.endswith((
|
|
# Exclude function calls because they are generally not part of data-paths.
|
|
"(", ")",
|
|
# RNA properties for introspection, not useful to expand.
|
|
".bl_rna", ".rna_type",
|
|
)):
|
|
continue
|
|
# If we type/paste in complete attributes, intellisense expands with a ".", remove that again (see #134092)
|
|
attr_full = (prefix + attr.lstrip()).removesuffix(".")
|
|
if attr_full in unique_attrs:
|
|
continue
|
|
unique_attrs.add(attr_full)
|
|
yield attr_full
|
|
|
|
|
|
def rna_path_prop_search_for_context(self, context, edit_text):
|
|
# NOTE(@campbellbarton): Limiting data-path expansion is rather arbitrary.
|
|
# It's possible for example that someone would want to set a shortcut in the preferences or
|
|
# in other region types than those currently expanded. Unless there is a reasonable likelihood
|
|
# users might expand these space-type/region-type combinations - exclude them from this search.
|
|
# After all, this list is mainly intended as a hint, users are not prevented from constructing
|
|
# the data-paths themselves.
|
|
unique_attrs = set()
|
|
|
|
for window in context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
# Users are very unlikely to be setting shortcuts in the preferences, skip this.
|
|
if area.type == 'PREFERENCES':
|
|
continue
|
|
# Ignore the same region type multiple times in an area.
|
|
# Prevents the 3D-viewport quad-view from attempting to expand 3 extra times for example
|
|
region_type_unique = set()
|
|
for region in area.regions:
|
|
if region.type not in {'WINDOW', 'PREVIEW'}:
|
|
continue
|
|
if region.type in region_type_unique:
|
|
continue
|
|
region_type_unique.add(region.type)
|
|
with context.temp_override(window=window, area=area, region=region):
|
|
yield from _rna_path_prop_search_for_context_impl(context, edit_text, unique_attrs)
|
|
|
|
if not unique_attrs:
|
|
# Users *might* only have a preferences area shown, in that case just expand the current context.
|
|
yield from _rna_path_prop_search_for_context_impl(context, edit_text, unique_attrs)
|
|
|
|
|
|
rna_path_prop = StringProperty(
|
|
name="Context Attributes",
|
|
description="Context data-path (expanded using visible windows in the current .blend file)",
|
|
maxlen=1024,
|
|
search=rna_path_prop_search_for_context,
|
|
)
|
|
|
|
rna_reverse_prop = BoolProperty(
|
|
name="Reverse",
|
|
description="Cycle backwards",
|
|
default=False,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
rna_wrap_prop = BoolProperty(
|
|
name="Wrap",
|
|
description="Wrap back to the first/last values",
|
|
default=False,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
rna_relative_prop = BoolProperty(
|
|
name="Relative",
|
|
description="Apply relative to the current value (delta)",
|
|
default=False,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
rna_space_type_prop = EnumProperty(
|
|
name="Type",
|
|
items=tuple(
|
|
(e.identifier, e.name, "", e. value)
|
|
for e in bpy.types.Space.bl_rna.properties["type"].enum_items
|
|
),
|
|
default='EMPTY',
|
|
)
|
|
|
|
# Note, this can be used for more operators,
|
|
# currently not used for all "WM_OT_context_" operators.
|
|
rna_module_prop = StringProperty(
|
|
name="Module",
|
|
description="Optionally override the context with a module",
|
|
maxlen=1024,
|
|
)
|
|
|
|
|
|
def context_path_validate(context, data_path):
|
|
try:
|
|
value = eval("context.{:s}".format(data_path)) if data_path else Ellipsis
|
|
except AttributeError as ex:
|
|
if str(ex).startswith("'NoneType'"):
|
|
# One of the items in the rna path is None, just ignore this
|
|
value = Ellipsis
|
|
else:
|
|
# Print invalid path, but don't show error to the users and fully
|
|
# break the UI if the operator is bound to an event like left click.
|
|
print("context_path_validate error: context.{:s} not found (invalid keymap entry?)".format(data_path))
|
|
value = Ellipsis
|
|
|
|
return value
|
|
|
|
|
|
def context_path_to_rna_property(context, data_path):
|
|
from bl_rna_utils.data_path import property_definition_from_data_path
|
|
rna_prop = property_definition_from_data_path(context, "." + data_path)
|
|
if rna_prop is not None:
|
|
return rna_prop
|
|
return None
|
|
|
|
|
|
def context_path_decompose(data_path):
|
|
# Decompose a data_path into 3 components:
|
|
# base_path, prop_attr, prop_item, where:
|
|
# `"foo.bar["baz"].fiz().bob.buz[10][2]"`, returns...
|
|
# `("foo.bar["baz"].fiz().bob", "buz", "[10][2]")`
|
|
#
|
|
# This is useful as we often want the base and the property, ignoring any item access.
|
|
# Note that item access includes function calls since these aren't properties.
|
|
#
|
|
# Note that the `.` is removed from the start of the first and second values,
|
|
# this is done because `.attr` isn't convenient to use as an argument,
|
|
# also the convention is not to include this within the data paths or the operator logic for `bpy.ops.wm.*`.
|
|
from bl_rna_utils.data_path import decompose_data_path
|
|
path_split = decompose_data_path("." + data_path)
|
|
|
|
# Find the last property that isn't a function call.
|
|
value_prev = ""
|
|
i = len(path_split)
|
|
while (i := i - 1) >= 0:
|
|
value = path_split[i]
|
|
if value.startswith("."):
|
|
if not value_prev.startswith("("):
|
|
break
|
|
value_prev = value
|
|
|
|
if i != -1:
|
|
base_path = "".join(path_split[:i])
|
|
prop_attr = path_split[i]
|
|
prop_item = "".join(path_split[i + 1:])
|
|
|
|
if base_path:
|
|
assert base_path.startswith(".")
|
|
base_path = base_path[1:]
|
|
if prop_attr:
|
|
assert prop_attr.startswith(".")
|
|
prop_attr = prop_attr[1:]
|
|
else:
|
|
# If there are no properties, everything is an item.
|
|
# Note that should not happen in practice with values which are added onto `context`,
|
|
# include since it's correct to account for this case and not doing so will create a confusing exception.
|
|
base_path = ""
|
|
prop_attr = ""
|
|
prop_item = "".join(path_split)
|
|
|
|
return (base_path, prop_attr, prop_item)
|
|
|
|
|
|
def description_from_data_path(base, data_path, *, prefix, value=Ellipsis):
|
|
if context_path_validate(base, data_path) is Ellipsis:
|
|
return None
|
|
|
|
if (
|
|
(rna_prop := context_path_to_rna_property(base, data_path)) and
|
|
(description := tip_(rna_prop.description))
|
|
):
|
|
description = tip_("{:s}: {:s}").format(prefix, description)
|
|
if value != Ellipsis:
|
|
description = "{:s}\n{:s}: {:s}".format(description, tip_("Value"), str(value))
|
|
return description
|
|
return None
|
|
|
|
|
|
def operator_value_is_undo(value):
|
|
if value in {None, Ellipsis}:
|
|
return False
|
|
|
|
# typical properties or objects
|
|
id_data = getattr(value, "id_data", Ellipsis)
|
|
|
|
if id_data is None:
|
|
return False
|
|
elif id_data is Ellipsis:
|
|
# handle mathutils types
|
|
id_data = getattr(getattr(value, "owner", None), "id_data", None)
|
|
|
|
if id_data is None:
|
|
return False
|
|
|
|
# return True if its a non window ID type
|
|
return (
|
|
isinstance(id_data, bpy.types.ID) and
|
|
(not isinstance(id_data, (
|
|
bpy.types.WindowManager,
|
|
bpy.types.Screen,
|
|
bpy.types.Brush,
|
|
)))
|
|
)
|
|
|
|
|
|
def operator_path_is_undo(context, data_path):
|
|
data_path_head, _, _ = context_path_decompose(data_path)
|
|
|
|
# When we can't find the data owner assume no undo is needed.
|
|
if not data_path_head:
|
|
return False
|
|
|
|
value = context_path_validate(context, data_path_head)
|
|
|
|
return operator_value_is_undo(value)
|
|
|
|
|
|
def operator_path_undo_return(context, data_path):
|
|
return {'FINISHED'} if operator_path_is_undo(context, data_path) else {'CANCELLED'}
|
|
|
|
|
|
def operator_value_undo_return(value):
|
|
return {'FINISHED'} if operator_value_is_undo(value) else {'CANCELLED'}
|
|
|
|
|
|
def execute_context_assign(self, context):
|
|
data_path = self.data_path
|
|
if context_path_validate(context, data_path) is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
if getattr(self, "relative", False):
|
|
exec("context.{:s} += self.value".format(data_path))
|
|
else:
|
|
exec("context.{:s} = self.value".format(data_path))
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_set_boolean(Operator):
|
|
"""Set a context value"""
|
|
bl_idname = "wm.context_set_boolean"
|
|
bl_label = "Context Set Boolean"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: BoolProperty(
|
|
name="Value",
|
|
description="Assignment value",
|
|
default=True,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Assign"), value=props.value)
|
|
|
|
execute = execute_context_assign
|
|
|
|
|
|
class WM_OT_context_set_int(Operator): # same as enum
|
|
"""Set a context value"""
|
|
bl_idname = "wm.context_set_int"
|
|
bl_label = "Context Set"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: IntProperty(
|
|
name="Value",
|
|
description="Assign value",
|
|
default=0,
|
|
)
|
|
relative: rna_relative_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Assign"), value=props.value)
|
|
|
|
execute = execute_context_assign
|
|
|
|
|
|
class WM_OT_context_scale_float(Operator):
|
|
"""Scale a float context value"""
|
|
bl_idname = "wm.context_scale_float"
|
|
bl_label = "Context Scale Float"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: FloatProperty(
|
|
name="Value",
|
|
description="Assign value",
|
|
default=1.0,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Scale"), value=props.value)
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
if context_path_validate(context, data_path) is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
value = self.value
|
|
|
|
if value == 1.0: # nothing to do
|
|
return {'CANCELLED'}
|
|
|
|
exec("context.{:s} *= value".format(data_path))
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_scale_int(Operator):
|
|
"""Scale an int context value"""
|
|
bl_idname = "wm.context_scale_int"
|
|
bl_label = "Context Scale Int"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: FloatProperty(
|
|
name="Value",
|
|
description="Assign value",
|
|
default=1.0,
|
|
)
|
|
always_step: BoolProperty(
|
|
name="Always Step",
|
|
description="Always adjust the value by a minimum of 1 when 'value' is not 1.0",
|
|
default=True,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Scale"), value=props.value)
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
if context_path_validate(context, data_path) is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
value = self.value
|
|
|
|
if value == 1.0: # nothing to do
|
|
return {'CANCELLED'}
|
|
|
|
if getattr(self, "always_step", False):
|
|
if value > 1.0:
|
|
add = "1"
|
|
func = "max"
|
|
else:
|
|
add = "-1"
|
|
func = "min"
|
|
exec("context.{:s} = {:s}(round(context.{:s} * value), context.{:s} + {:s})".format(
|
|
data_path, func, data_path, data_path, add,
|
|
))
|
|
else:
|
|
exec("context.{:s} *= value".format(data_path))
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_set_float(Operator): # same as enum
|
|
"""Set a context value"""
|
|
bl_idname = "wm.context_set_float"
|
|
bl_label = "Context Set Float"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: FloatProperty(
|
|
name="Value",
|
|
description="Assignment value",
|
|
default=0.0,
|
|
)
|
|
relative: rna_relative_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Assign"), value=props.value)
|
|
|
|
execute = execute_context_assign
|
|
|
|
|
|
class WM_OT_context_set_string(Operator): # same as enum
|
|
"""Set a context value"""
|
|
bl_idname = "wm.context_set_string"
|
|
bl_label = "Context Set String"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: StringProperty(
|
|
name="Value",
|
|
description="Assign value",
|
|
maxlen=1024,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Assign"), value=props.value)
|
|
|
|
execute = execute_context_assign
|
|
|
|
|
|
class WM_OT_context_set_enum(Operator):
|
|
"""Set a context value"""
|
|
bl_idname = "wm.context_set_enum"
|
|
bl_label = "Context Set Enum"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: StringProperty(
|
|
name="Value",
|
|
description="Assignment value (as a string)",
|
|
maxlen=1024,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Assign"), value=props.value)
|
|
|
|
execute = execute_context_assign
|
|
|
|
|
|
class WM_OT_context_set_value(Operator):
|
|
"""Set a context value"""
|
|
bl_idname = "wm.context_set_value"
|
|
bl_label = "Context Set Value"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: StringProperty(
|
|
name="Value",
|
|
description="Assignment value (as a string)",
|
|
maxlen=1024,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Assign"), value=props.value)
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
if context_path_validate(context, data_path) is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
exec("context.{:s} = {:s}".format(data_path, self.value))
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_toggle(Operator):
|
|
"""Toggle a context value"""
|
|
bl_idname = "wm.context_toggle"
|
|
bl_label = "Context Toggle"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
module: rna_module_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
# Currently unsupported, it might be possible to extract this.
|
|
if props.module:
|
|
return None
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Toggle"))
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
|
|
module = self.module
|
|
if not module:
|
|
base = context
|
|
else:
|
|
from importlib import import_module
|
|
base = import_module(self.module)
|
|
|
|
if context_path_validate(base, data_path) is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
exec("base.{:s} = not (base.{:s})".format(data_path, data_path))
|
|
|
|
return operator_path_undo_return(base, data_path)
|
|
|
|
|
|
class WM_OT_context_toggle_enum(Operator):
|
|
"""Toggle a context value"""
|
|
bl_idname = "wm.context_toggle_enum"
|
|
bl_label = "Context Toggle Values"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value_1: StringProperty(
|
|
name="Value",
|
|
description="Toggle enum",
|
|
maxlen=1024,
|
|
)
|
|
value_2: StringProperty(
|
|
name="Value",
|
|
description="Toggle enum",
|
|
maxlen=1024,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
value = "({!r}, {!r})".format(props.value_1, props.value_2)
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Toggle"), value=value)
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
|
|
if context_path_validate(context, data_path) is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
# failing silently is not ideal, but we don't want errors for shortcut
|
|
# keys that some values that are only available in a particular context
|
|
try:
|
|
exec(
|
|
"context.{:s} = {!r} if (context.{:s} != {!r}) else {!r}".format(
|
|
data_path,
|
|
self.value_2,
|
|
data_path,
|
|
self.value_2,
|
|
self.value_1,
|
|
)
|
|
)
|
|
except Exception:
|
|
return {'PASS_THROUGH'}
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_cycle_int(Operator):
|
|
"""Set a context value (useful for cycling active material, """ \
|
|
"""shape keys, groups, etc.)"""
|
|
bl_idname = "wm.context_cycle_int"
|
|
bl_label = "Context Int Cycle"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
reverse: rna_reverse_prop
|
|
wrap: rna_wrap_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Cycle"))
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
value = context_path_validate(context, data_path)
|
|
if value is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
if self.reverse:
|
|
value -= 1
|
|
else:
|
|
value += 1
|
|
|
|
exec("context.{:s} = value".format(data_path))
|
|
|
|
if self.wrap:
|
|
if value != eval("context.{:s}".format(data_path)):
|
|
# relies on rna clamping integers out of the range
|
|
if self.reverse:
|
|
value = (1 << 31) - 1
|
|
else:
|
|
value = -1 << 31
|
|
|
|
exec("context.{:s} = value".format(data_path))
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_cycle_enum(Operator):
|
|
"""Toggle a context value"""
|
|
bl_idname = "wm.context_cycle_enum"
|
|
bl_label = "Context Enum Cycle"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
reverse: rna_reverse_prop
|
|
wrap: rna_wrap_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Cycle"))
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
value = context_path_validate(context, data_path)
|
|
if value is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
orig_value = value
|
|
|
|
rna_prop = context_path_to_rna_property(context, data_path)
|
|
if type(rna_prop) != bpy.types.EnumProperty:
|
|
raise Exception("expected an enum property")
|
|
|
|
enums = rna_prop.enum_items.keys()
|
|
orig_index = enums.index(orig_value)
|
|
|
|
# Have the info we need, advance to the next item.
|
|
#
|
|
# When wrap's disabled we may set the value to itself,
|
|
# this is done to ensure update callbacks run.
|
|
if self.reverse:
|
|
if orig_index == 0:
|
|
advance_enum = enums[-1] if self.wrap else enums[0]
|
|
else:
|
|
advance_enum = enums[orig_index - 1]
|
|
else:
|
|
if orig_index == len(enums) - 1:
|
|
advance_enum = enums[0] if self.wrap else enums[-1]
|
|
else:
|
|
advance_enum = enums[orig_index + 1]
|
|
|
|
# set the new value
|
|
exec("context.{:s} = advance_enum".format(data_path))
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_cycle_array(Operator):
|
|
"""Set a context array value """ \
|
|
"""(useful for cycling the active mesh edit mode)"""
|
|
bl_idname = "wm.context_cycle_array"
|
|
bl_label = "Context Array Cycle"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
reverse: rna_reverse_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Cycle"))
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
value = context_path_validate(context, data_path)
|
|
if value is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
def cycle(array):
|
|
if self.reverse:
|
|
array.insert(0, array.pop())
|
|
else:
|
|
array.append(array.pop(0))
|
|
return array
|
|
|
|
exec("context.{:s} = cycle(context.{:s}[:])".format(data_path, data_path))
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
class WM_OT_context_menu_enum(Operator):
|
|
bl_idname = "wm.context_menu_enum"
|
|
bl_label = "Context Enum Menu"
|
|
# The menu items & UI logic handles undo.
|
|
bl_options = {'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Menu"))
|
|
|
|
def execute(self, context):
|
|
data_path = self.data_path
|
|
value = context_path_validate(context, data_path)
|
|
|
|
if value is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
base_path, prop_attr, _ = context_path_decompose(data_path)
|
|
value_base = context_path_validate(context, base_path)
|
|
rna_prop = context_path_to_rna_property(context, data_path)
|
|
|
|
def draw_cb(self, context):
|
|
layout = self.layout
|
|
layout.prop(value_base, prop_attr, expand=True)
|
|
|
|
context.window_manager.popup_menu(draw_func=draw_cb, title=rna_prop.name, icon=rna_prop.icon)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_context_pie_enum(Operator):
|
|
bl_idname = "wm.context_pie_enum"
|
|
bl_label = "Context Enum Pie"
|
|
# The menu items & UI logic handles undo.
|
|
bl_options = {'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Pie Menu"))
|
|
|
|
def invoke(self, context, event):
|
|
wm = context.window_manager
|
|
data_path = self.data_path
|
|
value = context_path_validate(context, data_path)
|
|
|
|
if value is Ellipsis:
|
|
return {'PASS_THROUGH'}
|
|
|
|
base_path, prop_attr, _ = context_path_decompose(data_path)
|
|
value_base = context_path_validate(context, base_path)
|
|
rna_prop = context_path_to_rna_property(context, data_path)
|
|
|
|
def draw_cb(self, context):
|
|
layout = self.layout
|
|
layout.prop(value_base, prop_attr, expand=True)
|
|
|
|
wm.popup_menu_pie(draw_func=draw_cb, title=rna_prop.name, icon=rna_prop.icon, event=event)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_operator_pie_enum(Operator):
|
|
bl_idname = "wm.operator_pie_enum"
|
|
bl_label = "Operator Enum Pie"
|
|
# The menu items & UI logic handles undo.
|
|
bl_options = {'INTERNAL'}
|
|
|
|
data_path: StringProperty(
|
|
name="Operator",
|
|
description="Operator name (in Python as string)",
|
|
maxlen=1024,
|
|
)
|
|
prop_string: StringProperty(
|
|
name="Property",
|
|
description="Property name (as a string)",
|
|
maxlen=1024,
|
|
)
|
|
|
|
@classmethod
|
|
def description(cls, context, props):
|
|
return description_from_data_path(context, props.data_path, prefix=tip_("Pie Menu"))
|
|
|
|
def invoke(self, context, event):
|
|
wm = context.window_manager
|
|
|
|
data_path = self.data_path
|
|
prop_attr = self.prop_string
|
|
|
|
# same as eval("bpy.ops." + data_path)
|
|
op_mod_str, ob_id_str = data_path.split(".", 1)
|
|
op = getattr(getattr(bpy.ops, op_mod_str), ob_id_str)
|
|
del op_mod_str, ob_id_str
|
|
|
|
try:
|
|
op_rna = op.get_rna_type()
|
|
except KeyError:
|
|
self.report({'ERROR'}, rpt_("Operator not found: bpy.ops.{:s}").format(data_path))
|
|
return {'CANCELLED'}
|
|
|
|
def draw_cb(self, context):
|
|
layout = self.layout
|
|
pie = layout.menu_pie()
|
|
pie.operator_enum(data_path, prop_attr)
|
|
|
|
wm.popup_menu_pie(draw_func=draw_cb, title=op_rna.name, event=event)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_context_set_id(Operator):
|
|
"""Set a context value to an ID data-block"""
|
|
bl_idname = "wm.context_set_id"
|
|
bl_label = "Set Library ID"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path_prop
|
|
value: StringProperty(
|
|
name="Value",
|
|
description="Assign value",
|
|
maxlen=1024,
|
|
)
|
|
|
|
def execute(self, context):
|
|
value = self.value
|
|
data_path = self.data_path
|
|
|
|
# Match the pointer type from the target property to `bpy.data.*`
|
|
# so we lookup the correct list.
|
|
|
|
rna_prop = context_path_to_rna_property(context, data_path)
|
|
rna_prop_fixed_type = rna_prop.fixed_type
|
|
|
|
id_iter = None
|
|
|
|
for prop in bpy.data.rna_type.properties:
|
|
if prop.rna_type.identifier == "CollectionProperty":
|
|
if prop.fixed_type == rna_prop_fixed_type:
|
|
id_iter = prop.identifier
|
|
break
|
|
|
|
if id_iter:
|
|
value_id = getattr(bpy.data, id_iter).get(value)
|
|
exec("context.{:s} = value_id".format(data_path))
|
|
|
|
return operator_path_undo_return(context, data_path)
|
|
|
|
|
|
doc_id = StringProperty(
|
|
name="Doc ID",
|
|
maxlen=1024,
|
|
options={'HIDDEN'},
|
|
)
|
|
|
|
data_path_iter = StringProperty(
|
|
description="The data path relative to the context, must point to an iterable",
|
|
)
|
|
|
|
data_path_item = StringProperty(
|
|
description="The data path from each iterable to the value (int or float)",
|
|
)
|
|
|
|
|
|
class WM_OT_context_collection_boolean_set(Operator):
|
|
"""Set boolean values for a collection of items"""
|
|
bl_idname = "wm.context_collection_boolean_set"
|
|
bl_label = "Context Collection Boolean Set"
|
|
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
|
|
|
|
data_path_iter: data_path_iter
|
|
data_path_item: data_path_item
|
|
|
|
type: EnumProperty(
|
|
name="Type",
|
|
items=(
|
|
('TOGGLE', "Toggle", ""),
|
|
('ENABLE', "Enable", ""),
|
|
('DISABLE', "Disable", ""),
|
|
),
|
|
)
|
|
|
|
def execute(self, context):
|
|
data_path_iter = self.data_path_iter
|
|
data_path_item = self.data_path_item
|
|
|
|
items = list(getattr(context, data_path_iter))
|
|
items_ok = []
|
|
is_set = False
|
|
for item in items:
|
|
try:
|
|
value_orig = eval("item." + data_path_item)
|
|
except Exception:
|
|
continue
|
|
|
|
if value_orig is True:
|
|
is_set = True
|
|
elif value_orig is False:
|
|
pass
|
|
else:
|
|
self.report(
|
|
{'WARNING'},
|
|
rpt_("Non boolean value found: {:s}[ ].{:s}").format(data_path_iter, data_path_item),
|
|
)
|
|
return {'CANCELLED'}
|
|
|
|
items_ok.append(item)
|
|
|
|
# avoid undo push when nothing to do
|
|
if not items_ok:
|
|
return {'CANCELLED'}
|
|
|
|
if self.type == 'ENABLE':
|
|
is_set = True
|
|
elif self.type == 'DISABLE':
|
|
is_set = False
|
|
else:
|
|
is_set = not is_set
|
|
|
|
exec_str = "item.{:s} = {:s}".format(data_path_item, str(is_set))
|
|
for item in items_ok:
|
|
exec(exec_str)
|
|
|
|
return operator_value_undo_return(item)
|
|
|
|
|
|
class WM_OT_context_modal_mouse(Operator):
|
|
"""Adjust arbitrary values with mouse input"""
|
|
bl_idname = "wm.context_modal_mouse"
|
|
bl_label = "Context Modal Mouse"
|
|
bl_options = {'GRAB_CURSOR', 'BLOCKING', 'UNDO', 'INTERNAL'}
|
|
|
|
data_path_iter: data_path_iter
|
|
data_path_item: data_path_item
|
|
header_text: StringProperty(
|
|
name="Header Text",
|
|
description="Text to display in header during scale",
|
|
)
|
|
|
|
input_scale: FloatProperty(
|
|
description="Scale the mouse movement by this value before applying the delta",
|
|
default=0.01,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
invert: BoolProperty(
|
|
description="Invert the mouse input",
|
|
default=False,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
initial_x: IntProperty(options={'HIDDEN'})
|
|
|
|
def _values_store(self, context):
|
|
data_path_iter = self.data_path_iter
|
|
data_path_item = self.data_path_item
|
|
|
|
self._values = values = {}
|
|
|
|
for item in getattr(context, data_path_iter):
|
|
try:
|
|
value_orig = eval("item." + data_path_item)
|
|
except Exception:
|
|
continue
|
|
|
|
# check this can be set, maybe this is library data.
|
|
try:
|
|
exec("item.{:s} = {:s}".format(data_path_item, str(value_orig)))
|
|
except Exception:
|
|
continue
|
|
|
|
values[item] = value_orig
|
|
|
|
def _values_delta(self, delta):
|
|
delta *= self.input_scale
|
|
if self.invert:
|
|
delta = - delta
|
|
|
|
data_path_item = self.data_path_item
|
|
for item, value_orig in self._values.items():
|
|
if type(value_orig) == int:
|
|
exec("item.{:s} = int({:d})".format(data_path_item, round(value_orig + delta)))
|
|
else:
|
|
exec("item.{:s} = {:f}".format(data_path_item, value_orig + delta))
|
|
|
|
def _values_restore(self):
|
|
data_path_item = self.data_path_item
|
|
for item, value_orig in self._values.items():
|
|
exec("item.{:s} = {:s}".format(data_path_item, str(value_orig)))
|
|
|
|
self._values.clear()
|
|
|
|
def _values_clear(self):
|
|
self._values.clear()
|
|
|
|
def modal(self, context, event):
|
|
event_type = event.type
|
|
|
|
if event_type == 'MOUSEMOVE':
|
|
delta = event.mouse_x - self.initial_x
|
|
self._values_delta(delta)
|
|
header_text = self.header_text
|
|
if header_text:
|
|
if len(self._values) == 1:
|
|
(item, ) = self._values.keys()
|
|
header_text = header_text % eval("item.{:s}".format(self.data_path_item))
|
|
else:
|
|
header_text = (self.header_text % delta) + rpt_(" (delta)")
|
|
context.area.header_text_set(header_text)
|
|
|
|
elif 'LEFTMOUSE' == event_type:
|
|
item = next(iter(self._values.keys()))
|
|
self._values_clear()
|
|
context.area.header_text_set(None)
|
|
return operator_value_undo_return(item)
|
|
|
|
elif event_type in {'RIGHTMOUSE', 'ESC'}:
|
|
self._values_restore()
|
|
context.area.header_text_set(None)
|
|
return {'CANCELLED'}
|
|
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def invoke(self, context, event):
|
|
self._values_store(context)
|
|
|
|
if not self._values:
|
|
self.report(
|
|
{'WARNING'},
|
|
rpt_("Nothing to operate on: {:s}[ ].{:s}").format(
|
|
self.data_path_iter, self.data_path_item,
|
|
),
|
|
)
|
|
return {'CANCELLED'}
|
|
else:
|
|
self.initial_x = event.mouse_x
|
|
|
|
context.window_manager.modal_handler_add(self)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
|
|
class WM_OT_url_open(Operator):
|
|
"""Open a website in the web browser"""
|
|
bl_idname = "wm.url_open"
|
|
bl_label = ""
|
|
bl_options = {'INTERNAL'}
|
|
|
|
url: StringProperty(
|
|
name="URL",
|
|
description="URL to open",
|
|
)
|
|
|
|
@staticmethod
|
|
def _add_utm_param_to_url(url, utm_source):
|
|
import urllib.parse
|
|
|
|
# Parse the URL to get its domain and query parameters.
|
|
if not urllib.parse.urlparse(url).scheme:
|
|
url = "https://" + url
|
|
parsed_url = urllib.parse.urlparse(url)
|
|
|
|
# Only add a utm source if it points to a blender.org domain.
|
|
domain = parsed_url.netloc
|
|
if not (domain.endswith(".blender.org") or domain == "blender.org"):
|
|
return url
|
|
|
|
# Parse the query parameters and add or update the utm_source parameter.
|
|
query_params = urllib.parse.parse_qs(parsed_url.query)
|
|
query_params["utm_source"] = utm_source
|
|
new_query = urllib.parse.urlencode(query_params, doseq=True)
|
|
|
|
# Create a new URL with the updated query parameters.
|
|
new_url_parts = list(parsed_url)
|
|
new_url_parts[4] = new_query
|
|
new_url = urllib.parse.urlunparse(new_url_parts)
|
|
|
|
return new_url
|
|
|
|
@staticmethod
|
|
def _get_utm_source():
|
|
version = bpy.app.version_string
|
|
return "blender-" + version.replace(" ", "-").lower()
|
|
|
|
def execute(self, _context):
|
|
import webbrowser
|
|
complete_url = self._add_utm_param_to_url(self.url, self._get_utm_source())
|
|
webbrowser.open(complete_url)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_url_open_preset(Operator):
|
|
"""Open a preset website in the web browser"""
|
|
bl_idname = "wm.url_open_preset"
|
|
bl_label = "Open Preset Website"
|
|
bl_options = {'INTERNAL'}
|
|
bl_property = "type"
|
|
|
|
@staticmethod
|
|
def _wm_url_open_preset_type_items(_self, _context):
|
|
return [item for (item, _) in WM_OT_url_open_preset.preset_items]
|
|
|
|
type: EnumProperty(
|
|
name="Site",
|
|
items=WM_OT_url_open_preset._wm_url_open_preset_type_items,
|
|
)
|
|
|
|
def _url_from_bug(self, _context):
|
|
from _bpy_internal.system_info.url_prefill_runtime import url_from_blender
|
|
return url_from_blender()
|
|
|
|
def _url_from_release_notes(self, _context):
|
|
return "https://www.blender.org/download/releases/{:d}-{:d}/".format(*bpy.app.version[:2])
|
|
|
|
def _url_from_manual(self, _context):
|
|
return "https://docs.blender.org/manual/{:s}/{:d}.{:d}/".format(
|
|
bpy.utils.manual_language_code(), *bpy.app.version[:2],
|
|
)
|
|
|
|
def _url_from_api(self, _context):
|
|
return "https://docs.blender.org/api/{:d}.{:d}/".format(*bpy.app.version[:2])
|
|
|
|
# This list is: (enum_item, url) pairs.
|
|
# Allow dynamically extending.
|
|
preset_items = [
|
|
# Dynamic URL's.
|
|
(('BUG', iface_("Bug"),
|
|
tip_("Report a bug with pre-filled version information")),
|
|
_url_from_bug),
|
|
(('RELEASE_NOTES', iface_("Release Notes"),
|
|
tip_("Read about what's new in this version of Blender")),
|
|
_url_from_release_notes),
|
|
(('MANUAL', iface_("User Manual"),
|
|
tip_("The reference manual for this version of Blender")),
|
|
_url_from_manual),
|
|
(('API', iface_("Python API Reference"),
|
|
tip_("The API reference manual for this version of Blender")),
|
|
_url_from_api),
|
|
|
|
# Static URL's.
|
|
(('FUND', iface_("Development Fund"),
|
|
tip_("The donation program to support maintenance and improvements")),
|
|
"https://fund.blender.org"),
|
|
(('BLENDER', "blender.org",
|
|
tip_("Blender's official web-site")),
|
|
"https://www.blender.org"),
|
|
(('CREDITS', iface_("Credits"),
|
|
tip_("Lists committers to Blender's source code")),
|
|
"https://www.blender.org/about/credits/"),
|
|
(('EXTENSIONS', iface_("Extensions Platform"),
|
|
tip_("Online directory of free and open source extensions")),
|
|
"https://extensions.blender.org/"),
|
|
]
|
|
|
|
def execute(self, context):
|
|
url = None
|
|
type = self.type
|
|
for (item_id, _, _), url in self.preset_items:
|
|
if item_id == type:
|
|
if callable(url):
|
|
url = url(self, context)
|
|
break
|
|
|
|
return bpy.ops.wm.url_open(url=url)
|
|
|
|
|
|
class WM_OT_path_open(Operator):
|
|
"""Open a path in a file browser"""
|
|
bl_idname = "wm.path_open"
|
|
bl_label = ""
|
|
bl_options = {'INTERNAL'}
|
|
|
|
filepath: StringProperty(
|
|
subtype='FILE_PATH',
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
def execute(self, _context):
|
|
import sys
|
|
import os
|
|
import subprocess
|
|
|
|
filepath = self.filepath
|
|
|
|
if not filepath:
|
|
self.report({'ERROR'}, "File path was not set")
|
|
return {'CANCELLED'}
|
|
|
|
filepath = bpy.path.abspath(filepath)
|
|
filepath = os.path.normpath(filepath)
|
|
|
|
if not os.path.exists(filepath):
|
|
self.report({'ERROR'}, rpt_("File '{:s}' not found").format(filepath))
|
|
return {'CANCELLED'}
|
|
|
|
if sys.platform[:3] == "win":
|
|
os.startfile(filepath)
|
|
elif sys.platform == "darwin":
|
|
subprocess.check_call(["open", filepath])
|
|
else:
|
|
try:
|
|
subprocess.check_call(["xdg-open", filepath])
|
|
except Exception:
|
|
# `xdg-open` *should* be supported by recent Gnome, KDE, XFCE.
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
def _wm_doc_get_id(doc_id, *, do_url=True, url_prefix="", report=None):
|
|
|
|
def operator_exists_pair(a, b):
|
|
# Not fast, this is only for docs.
|
|
return b in dir(getattr(bpy.ops, a))
|
|
|
|
def operator_exists_single(a):
|
|
a, b = a.partition("_OT_")[::2]
|
|
return operator_exists_pair(a.lower(), b)
|
|
|
|
id_split = doc_id.split(".")
|
|
url = rna = None
|
|
|
|
if len(id_split) == 1: # rna, class
|
|
if do_url:
|
|
url = "{:s}/bpy.types.{:s}.html".format(url_prefix, id_split[0])
|
|
else:
|
|
rna = "bpy.types.{:s}".format(id_split[0])
|
|
|
|
elif len(id_split) == 2: # rna, class.prop
|
|
class_name, class_prop = id_split
|
|
|
|
# an operator (common case - just button referencing an op)
|
|
if operator_exists_pair(class_name, class_prop):
|
|
if do_url:
|
|
url = "{:s}/bpy.ops.{:s}.html#bpy.ops.{:s}.{:s}".format(url_prefix, class_name, class_name, class_prop)
|
|
else:
|
|
rna = "bpy.ops.{:s}.{:s}".format(class_name, class_prop)
|
|
elif operator_exists_single(class_name):
|
|
# note: ignore the prop name since we don't have a way to link into it
|
|
class_name, class_prop = class_name.split("_OT_", 1)
|
|
class_name = class_name.lower()
|
|
if do_url:
|
|
url = "{:s}/bpy.ops.{:s}.html#bpy.ops.{:s}.{:s}".format(url_prefix, class_name, class_name, class_prop)
|
|
else:
|
|
rna = "bpy.ops.{:s}.{:s}".format(class_name, class_prop)
|
|
else:
|
|
# An RNA setting, common case.
|
|
|
|
# Check the built-in RNA types.
|
|
rna_class = getattr(bpy.types, class_name, None)
|
|
if rna_class is None:
|
|
# Check class for dynamically registered types.
|
|
rna_class = bpy.types.PropertyGroup.bl_rna_get_subclass_py(class_name)
|
|
|
|
if rna_class is None:
|
|
if report is not None:
|
|
report({'ERROR'}, rpt_("Type \"{:s}\" cannot be found").format(class_name))
|
|
return None
|
|
|
|
# Detect if this is a inherited member and use that name instead.
|
|
rna_parent = rna_class.bl_rna
|
|
rna_prop = rna_parent.properties.get(class_prop)
|
|
if rna_prop:
|
|
rna_parent = rna_parent.base
|
|
while rna_parent and rna_prop == rna_parent.properties.get(class_prop):
|
|
class_name = rna_parent.identifier
|
|
rna_parent = rna_parent.base
|
|
|
|
if do_url:
|
|
url = "{:s}/bpy.types.{:s}.html#bpy.types.{:s}.{:s}".format(
|
|
url_prefix, class_name, class_name, class_prop,
|
|
)
|
|
else:
|
|
rna = "bpy.types.{:s}.{:s}".format(class_name, class_prop)
|
|
else:
|
|
# We assume this is custom property, only try to generate generic url/rna_id...
|
|
if do_url:
|
|
url = ("{:s}/bpy.types.bpy_struct.html#bpy.types.bpy_struct.items".format(url_prefix))
|
|
else:
|
|
rna = "bpy.types.bpy_struct"
|
|
|
|
return url if do_url else rna
|
|
|
|
|
|
class WM_OT_doc_view_manual(Operator):
|
|
"""Load online manual"""
|
|
bl_idname = "wm.doc_view_manual"
|
|
bl_label = "View Manual"
|
|
|
|
doc_id: doc_id
|
|
|
|
@staticmethod
|
|
def _find_reference(rna_id, url_mapping, *, verbose=True):
|
|
if verbose:
|
|
print("online manual check for: '{:s}'... ".format(rna_id))
|
|
from fnmatch import fnmatchcase
|
|
# XXX, for some reason all RNA ID's are stored lowercase
|
|
# Adding case into all ID's isn't worth the hassle so force lowercase.
|
|
rna_id = rna_id.lower()
|
|
|
|
# NOTE: `fnmatch` in Python is slow as it translates the string to a regular-expression
|
|
# which needs to be compiled (as of Python 3.11), this is slow enough to cause a noticeable
|
|
# delay when opening manual links (approaching half a second).
|
|
#
|
|
# Resolve by matching characters that have a special meaning to `fnmatch`.
|
|
# The characters that can occur as the first special character are `*?[`.
|
|
# If any of these are used we must let `fnmatch` run its own matching logic.
|
|
# However, in most cases a literal prefix is used making it considerably faster
|
|
# to do a simple `startswith` check before performing a full match.
|
|
# An alternative solution could be to use `fnmatch` from C which is significantly
|
|
# faster than Python's, see !104581 for details.
|
|
import re
|
|
re_match_non_special = re.compile(r"^[^?\*\[]+").match
|
|
|
|
for pattern, url_suffix in url_mapping:
|
|
|
|
# Simple optimization, makes a big difference (over 50x speedup).
|
|
# Even when `non_special.end()` is zero (resulting in an empty-string),
|
|
# the `startswith` check succeeds so there is no need to check for an empty match.
|
|
non_special = re_match_non_special(pattern)
|
|
if non_special is None or not rna_id.startswith(pattern[:non_special.end()]):
|
|
continue
|
|
# End simple optimization.
|
|
|
|
if fnmatchcase(rna_id, pattern):
|
|
if verbose:
|
|
print(" match found: '{:s}' --> '{:s}'".format(pattern, url_suffix))
|
|
return url_suffix
|
|
if verbose:
|
|
print("match not found")
|
|
return None
|
|
|
|
@staticmethod
|
|
def _lookup_rna_url(rna_id, verbose=True):
|
|
for prefix, url_manual_mapping in bpy.utils.manual_map():
|
|
rna_ref = WM_OT_doc_view_manual._find_reference(rna_id, url_manual_mapping, verbose=verbose)
|
|
if rna_ref is not None:
|
|
url = prefix + rna_ref
|
|
return url
|
|
|
|
def execute(self, _context):
|
|
rna_id = _wm_doc_get_id(self.doc_id, do_url=False, report=self.report)
|
|
if rna_id is None:
|
|
return {'CANCELLED'}
|
|
|
|
url = self._lookup_rna_url(rna_id)
|
|
|
|
if url is None:
|
|
self.report(
|
|
{'WARNING'},
|
|
rpt_("No reference available {!r}, "
|
|
"Update info in 'rna_manual_reference.py' "
|
|
"or callback to bpy.utils.manual_map()").format(self.doc_id)
|
|
)
|
|
return {'CANCELLED'}
|
|
else:
|
|
return bpy.ops.wm.url_open(url=url)
|
|
|
|
|
|
class WM_OT_doc_view(Operator):
|
|
"""Open online reference docs in a web browser"""
|
|
bl_idname = "wm.doc_view"
|
|
bl_label = "View Documentation"
|
|
|
|
doc_id: doc_id
|
|
_prefix = "https://docs.blender.org/api/{:d}.{:d}".format(*bpy.app.version[:2])
|
|
|
|
def execute(self, _context):
|
|
url = _wm_doc_get_id(self.doc_id, do_url=True, url_prefix=self._prefix, report=self.report)
|
|
if url is None:
|
|
return {'CANCELLED'}
|
|
|
|
return bpy.ops.wm.url_open(url=url)
|
|
|
|
|
|
rna_path = StringProperty(
|
|
name="Property Edit",
|
|
description="Property data_path edit",
|
|
maxlen=1024,
|
|
options={'HIDDEN'},
|
|
)
|
|
|
|
rna_custom_property_name = StringProperty(
|
|
name="Property Name",
|
|
description="Property name edit",
|
|
# Match `MAX_IDPROP_NAME - 1` in Blender's source.
|
|
maxlen=63,
|
|
)
|
|
|
|
# Most useful entries of rna_enum_property_subtype_items:
|
|
rna_custom_property_type_items = (
|
|
('FLOAT', "Float", "A single floating-point value"),
|
|
('FLOAT_ARRAY', "Float Array", "An array of floating-point values"),
|
|
('INT', "Integer", "A single integer"),
|
|
('INT_ARRAY', "Integer Array", "An array of integers"),
|
|
('BOOL', "Boolean", "A true or false value"),
|
|
('BOOL_ARRAY', "Boolean Array", "An array of true or false values"),
|
|
('STRING', "String", "A string value"),
|
|
('DATA_BLOCK', "Data-Block", "A data-block value"),
|
|
('PYTHON', "Python", "Edit a Python value directly, for unsupported property types"),
|
|
)
|
|
|
|
rna_custom_property_subtype_none_item = (
|
|
'NONE', n_("Plain Data", i18n_contexts.unit), n_("Data values without special behavior")
|
|
)
|
|
|
|
rna_custom_property_subtype_number_items = (
|
|
rna_custom_property_subtype_none_item,
|
|
('PIXEL', n_("Pixel", i18n_contexts.unit), n_("A distance on screen")),
|
|
('PERCENTAGE', n_("Percentage", i18n_contexts.unit), n_("A percentage between 0 and 100")),
|
|
('FACTOR', n_("Factor", i18n_contexts.unit), n_("A factor between 0.0 and 1.0")),
|
|
('ANGLE', n_("Angle", i18n_contexts.unit), n_("A rotational value specified in radians")),
|
|
('TIME_ABSOLUTE', n_("Time", i18n_contexts.unit), n_("Time specified in seconds")),
|
|
('DISTANCE', n_("Distance", i18n_contexts.unit), n_("A distance between two points")),
|
|
('POWER', n_("Power", i18n_contexts.unit), ""),
|
|
('TEMPERATURE', n_("Temperature", i18n_contexts.unit), ""),
|
|
)
|
|
|
|
rna_custom_property_subtype_vector_items = (
|
|
rna_custom_property_subtype_none_item,
|
|
('COLOR', n_("Linear Color", i18n_contexts.unit), n_("Color in the linear space")),
|
|
('COLOR_GAMMA', n_("Gamma-Corrected Color", i18n_contexts.unit), n_("Color in the gamma corrected space")),
|
|
('TRANSLATION', n_("Translation", i18n_contexts.unit), ""),
|
|
('DIRECTION', n_("Direction", i18n_contexts.unit), ""),
|
|
('VELOCITY', n_("Velocity", i18n_contexts.unit), ""),
|
|
('ACCELERATION', n_("Acceleration", i18n_contexts.unit), ""),
|
|
('EULER', n_("Euler Angles", i18n_contexts.unit), n_("Euler rotation angles in radians")),
|
|
('QUATERNION', n_("Quaternion Rotation", i18n_contexts.unit), n_("Quaternion rotation (affects NLA blending)")),
|
|
('AXISANGLE', n_("Axis-Angle", i18n_contexts.unit), n_("Angle and axis to rotate around")),
|
|
('XYZ', n_("XYZ", i18n_contexts.unit), ""),
|
|
)
|
|
|
|
rna_id_type_items = tuple(
|
|
(item.identifier, item.name, item.description, item.icon, item.value)
|
|
for item in bpy.types.ID.bl_rna.properties["id_type"].enum_items
|
|
)
|
|
|
|
|
|
class WM_OT_properties_edit(Operator):
|
|
"""Change a custom property's type, or adjust how it is displayed in the interface"""
|
|
bl_idname = "wm.properties_edit"
|
|
bl_label = "Edit Property"
|
|
# register only because invoke_props_popup requires.
|
|
bl_options = {'REGISTER', 'INTERNAL'}
|
|
|
|
def subtype_items_cb(self, context):
|
|
match self.property_type:
|
|
case 'FLOAT':
|
|
return rna_custom_property_subtype_number_items
|
|
case 'FLOAT_ARRAY':
|
|
return rna_custom_property_subtype_vector_items
|
|
case _:
|
|
# Needed so 'NONE' can always be assigned.
|
|
return (
|
|
rna_custom_property_subtype_none_item,
|
|
)
|
|
|
|
def property_type_update_cb(self, context):
|
|
self.subtype = 'NONE'
|
|
|
|
# Common settings used for all property types. Generally, separate properties are used for each
|
|
# type to improve the experience when choosing UI data values.
|
|
|
|
data_path: rna_path
|
|
property_name: rna_custom_property_name
|
|
property_type: EnumProperty(
|
|
name="Type",
|
|
items=rna_custom_property_type_items,
|
|
update=property_type_update_cb,
|
|
)
|
|
is_overridable_library: BoolProperty(
|
|
name="Library Overridable",
|
|
description="Allow the property to be overridden when the data-block is linked",
|
|
default=False,
|
|
)
|
|
description: StringProperty(
|
|
name="Description",
|
|
)
|
|
|
|
# Shared for integer and string properties.
|
|
|
|
use_soft_limits: BoolProperty(
|
|
name="Soft Limits",
|
|
description=(
|
|
"Limits the Property Value slider to a range, "
|
|
"values outside the range must be inputted numerically"
|
|
),
|
|
)
|
|
array_length: IntProperty(
|
|
name="Array Length",
|
|
default=3,
|
|
min=1,
|
|
# 32 is the maximum size for RNA array properties.
|
|
max=32,
|
|
)
|
|
|
|
# Integer properties.
|
|
|
|
# This property stores values for both array and non-array properties.
|
|
default_int: IntVectorProperty(
|
|
name="Default Value",
|
|
size=32,
|
|
)
|
|
min_int: IntProperty(
|
|
name="Min",
|
|
default=-10000,
|
|
)
|
|
max_int: IntProperty(
|
|
name="Max",
|
|
default=10000,
|
|
)
|
|
soft_min_int: IntProperty(
|
|
name="Soft Min",
|
|
default=-10000,
|
|
)
|
|
soft_max_int: IntProperty(
|
|
name="Soft Max",
|
|
default=10000,
|
|
)
|
|
step_int: IntProperty(
|
|
name="Step",
|
|
min=1,
|
|
default=1,
|
|
)
|
|
|
|
# Boolean properties.
|
|
|
|
# This property stores values for both array and non-array properties.
|
|
default_bool: BoolVectorProperty(
|
|
name="Default Value",
|
|
size=32,
|
|
)
|
|
|
|
# Float properties.
|
|
|
|
# This property stores values for both array and non-array properties.
|
|
default_float: FloatVectorProperty(
|
|
name="Default Value",
|
|
size=32,
|
|
)
|
|
min_float: FloatProperty(
|
|
name="Min",
|
|
default=-10000.0,
|
|
)
|
|
max_float: FloatProperty(
|
|
name="Max",
|
|
default=-10000.0,
|
|
)
|
|
soft_min_float: FloatProperty(
|
|
name="Soft Min",
|
|
default=-10000.0,
|
|
)
|
|
soft_max_float: FloatProperty(
|
|
name="Soft Max",
|
|
default=-10000.0,
|
|
)
|
|
precision: IntProperty(
|
|
name="Precision",
|
|
default=3,
|
|
min=0,
|
|
max=8,
|
|
)
|
|
step_float: FloatProperty(
|
|
name="Step",
|
|
default=0.1,
|
|
min=0.001,
|
|
)
|
|
subtype: EnumProperty(
|
|
name="Subtype",
|
|
items=subtype_items_cb,
|
|
translation_context=i18n_contexts.unit,
|
|
)
|
|
|
|
# String properties.
|
|
|
|
default_string: StringProperty(
|
|
name="Default Value",
|
|
maxlen=1024,
|
|
)
|
|
|
|
# Data-block properties.
|
|
|
|
id_type: EnumProperty(
|
|
name="ID Type",
|
|
items=rna_id_type_items,
|
|
translation_context=i18n_contexts.id_id,
|
|
default='OBJECT',
|
|
)
|
|
|
|
# Store the value converted to a string as a fallback for otherwise unsupported types.
|
|
eval_string: StringProperty(
|
|
name="Value",
|
|
description="Python value for unsupported custom property types",
|
|
)
|
|
|
|
# Helper method to avoid repetitive code to retrieve a single value from sequences and non-sequences.
|
|
@staticmethod
|
|
def _convert_new_value_single(old_value, new_type):
|
|
if hasattr(old_value, "__len__") and len(old_value) > 0:
|
|
return new_type(old_value[0])
|
|
return new_type(old_value)
|
|
|
|
# Helper method to create a list of a given value and type, using a sequence or non-sequence old value.
|
|
@staticmethod
|
|
def _convert_new_value_array(old_value, new_type, new_len):
|
|
if hasattr(old_value, "__len__"):
|
|
new_array = [new_type()] * new_len
|
|
for i in range(min(len(old_value), new_len)):
|
|
new_array[i] = new_type(old_value[i])
|
|
return new_array
|
|
return [new_type(old_value)] * new_len
|
|
|
|
# Convert an old property for a string, avoiding unhelpful string representations for custom list types.
|
|
@staticmethod
|
|
def convert_custom_property_to_string(item, name):
|
|
# The IDProperty group view API currently doesn't have a "lookup" method.
|
|
for key, value in item.items():
|
|
if key == name:
|
|
old_value = value
|
|
break
|
|
|
|
# In order to get a better string conversion, convert the property to a builtin sequence type first.
|
|
to_dict = getattr(old_value, "to_dict", None)
|
|
to_list = getattr(old_value, "to_list", None)
|
|
if to_dict:
|
|
old_value = to_dict()
|
|
elif to_list:
|
|
old_value = to_list()
|
|
|
|
return str(old_value)
|
|
|
|
# Retrieve the current type of the custom property on the RNA struct. Some properties like group properties
|
|
# can be created in the UI, but editing their meta-data isn't supported. In that case, return 'PYTHON'.
|
|
@staticmethod
|
|
def get_property_type(item, property_name):
|
|
from rna_prop_ui import (
|
|
rna_idprop_value_item_type,
|
|
)
|
|
|
|
prop_value = item[property_name]
|
|
|
|
prop_type, is_array = rna_idprop_value_item_type(prop_value)
|
|
if prop_type == int:
|
|
if is_array:
|
|
return 'INT_ARRAY'
|
|
return 'INT'
|
|
elif prop_type == float:
|
|
if is_array:
|
|
return 'FLOAT_ARRAY'
|
|
return 'FLOAT'
|
|
elif prop_type == bool:
|
|
if is_array:
|
|
return 'BOOL_ARRAY'
|
|
return 'BOOL'
|
|
elif prop_type == str:
|
|
if is_array:
|
|
return 'PYTHON'
|
|
return 'STRING'
|
|
elif prop_type == type(None) or issubclass(prop_type, bpy.types.ID):
|
|
if is_array:
|
|
return 'PYTHON'
|
|
return 'DATA_BLOCK'
|
|
|
|
return 'PYTHON'
|
|
|
|
# For `DATA_BLOCK` types, return the `id_type` or an empty string for non data-block types.
|
|
@staticmethod
|
|
def get_property_id_type(item, property_name):
|
|
ui_data = item.id_properties_ui(property_name)
|
|
rna_data = ui_data.as_dict()
|
|
# For non `DATA_BLOCK` types, the `id_type` wont exist.
|
|
return rna_data.get("id_type", "")
|
|
|
|
def _init_subtype(self, subtype):
|
|
self.subtype = subtype or 'NONE'
|
|
|
|
# Fill the operator's properties with the UI data properties from the existing custom property.
|
|
# Note that if the UI data doesn't exist yet, the access will create it and use those default values.
|
|
def _fill_old_ui_data(self, item, name):
|
|
ui_data = item.id_properties_ui(name)
|
|
rna_data = ui_data.as_dict()
|
|
|
|
if self.property_type in {'FLOAT', 'FLOAT_ARRAY'}:
|
|
self.min_float = rna_data["min"]
|
|
self.max_float = rna_data["max"]
|
|
self.soft_min_float = rna_data["soft_min"]
|
|
self.soft_max_float = rna_data["soft_max"]
|
|
self.precision = rna_data["precision"]
|
|
self.step_float = rna_data["step"]
|
|
if rna_data["subtype"] in [item[0] for item in self.subtype_items_cb(None)]:
|
|
self.subtype = rna_data["subtype"]
|
|
self.use_soft_limits = (
|
|
self.min_float != self.soft_min_float or
|
|
self.max_float != self.soft_max_float
|
|
)
|
|
default = self._convert_new_value_array(rna_data["default"], float, 32)
|
|
self.default_float = default if isinstance(default, list) else [default] * 32
|
|
elif self.property_type in {'INT', 'INT_ARRAY'}:
|
|
self.min_int = rna_data["min"]
|
|
self.max_int = rna_data["max"]
|
|
self.soft_min_int = rna_data["soft_min"]
|
|
self.soft_max_int = rna_data["soft_max"]
|
|
self.step_int = rna_data["step"]
|
|
self.use_soft_limits = (
|
|
self.min_int != self.soft_min_int or
|
|
self.max_int != self.soft_max_int
|
|
)
|
|
self.default_int = self._convert_new_value_array(rna_data["default"], int, 32)
|
|
elif self.property_type == 'STRING':
|
|
self.default_string = rna_data["default"]
|
|
elif self.property_type in {'BOOL', 'BOOL_ARRAY'}:
|
|
self.default_bool = self._convert_new_value_array(rna_data["default"], bool, 32)
|
|
elif self.property_type == 'DATA_BLOCK':
|
|
self.id_type = rna_data["id_type"]
|
|
|
|
if self.property_type in {'FLOAT_ARRAY', 'INT_ARRAY', 'BOOL_ARRAY'}:
|
|
self.array_length = len(item[name])
|
|
|
|
# The dictionary does not contain the description if it was empty.
|
|
self.description = rna_data.get("description", "")
|
|
|
|
self._init_subtype(self.subtype)
|
|
escaped_name = bpy.utils.escape_identifier(name)
|
|
self.is_overridable_library = bool(item.is_property_overridable_library('["{:s}"]'.format(escaped_name)))
|
|
|
|
# When the operator chooses a different type than the original property,
|
|
# attempt to convert the old value to the new type for continuity and speed.
|
|
def _get_converted_value(self, item, name_old, prop_type_new, id_type_old, id_type_new):
|
|
if prop_type_new == 'INT':
|
|
return self._convert_new_value_single(item[name_old], int)
|
|
elif prop_type_new == 'FLOAT':
|
|
return self._convert_new_value_single(item[name_old], float)
|
|
elif prop_type_new == 'BOOL':
|
|
return self._convert_new_value_single(item[name_old], bool)
|
|
elif prop_type_new == 'INT_ARRAY':
|
|
prop_type_old = self.get_property_type(item, name_old)
|
|
if prop_type_old in {'INT', 'FLOAT', 'INT_ARRAY', 'FLOAT_ARRAY', 'BOOL_ARRAY'}:
|
|
return self._convert_new_value_array(item[name_old], int, self.array_length)
|
|
elif prop_type_new == 'FLOAT_ARRAY':
|
|
prop_type_old = self.get_property_type(item, name_old)
|
|
if prop_type_old in {'INT', 'FLOAT', 'FLOAT_ARRAY', 'INT_ARRAY', 'BOOL_ARRAY'}:
|
|
return self._convert_new_value_array(item[name_old], float, self.array_length)
|
|
elif prop_type_new == 'BOOL_ARRAY':
|
|
prop_type_old = self.get_property_type(item, name_old)
|
|
if prop_type_old in {'INT', 'FLOAT', 'FLOAT_ARRAY', 'INT_ARRAY', 'BOOL_ARRAY'}:
|
|
return self._convert_new_value_array(item[name_old], bool, self.array_length)
|
|
else:
|
|
return [False] * self.array_length
|
|
elif prop_type_new == 'STRING':
|
|
return self.convert_custom_property_to_string(item, name_old)
|
|
elif prop_type_new == 'DATA_BLOCK':
|
|
if id_type_old != id_type_new:
|
|
return None
|
|
old_value = item[name_old]
|
|
if not isinstance(old_value, bpy.types.ID):
|
|
return None
|
|
return old_value
|
|
|
|
# If all else fails, create an empty string property. That should avoid errors later on anyway.
|
|
return ""
|
|
|
|
# Any time the target type is changed in the dialog, it's helpful to convert the UI data values
|
|
# to the new type as well, when possible, currently this only applies for floats and ints.
|
|
def _convert_old_ui_data_to_new_type(self, prop_type_old, prop_type_new):
|
|
if prop_type_new in {'INT', 'INT_ARRAY'} and prop_type_old in {'FLOAT', 'FLOAT_ARRAY'}:
|
|
self.min_int = int(self.min_float)
|
|
self.max_int = int(self.max_float)
|
|
self.soft_min_int = int(self.soft_min_float)
|
|
self.soft_max_int = int(self.soft_max_float)
|
|
self.default_int = self._convert_new_value_array(self.default_float, int, 32)
|
|
elif prop_type_new in {'FLOAT', 'FLOAT_ARRAY'} and prop_type_old in {'INT', 'INT_ARRAY'}:
|
|
self.min_float = float(self.min_int)
|
|
self.max_float = float(self.max_int)
|
|
self.soft_min_float = float(self.soft_min_int)
|
|
self.soft_max_float = float(self.soft_max_int)
|
|
self.default_float = self._convert_new_value_array(self.default_int, float, 32)
|
|
elif prop_type_new in {'BOOL', 'BOOL_ARRAY'} and prop_type_old in {'INT', 'INT_ARRAY'}:
|
|
self.default_bool = self._convert_new_value_array(self.default_int, bool, 32)
|
|
|
|
# Don't convert between string and float/int defaults here, it's not expected like the other conversions.
|
|
|
|
# Fill the property's UI data with the values chosen in the operator.
|
|
def _create_ui_data_for_new_prop(self, item, name, prop_type_new):
|
|
if prop_type_new in {'INT', 'INT_ARRAY'}:
|
|
ui_data = item.id_properties_ui(name)
|
|
ui_data.update(
|
|
min=self.min_int,
|
|
max=self.max_int,
|
|
soft_min=self.soft_min_int if self.use_soft_limits else self.min_int,
|
|
soft_max=self.soft_max_int if self.use_soft_limits else self.max_int,
|
|
step=self.step_int,
|
|
default=self.default_int[0] if prop_type_new == 'INT' else self.default_int[:self.array_length],
|
|
description=self.description,
|
|
)
|
|
elif prop_type_new in {'BOOL', 'BOOL_ARRAY'}:
|
|
ui_data = item.id_properties_ui(name)
|
|
ui_data.update(
|
|
default=self.default_bool[0] if prop_type_new == 'BOOL' else self.default_bool[:self.array_length],
|
|
description=self.description,
|
|
)
|
|
elif prop_type_new in {'FLOAT', 'FLOAT_ARRAY'}:
|
|
ui_data = item.id_properties_ui(name)
|
|
ui_data.update(
|
|
min=self.min_float,
|
|
max=self.max_float,
|
|
soft_min=self.soft_min_float if self.use_soft_limits else self.min_float,
|
|
soft_max=self.soft_max_float if self.use_soft_limits else self.max_float,
|
|
step=self.step_float,
|
|
precision=self.precision,
|
|
default=self.default_float[0] if prop_type_new == 'FLOAT' else self.default_float[:self.array_length],
|
|
description=self.description,
|
|
subtype=self.subtype,
|
|
)
|
|
elif prop_type_new == 'STRING':
|
|
ui_data = item.id_properties_ui(name)
|
|
ui_data.update(
|
|
default=self.default_string,
|
|
description=self.description,
|
|
)
|
|
elif prop_type_new == 'DATA_BLOCK':
|
|
ui_data = item.id_properties_ui(name)
|
|
ui_data.update(
|
|
description=self.description,
|
|
id_type=self.id_type,
|
|
)
|
|
|
|
escaped_name = bpy.utils.escape_identifier(name)
|
|
item.property_overridable_library_set('["{:s}"]'.format(escaped_name), self.is_overridable_library)
|
|
|
|
def _update_blender_for_prop_change(self, context, item, name, prop_type_old, prop_type_new):
|
|
from rna_prop_ui import (
|
|
rna_idprop_ui_prop_update,
|
|
)
|
|
|
|
rna_idprop_ui_prop_update(item, name)
|
|
|
|
# If we have changed the type of the property, update its potential anim curves!
|
|
if prop_type_old != prop_type_new:
|
|
escaped_name = bpy.utils.escape_identifier(name)
|
|
data_path = '["{:s}"]'.format(escaped_name)
|
|
done = set()
|
|
|
|
def _update(fcurves):
|
|
for fcu in fcurves:
|
|
if fcu not in done and fcu.data_path == data_path:
|
|
fcu.update_autoflags(item)
|
|
done.add(fcu)
|
|
|
|
def _update_strips(strips):
|
|
for st in strips:
|
|
if st.type == 'CLIP' and st.action:
|
|
_update(st.action.fcurves)
|
|
elif st.type == 'META':
|
|
_update_strips(st.strips)
|
|
|
|
adt = getattr(item, "animation_data", None)
|
|
if adt is not None:
|
|
if adt.action:
|
|
_update(adt.action.fcurves)
|
|
if adt.drivers:
|
|
_update(adt.drivers)
|
|
if adt.nla_tracks:
|
|
for nt in adt.nla_tracks:
|
|
_update_strips(nt.strips)
|
|
|
|
# Otherwise existing buttons which reference freed memory may crash Blender (#26510).
|
|
for win in context.window_manager.windows:
|
|
for area in win.screen.areas:
|
|
area.tag_redraw()
|
|
|
|
def execute(self, context):
|
|
name_old = getattr(self, "_old_prop_name", [None])[0]
|
|
if name_old is None:
|
|
self.report({'ERROR'}, "Direct execution not supported")
|
|
return {'CANCELLED'}
|
|
|
|
data_path = self.data_path
|
|
name = self.property_name
|
|
|
|
item = eval("context.{:s}".format(data_path))
|
|
if (item.id_data and item.id_data.override_library and item.id_data.override_library.reference):
|
|
self.report({'ERROR'}, "Cannot edit properties from override data")
|
|
return {'CANCELLED'}
|
|
|
|
prop_type_old = self.get_property_type(item, name_old)
|
|
prop_type_new = self.property_type
|
|
self._old_prop_name[:] = [name]
|
|
|
|
id_type_old = self.get_property_id_type(item, name_old)
|
|
id_type_new = self.id_type
|
|
|
|
if prop_type_new == 'PYTHON':
|
|
try:
|
|
new_value = eval(self.eval_string)
|
|
except Exception as ex:
|
|
self.report({'WARNING'}, "Python evaluation failed: " + str(ex))
|
|
return {'CANCELLED'}
|
|
try:
|
|
item[name] = new_value
|
|
except Exception as ex:
|
|
self.report({'ERROR'}, "Failed to assign value: " + str(ex))
|
|
return {'CANCELLED'}
|
|
if name_old != name:
|
|
del item[name_old]
|
|
else:
|
|
new_value = self._get_converted_value(item, name_old, prop_type_new, id_type_old, id_type_new)
|
|
del item[name_old]
|
|
item[name] = new_value
|
|
|
|
self._create_ui_data_for_new_prop(item, name, prop_type_new)
|
|
|
|
self._update_blender_for_prop_change(context, item, name, prop_type_old, prop_type_new)
|
|
|
|
if name_old != name:
|
|
adt = getattr(item, "animation_data", None)
|
|
if adt is not None:
|
|
adt.fix_paths_rename_all(prefix="", old_name=name_old, new_name=name)
|
|
|
|
return {'FINISHED'}
|
|
|
|
def invoke(self, context, _event):
|
|
data_path = self.data_path
|
|
if not data_path:
|
|
self.report({'ERROR'}, "Data path not set")
|
|
return {'CANCELLED'}
|
|
|
|
name = self.property_name
|
|
|
|
self._old_prop_name = [name]
|
|
|
|
item = eval("context.{:s}".format(data_path))
|
|
if (item.id_data and item.id_data.override_library and item.id_data.override_library.reference):
|
|
self.report({'ERROR'}, "Properties from override data cannot be edited")
|
|
return {'CANCELLED'}
|
|
|
|
# Set operator's property type with the type of the existing property, to display the right settings.
|
|
old_type = self.get_property_type(item, name)
|
|
self.property_type = old_type
|
|
self.last_property_type = old_type
|
|
|
|
# So that the operator can do something for unsupported properties, change the property into
|
|
# a string, just for editing in the dialog. When the operator executes, it will be converted back
|
|
# into a python value. Always do this conversion, in case the Python property edit type is selected.
|
|
self.eval_string = self.convert_custom_property_to_string(item, name)
|
|
|
|
if old_type != 'PYTHON':
|
|
self._fill_old_ui_data(item, name)
|
|
|
|
wm = context.window_manager
|
|
return wm.invoke_props_dialog(self)
|
|
|
|
def check(self, context):
|
|
changed = False
|
|
|
|
# In order to convert UI data between types for type changes before the operator has actually executed,
|
|
# compare against the type the last time the check method was called (the last time a value was edited).
|
|
if self.property_type != self.last_property_type:
|
|
self._convert_old_ui_data_to_new_type(self.last_property_type, self.property_type)
|
|
changed = True
|
|
|
|
# Make sure that min is less than max, soft range is inside hard range, etc.
|
|
if self.property_type in {'FLOAT', 'FLOAT_ARRAY'}:
|
|
if self.min_float > self.max_float:
|
|
self.min_float, self.max_float = self.max_float, self.min_float
|
|
changed = True
|
|
if self.use_soft_limits:
|
|
if self.soft_min_float > self.soft_max_float:
|
|
self.soft_min_float, self.soft_max_float = self.soft_max_float, self.soft_min_float
|
|
changed = True
|
|
if self.soft_max_float > self.max_float:
|
|
self.soft_max_float = self.max_float
|
|
changed = True
|
|
if self.soft_min_float < self.min_float:
|
|
self.soft_min_float = self.min_float
|
|
changed = True
|
|
elif self.property_type in {'INT', 'INT_ARRAY'}:
|
|
if self.min_int > self.max_int:
|
|
self.min_int, self.max_int = self.max_int, self.min_int
|
|
changed = True
|
|
if self.use_soft_limits:
|
|
if self.soft_min_int > self.soft_max_int:
|
|
self.soft_min_int, self.soft_max_int = self.soft_max_int, self.soft_min_int
|
|
changed = True
|
|
if self.soft_max_int > self.max_int:
|
|
self.soft_max_int = self.max_int
|
|
changed = True
|
|
if self.soft_min_int < self.min_int:
|
|
self.soft_min_int = self.min_int
|
|
changed = True
|
|
|
|
self.last_property_type = self.property_type
|
|
|
|
return changed
|
|
|
|
def draw(self, _context):
|
|
layout = self.layout
|
|
|
|
layout.use_property_split = True
|
|
layout.use_property_decorate = False
|
|
|
|
layout.prop(self, "property_type")
|
|
layout.prop(self, "property_name")
|
|
|
|
if self.property_type in {'FLOAT', 'FLOAT_ARRAY'}:
|
|
if self.property_type == 'FLOAT_ARRAY':
|
|
layout.prop(self, "array_length")
|
|
col = layout.column(align=True)
|
|
col.prop(self, "default_float", index=0, text="Default")
|
|
for i in range(1, self.array_length):
|
|
col.prop(self, "default_float", index=i, text=" ")
|
|
else:
|
|
layout.prop(self, "default_float", index=0)
|
|
|
|
col = layout.column(align=True)
|
|
col.prop(self, "min_float")
|
|
col.prop(self, "max_float")
|
|
|
|
col = layout.column()
|
|
col.prop(self, "use_soft_limits")
|
|
|
|
col = layout.column(align=True)
|
|
col.enabled = self.use_soft_limits
|
|
col.prop(self, "soft_min_float", text="Soft Min")
|
|
col.prop(self, "soft_max_float", text="Max")
|
|
|
|
layout.prop(self, "step_float")
|
|
layout.prop(self, "precision")
|
|
|
|
layout.prop(self, "subtype")
|
|
elif self.property_type in {'INT', 'INT_ARRAY'}:
|
|
if self.property_type == 'INT_ARRAY':
|
|
layout.prop(self, "array_length")
|
|
col = layout.column(align=True)
|
|
col.prop(self, "default_int", index=0, text="Default")
|
|
for i in range(1, self.array_length):
|
|
col.prop(self, "default_int", index=i, text=" ")
|
|
else:
|
|
layout.prop(self, "default_int", index=0)
|
|
|
|
col = layout.column(align=True)
|
|
col.prop(self, "min_int")
|
|
col.prop(self, "max_int")
|
|
|
|
col = layout.column()
|
|
col.prop(self, "use_soft_limits")
|
|
|
|
col = layout.column(align=True)
|
|
col.enabled = self.use_soft_limits
|
|
col.prop(self, "soft_min_int", text="Soft Min")
|
|
col.prop(self, "soft_max_int", text="Max")
|
|
|
|
layout.prop(self, "step_int")
|
|
elif self.property_type in {'BOOL', 'BOOL_ARRAY'}:
|
|
if self.property_type == 'BOOL_ARRAY':
|
|
layout.prop(self, "array_length")
|
|
col = layout.column(align=True)
|
|
col.prop(self, "default_bool", index=0, text="Default")
|
|
for i in range(1, self.array_length):
|
|
col.prop(self, "default_bool", index=i, text=" ")
|
|
else:
|
|
layout.prop(self, "default_bool", index=0)
|
|
elif self.property_type == 'STRING':
|
|
layout.prop(self, "default_string")
|
|
elif self.property_type == 'DATA_BLOCK':
|
|
layout.prop(self, "id_type")
|
|
|
|
if self.property_type == 'PYTHON':
|
|
layout.prop(self, "eval_string")
|
|
else:
|
|
layout.prop(self, "description")
|
|
|
|
layout.prop(self, "is_overridable_library")
|
|
|
|
|
|
# Edit the value of a custom property with the given name on the RNA struct at the given data path.
|
|
# For supported types, this simply acts as a convenient way to create a popup for a specific property
|
|
# and draws the custom property value directly in the popup. For types like groups which can't be edited
|
|
# directly with buttons, instead convert the value to a string, evaluate the changed string when executing.
|
|
class WM_OT_properties_edit_value(Operator):
|
|
"""Edit the value of a custom property"""
|
|
bl_idname = "wm.properties_edit_value"
|
|
bl_label = "Edit Property Value"
|
|
# register only because invoke_props_popup requires.
|
|
bl_options = {'REGISTER', 'INTERNAL'}
|
|
|
|
data_path: rna_path
|
|
property_name: rna_custom_property_name
|
|
|
|
# Store the value converted to a string as a fallback for otherwise unsupported types.
|
|
eval_string: StringProperty(
|
|
name="Value",
|
|
description="Value for custom property types that can only be edited as a Python expression",
|
|
)
|
|
|
|
def execute(self, context):
|
|
if self.eval_string:
|
|
rna_item = eval("context.{:s}".format(self.data_path))
|
|
try:
|
|
new_value = eval(self.eval_string)
|
|
except Exception as ex:
|
|
self.report({'WARNING'}, "Python evaluation failed: " + str(ex))
|
|
return {'CANCELLED'}
|
|
rna_item[self.property_name] = new_value
|
|
return {'FINISHED'}
|
|
|
|
def invoke(self, context, _event):
|
|
rna_item = eval("context.{:s}".format(self.data_path))
|
|
|
|
if WM_OT_properties_edit.get_property_type(rna_item, self.property_name) == 'PYTHON':
|
|
self.eval_string = WM_OT_properties_edit.convert_custom_property_to_string(rna_item, self.property_name)
|
|
else:
|
|
self.eval_string = ""
|
|
|
|
wm = context.window_manager
|
|
return wm.invoke_props_dialog(self)
|
|
|
|
def draw(self, context):
|
|
from bpy.utils import escape_identifier
|
|
|
|
rna_item = eval("context.{:s}".format(self.data_path))
|
|
|
|
layout = self.layout
|
|
if WM_OT_properties_edit.get_property_type(rna_item, self.property_name) == 'PYTHON':
|
|
layout.prop(self, "eval_string")
|
|
else:
|
|
col = layout.column(align=True)
|
|
col.prop(rna_item, '["{:s}"]'.format(escape_identifier(self.property_name)), text="")
|
|
|
|
|
|
class WM_OT_properties_add(Operator):
|
|
"""Add your own property to the data-block"""
|
|
bl_idname = "wm.properties_add"
|
|
bl_label = "Add Property"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path
|
|
|
|
def execute(self, context):
|
|
from rna_prop_ui import (
|
|
rna_idprop_ui_create,
|
|
)
|
|
|
|
data_path = self.data_path
|
|
item = eval("context.{:s}".format(data_path))
|
|
|
|
if (item.id_data and item.id_data.override_library and item.id_data.override_library.reference):
|
|
self.report({'ERROR'}, "Cannot add properties to override data")
|
|
return {'CANCELLED'}
|
|
|
|
def unique_name(names):
|
|
prop = "prop"
|
|
prop_new = prop
|
|
i = 1
|
|
while prop_new in names:
|
|
prop_new = prop + str(i)
|
|
i += 1
|
|
|
|
return prop_new
|
|
|
|
prop = unique_name({
|
|
*item.keys(),
|
|
*type(item).bl_rna.properties.keys(),
|
|
})
|
|
|
|
rna_idprop_ui_create(item, prop, default=1.0)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_properties_context_change(Operator):
|
|
"""Jump to a different tab inside the properties editor"""
|
|
bl_idname = "wm.properties_context_change"
|
|
bl_label = ""
|
|
bl_options = {'INTERNAL'}
|
|
|
|
context: StringProperty(
|
|
name="Context",
|
|
maxlen=64,
|
|
)
|
|
|
|
def execute(self, context):
|
|
context.space_data.context = self.context
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_properties_remove(Operator):
|
|
"""Internal use (edit a property data_path)"""
|
|
bl_idname = "wm.properties_remove"
|
|
bl_label = "Remove Property"
|
|
bl_options = {'UNDO', 'INTERNAL'}
|
|
|
|
data_path: rna_path
|
|
property_name: rna_custom_property_name
|
|
|
|
def execute(self, context):
|
|
from rna_prop_ui import (
|
|
rna_idprop_ui_prop_update,
|
|
)
|
|
data_path = self.data_path
|
|
item = eval("context.{:s}".format(data_path))
|
|
|
|
if (item.id_data and item.id_data.override_library and item.id_data.override_library.reference):
|
|
self.report({'ERROR'}, "Cannot remove properties from override data")
|
|
return {'CANCELLED'}
|
|
|
|
name = self.property_name
|
|
rna_idprop_ui_prop_update(item, name)
|
|
del item[name]
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_sysinfo(Operator):
|
|
"""Generate system information, saved into a text file"""
|
|
|
|
bl_idname = "wm.sysinfo"
|
|
bl_label = "Save System Info"
|
|
|
|
filepath: StringProperty(
|
|
subtype='FILE_PATH',
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
def execute(self, _context):
|
|
from _bpy_internal.system_info.text_generate_runtime import write
|
|
with open(self.filepath, "w", encoding="utf-8") as output:
|
|
try:
|
|
write(output)
|
|
except Exception as ex:
|
|
# Not expected to occur, simply forward the exception.
|
|
self.report({'ERROR'}, str(ex))
|
|
|
|
# Also write into the file (to avoid confusion).
|
|
output.write("ERROR: {:s}\n".format(str(ex)))
|
|
return {'CANCELLED'}
|
|
|
|
return {'FINISHED'}
|
|
|
|
def invoke(self, context, _event):
|
|
import os
|
|
|
|
if not self.filepath:
|
|
self.filepath = os.path.join(
|
|
os.path.expanduser("~"), "system-info.txt")
|
|
|
|
wm = context.window_manager
|
|
wm.fileselect_add(self)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
|
|
class WM_OT_operator_cheat_sheet(Operator):
|
|
"""List all the operators in a text-block, useful for scripting"""
|
|
bl_idname = "wm.operator_cheat_sheet"
|
|
bl_label = "Operator Cheat Sheet"
|
|
|
|
def execute(self, _context):
|
|
op_strings = []
|
|
tot = 0
|
|
for op_module_name in dir(bpy.ops):
|
|
op_module = getattr(bpy.ops, op_module_name)
|
|
for op_submodule_name in dir(op_module):
|
|
op = getattr(op_module, op_submodule_name)
|
|
text = repr(op)
|
|
if text.split("\n")[-1].startswith("bpy.ops."):
|
|
op_strings.append(text)
|
|
tot += 1
|
|
|
|
op_strings.append('')
|
|
|
|
textblock = bpy.data.texts.new("OperatorList.txt")
|
|
textblock.write("# {:d} Operators\n\n".format(tot))
|
|
textblock.write("\n".join(op_strings))
|
|
self.report({'INFO'}, "See OperatorList.txt text block")
|
|
return {'FINISHED'}
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Add-on Operators
|
|
|
|
class WM_OT_owner_enable(Operator):
|
|
"""Enable add-on for workspace"""
|
|
bl_idname = "wm.owner_enable"
|
|
bl_label = "Enable Add-on"
|
|
|
|
owner_id: StringProperty(
|
|
name="UI Tag",
|
|
)
|
|
|
|
def execute(self, context):
|
|
workspace = context.workspace
|
|
workspace.owner_ids.new(self.owner_id)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_owner_disable(Operator):
|
|
"""Disable add-on for workspace"""
|
|
bl_idname = "wm.owner_disable"
|
|
bl_label = "Disable Add-on"
|
|
|
|
owner_id: StringProperty(
|
|
name="UI Tag",
|
|
)
|
|
|
|
def execute(self, context):
|
|
workspace = context.workspace
|
|
owner_id = workspace.owner_ids[self.owner_id]
|
|
workspace.owner_ids.remove(owner_id)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_tool_set_by_id(Operator):
|
|
"""Set the tool by name (for key-maps)"""
|
|
bl_idname = "wm.tool_set_by_id"
|
|
bl_label = "Set Tool by Name"
|
|
|
|
name: StringProperty(
|
|
name="Identifier",
|
|
description="Identifier of the tool",
|
|
)
|
|
cycle: BoolProperty(
|
|
name="Cycle",
|
|
description="Cycle through tools in this group",
|
|
default=False,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
as_fallback: BoolProperty(
|
|
name="Set Fallback",
|
|
description="Set the fallback tool instead of the primary tool",
|
|
default=False,
|
|
options={'SKIP_SAVE', 'HIDDEN'},
|
|
)
|
|
|
|
space_type: rna_space_type_prop
|
|
|
|
@staticmethod
|
|
def space_type_from_operator(op, context):
|
|
if op.properties.is_property_set("space_type"):
|
|
space_type = op.space_type
|
|
else:
|
|
space = context.space_data
|
|
if space is None:
|
|
op.report({'WARNING'}, rpt_("Tool cannot be set with an empty space"))
|
|
return None
|
|
space_type = space.type
|
|
return space_type
|
|
|
|
def execute(self, context):
|
|
from bl_ui.space_toolsystem_common import (
|
|
activate_by_id,
|
|
activate_by_id_or_cycle,
|
|
)
|
|
|
|
if (space_type := WM_OT_tool_set_by_id.space_type_from_operator(self, context)) is None:
|
|
return {'CANCELLED'}
|
|
|
|
fn = activate_by_id_or_cycle if self.cycle else activate_by_id
|
|
if fn(context, space_type, self.name, as_fallback=self.as_fallback):
|
|
if self.as_fallback:
|
|
tool_settings = context.tool_settings
|
|
tool_settings.workspace_tool_type = 'FALLBACK'
|
|
return {'FINISHED'}
|
|
else:
|
|
self.report({'WARNING'}, rpt_("Tool {!r} not found for space {!r}").format(self.name, space_type))
|
|
return {'CANCELLED'}
|
|
|
|
|
|
class WM_OT_tool_set_by_index(Operator):
|
|
"""Set the tool by index (for key-maps)"""
|
|
bl_idname = "wm.tool_set_by_index"
|
|
bl_label = "Set Tool by Index"
|
|
index: IntProperty(
|
|
name="Index in Toolbar",
|
|
default=0,
|
|
)
|
|
cycle: BoolProperty(
|
|
name="Cycle",
|
|
description="Cycle through tools in this group",
|
|
default=False,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
expand: BoolProperty(
|
|
description="Include tool subgroups",
|
|
default=True,
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
as_fallback: BoolProperty(
|
|
name="Set Fallback",
|
|
description="Set the fallback tool instead of the primary",
|
|
default=False,
|
|
options={'SKIP_SAVE', 'HIDDEN'},
|
|
)
|
|
|
|
space_type: rna_space_type_prop
|
|
|
|
def execute(self, context):
|
|
from bl_ui.space_toolsystem_common import (
|
|
activate_by_id,
|
|
activate_by_id_or_cycle,
|
|
item_from_index_active,
|
|
item_from_flat_index,
|
|
)
|
|
|
|
if (space_type := WM_OT_tool_set_by_id.space_type_from_operator(self, context)) is None:
|
|
return {'CANCELLED'}
|
|
|
|
fn = item_from_flat_index if self.expand else item_from_index_active
|
|
item = fn(context, space_type, self.index)
|
|
if item is None:
|
|
# Don't report, since the number of tools may change.
|
|
return {'CANCELLED'}
|
|
|
|
# Same as: WM_OT_tool_set_by_id
|
|
fn = activate_by_id_or_cycle if self.cycle else activate_by_id
|
|
if fn(context, space_type, item.idname, as_fallback=self.as_fallback):
|
|
if self.as_fallback:
|
|
tool_settings = context.tool_settings
|
|
tool_settings.workspace_tool_type = 'FALLBACK'
|
|
return {'FINISHED'}
|
|
else:
|
|
# Since we already have the tool, this can't happen.
|
|
raise Exception("Internal error setting tool")
|
|
|
|
|
|
class WM_OT_tool_set_by_brush_type(Operator):
|
|
"""Look up the most appropriate tool for the given brush type and activate that"""
|
|
bl_idname = "wm.tool_set_by_brush_type"
|
|
bl_label = "Set Tool by Brush Type"
|
|
|
|
brush_type: StringProperty(
|
|
name="Brush Type",
|
|
description="Brush type identifier for which the most appropriate tool will be looked up",
|
|
)
|
|
|
|
space_type: rna_space_type_prop
|
|
|
|
def execute(self, context):
|
|
from bl_ui.space_toolsystem_common import (
|
|
ToolSelectPanelHelper,
|
|
activate_by_id
|
|
)
|
|
|
|
if (space_type := WM_OT_tool_set_by_id.space_type_from_operator(self, context)) is None:
|
|
return {'CANCELLED'}
|
|
|
|
tool_helper_cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
|
|
# Lookup a tool with a matching brush type (ignoring some specific ones).
|
|
tool_id = "builtin.brush"
|
|
for item in ToolSelectPanelHelper._tools_flatten(
|
|
tool_helper_cls.tools_from_context(context, mode=context.mode),
|
|
):
|
|
if item is None:
|
|
continue
|
|
|
|
# Never automatically activate these tools, they use a brush type that we want to use
|
|
# the main brush for (e.g. grease pencil primitive tools use 'DRAW' brush type, which
|
|
# is the most general one).
|
|
if item.idname in {
|
|
"builtin.arc",
|
|
"builtin.curve",
|
|
"builtin.line",
|
|
"builtin.box",
|
|
"builtin.circle",
|
|
"builtin.polyline",
|
|
}:
|
|
continue
|
|
|
|
if item.options is not None and ('USE_BRUSHES' in item.options) and item.brush_type is not None:
|
|
if item.brush_type == self.brush_type:
|
|
tool_id = item.idname
|
|
break
|
|
|
|
if activate_by_id(context, space_type, tool_id):
|
|
return {'FINISHED'}
|
|
else:
|
|
self.report({'WARNING'}, rpt_("Tool {!r} not found for space {!r}").format(tool_id, space_type))
|
|
return {'CANCELLED'}
|
|
|
|
|
|
class WM_OT_toolbar(Operator):
|
|
bl_idname = "wm.toolbar"
|
|
bl_label = "Toolbar"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data is not None
|
|
|
|
@staticmethod
|
|
def keymap_from_toolbar(context, space_type, *, use_fallback_keys=True, use_reset=True):
|
|
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
|
from bl_keymap_utils import keymap_from_toolbar
|
|
|
|
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
|
|
if cls is None:
|
|
return None, None
|
|
|
|
return cls, keymap_from_toolbar.generate(
|
|
context,
|
|
space_type,
|
|
use_fallback_keys=use_fallback_keys,
|
|
use_reset=use_reset,
|
|
)
|
|
|
|
def execute(self, context):
|
|
space_type = context.space_data.type
|
|
cls, keymap = self.keymap_from_toolbar(context, space_type)
|
|
if keymap is None:
|
|
return {'CANCELLED'}
|
|
|
|
def draw_menu(popover, context):
|
|
layout = popover.layout
|
|
layout.operator_context = 'INVOKE_REGION_WIN'
|
|
cls.draw_cls(layout, context, detect_layout=False, scale_y=1.0)
|
|
|
|
wm = context.window_manager
|
|
wm.popover(draw_menu, ui_units_x=8, keymap=keymap)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_toolbar_fallback_pie(Operator):
|
|
bl_idname = "wm.toolbar_fallback_pie"
|
|
bl_label = "Fallback Tool Pie Menu"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data is not None
|
|
|
|
def invoke(self, context, event):
|
|
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
|
space_type = context.space_data.type
|
|
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
|
|
if cls is None:
|
|
return {'PASS_THROUGH'}
|
|
|
|
# It's possible we don't have the fallback tool available.
|
|
# This can happen in the image editor for example when there is no selection
|
|
# in painting modes.
|
|
item, _ = cls._tool_get_by_id(context, cls.tool_fallback_id)
|
|
if item is None:
|
|
print("Tool", cls.tool_fallback_id, "not active in", cls)
|
|
return {'PASS_THROUGH'}
|
|
|
|
def draw_cb(self, context):
|
|
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
|
ToolSelectPanelHelper.draw_fallback_tool_items_for_pie_menu(self.layout, context)
|
|
|
|
wm = context.window_manager
|
|
wm.popup_menu_pie(draw_func=draw_cb, title=iface_("Fallback Tool"), event=event)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class WM_OT_toolbar_prompt(Operator):
|
|
"""Leader key like functionality for accessing tools"""
|
|
bl_idname = "wm.toolbar_prompt"
|
|
bl_label = "Toolbar Prompt"
|
|
|
|
@staticmethod
|
|
def _status_items_generate(cls, keymap, context):
|
|
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
|
|
|
# The keymap doesn't have the same order the tools are declared in,
|
|
# while we could support this, it's simpler to apply order here.
|
|
tool_map_id_to_order = {}
|
|
# Map the
|
|
tool_map_id_to_label = {}
|
|
for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context)):
|
|
if item is not None:
|
|
tool_map_id_to_label[item.idname] = item.label
|
|
tool_map_id_to_order[item.idname] = len(tool_map_id_to_order)
|
|
|
|
status_items = []
|
|
|
|
for item in keymap.keymap_items:
|
|
name = item.name
|
|
key_str = item.to_string()
|
|
# These are duplicated from regular numbers.
|
|
if key_str.startswith("Numpad "):
|
|
continue
|
|
properties = item.properties
|
|
idname = item.idname
|
|
if idname == "wm.tool_set_by_id":
|
|
tool_idname = properties["name"]
|
|
name = tool_map_id_to_label[tool_idname]
|
|
name = name.replace("Annotate ", "")
|
|
else:
|
|
continue
|
|
|
|
status_items.append((tool_idname, name, item))
|
|
|
|
status_items.sort(
|
|
key=lambda a: tool_map_id_to_order[a[0]]
|
|
)
|
|
return status_items
|
|
|
|
def modal(self, context, event):
|
|
event_type = event.type
|
|
event_value = event.value
|
|
|
|
if event_type in {
|
|
'LEFTMOUSE', 'RIGHTMOUSE', 'MIDDLEMOUSE',
|
|
'WHEELDOWNMOUSE', 'WHEELUPMOUSE', 'WHEELINMOUSE', 'WHEELOUTMOUSE',
|
|
'ESC',
|
|
}:
|
|
context.workspace.status_text_set(None)
|
|
return {'CANCELLED', 'PASS_THROUGH'}
|
|
|
|
keymap = self._keymap
|
|
item = keymap.keymap_items.match_event(event)
|
|
if item is not None:
|
|
idname = item.idname
|
|
properties = item.properties
|
|
if idname == "wm.tool_set_by_id":
|
|
tool_idname = properties["name"]
|
|
bpy.ops.wm.tool_set_by_id(name=tool_idname)
|
|
|
|
context.workspace.status_text_set(None)
|
|
return {'FINISHED'}
|
|
|
|
# Pressing entry even again exists, as long as it's not mapped to a key (for convenience).
|
|
if event_type == self._init_event_type:
|
|
if event_value == 'RELEASE':
|
|
if not (event.ctrl or event.alt or event.shift or event.oskey or event.hyper):
|
|
context.workspace.status_text_set(None)
|
|
return {'CANCELLED'}
|
|
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def invoke(self, context, event):
|
|
space_data = context.space_data
|
|
if space_data is None:
|
|
return {'CANCELLED'}
|
|
|
|
space_type = space_data.type
|
|
cls, keymap = WM_OT_toolbar.keymap_from_toolbar(
|
|
context,
|
|
space_type,
|
|
use_fallback_keys=False,
|
|
use_reset=False,
|
|
)
|
|
if (keymap is None) or (not keymap.keymap_items):
|
|
return {'CANCELLED'}
|
|
|
|
self._init_event_type = event.type
|
|
|
|
# Strip Left/Right, since "Left Alt" isn't especially useful.
|
|
init_event_type_as_text = self._init_event_type.title().split("_")
|
|
if init_event_type_as_text[0] in {"Left", "Right"}:
|
|
del init_event_type_as_text[0]
|
|
init_event_type_as_text = " ".join(init_event_type_as_text)
|
|
|
|
status_items = self._status_items_generate(cls, keymap, context)
|
|
|
|
def status_text_fn(self, context):
|
|
|
|
layout = self.layout
|
|
if True:
|
|
box = layout.row(align=True).box()
|
|
box.scale_x = 0.8
|
|
box.label(text=init_event_type_as_text)
|
|
|
|
flow = layout.grid_flow(columns=len(status_items), align=True, row_major=True)
|
|
for _, name, item in status_items:
|
|
row = flow.row(align=True)
|
|
row.template_event_from_keymap_item(
|
|
item, text=name, text_ctxt=i18n_contexts.operator_default
|
|
)
|
|
|
|
self._keymap = keymap
|
|
|
|
context.workspace.status_text_set(status_text_fn)
|
|
|
|
context.window_manager.modal_handler_add(self)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
|
|
class BatchRenameAction(bpy.types.PropertyGroup):
|
|
# category: StringProperty()
|
|
type: EnumProperty(
|
|
name="Operation",
|
|
items=(
|
|
('REPLACE', "Find/Replace", "Replace text in the name"),
|
|
('SET', "Set Name", "Set a new name or prefix/suffix the existing one"),
|
|
('STRIP', "Strip Characters", "Strip leading/trailing text from the name"),
|
|
('CASE', "Change Case", "Change case of each name"),
|
|
),
|
|
)
|
|
|
|
# We could split these into sub-properties, however it's not so important.
|
|
|
|
# Used when `type == 'SET'`.
|
|
set_name: StringProperty(name="Name")
|
|
set_method: EnumProperty(
|
|
name="Method",
|
|
items=(
|
|
('NEW', "New", ""),
|
|
('PREFIX', "Prefix", ""),
|
|
('SUFFIX', "Suffix", ""),
|
|
),
|
|
default='SUFFIX',
|
|
)
|
|
|
|
# Used when `type == 'STRIP'`.
|
|
strip_chars: EnumProperty(
|
|
name="Strip Characters",
|
|
translation_context=i18n_contexts.id_text,
|
|
options={'ENUM_FLAG'},
|
|
items=(
|
|
('SPACE', "Spaces", ""),
|
|
('DIGIT', "Digits", ""),
|
|
('PUNCT', "Punctuation", ""),
|
|
),
|
|
)
|
|
|
|
# Used when `type == 'STRIP'`.
|
|
strip_part: EnumProperty(
|
|
name="Strip Part",
|
|
options={'ENUM_FLAG'},
|
|
items=(
|
|
('START', "Start", ""),
|
|
('END', "End", ""),
|
|
),
|
|
)
|
|
|
|
# Used when `type == 'REPLACE'`.
|
|
replace_src: StringProperty(name="Find")
|
|
replace_dst: StringProperty(name="Replace")
|
|
replace_match_case: BoolProperty(name="Case Sensitive")
|
|
use_replace_regex_src: BoolProperty(
|
|
name="Regular Expression Find",
|
|
description="Use regular expressions to match text in the 'Find' field",
|
|
)
|
|
use_replace_regex_dst: BoolProperty(
|
|
name="Regular Expression Replace",
|
|
description="Use regular expression for the replacement text (supporting groups)",
|
|
)
|
|
|
|
# Used when `type == 'CASE'`.
|
|
case_method: EnumProperty(
|
|
name="Case",
|
|
items=(
|
|
('UPPER', "Upper Case", ""),
|
|
('LOWER', "Lower Case", ""),
|
|
('TITLE', "Title Case", ""),
|
|
),
|
|
)
|
|
|
|
# Weak, add/remove as properties.
|
|
op_add: BoolProperty(name="Add", translation_context=i18n_contexts.operator_default)
|
|
op_remove: BoolProperty(name="Remove", translation_context=i18n_contexts.operator_default)
|
|
|
|
|
|
class WM_OT_batch_rename(Operator):
|
|
"""Rename multiple items at once"""
|
|
|
|
bl_idname = "wm.batch_rename"
|
|
bl_label = "Batch Rename"
|
|
|
|
bl_options = {'UNDO'}
|
|
|
|
data_type: EnumProperty(
|
|
name="Type",
|
|
items=(
|
|
('OBJECT', "Objects", "", 'OBJECT_DATA', 0),
|
|
('COLLECTION', "Collections", "", 'OUTLINER_COLLECTION', 1),
|
|
('MATERIAL', "Materials", "", 'MATERIAL_DATA', 2),
|
|
None,
|
|
# Enum identifiers are compared with `object.type`.
|
|
# Follow order in "Add" menu.
|
|
('MESH', "Meshes", "", 'MESH_DATA', 3),
|
|
('CURVE', "Curves", "", 'CURVE_DATA', 4),
|
|
('META', "Metaballs", "", 'META_DATA', 5),
|
|
('VOLUME', "Volumes", "", 'VOLUME_DATA', 6),
|
|
('GPENCIL', "Grease Pencils", "", 'OUTLINER_DATA_GREASEPENCIL', 7),
|
|
('ARMATURE', "Armatures", "", 'ARMATURE_DATA', 8),
|
|
('LATTICE', "Lattices", "", 'LATTICE_DATA', 9),
|
|
('LIGHT', "Lights", "", 'LIGHT_DATA', 10),
|
|
('LIGHT_PROBE', "Light Probes", "", 'OUTLINER_DATA_LIGHTPROBE', 11),
|
|
('CAMERA', "Cameras", "", 'CAMERA_DATA', 12),
|
|
('SPEAKER', "Speakers", "", 'OUTLINER_DATA_SPEAKER', 13),
|
|
None,
|
|
('BONE', "Bones", "", 'BONE_DATA', 14),
|
|
('NODE', "Nodes", "", 'NODETREE', 15),
|
|
('SEQUENCE_STRIP', "Sequence Strips", "", 'SEQ_SEQUENCER', 16),
|
|
('ACTION_CLIP', "Action Clips", "", 'ACTION', 17),
|
|
None,
|
|
('SCENE', "Scenes", "", 'SCENE_DATA', 18),
|
|
('BRUSH', "Brushes", "", 'BRUSH_DATA', 19),
|
|
),
|
|
translation_context=i18n_contexts.id_id,
|
|
description="Type of data to rename",
|
|
)
|
|
|
|
data_source: EnumProperty(
|
|
name="Source",
|
|
items=(
|
|
('SELECT', "Selected", ""),
|
|
('ALL', "All", ""),
|
|
),
|
|
)
|
|
|
|
actions: CollectionProperty(type=BatchRenameAction)
|
|
|
|
@staticmethod
|
|
def _selected_ids_from_outliner_by_type(context, ty):
|
|
return [
|
|
id for id in context.selected_ids
|
|
if isinstance(id, ty)
|
|
if id.is_editable
|
|
]
|
|
|
|
@staticmethod
|
|
def _selected_ids_from_outliner_by_type_for_object_data(context, ty):
|
|
# Include selected object-data as well as the selected ID's.
|
|
from bpy.types import Object
|
|
# De-duplicate the result as object-data may cause duplicates.
|
|
return tuple(set([
|
|
id for id_base in context.selected_ids
|
|
if isinstance(id := id_base.data if isinstance(id_base, Object) else id_base, ty)
|
|
if id.is_editable
|
|
]))
|
|
|
|
@staticmethod
|
|
def _selected_actions_from_outliner(context):
|
|
# Actions are a special case because they can be accessed directly or via animation-data.
|
|
from bpy.types import Action
|
|
|
|
def action_from_any_id(id_data):
|
|
if isinstance(id_data, Action):
|
|
return id_data
|
|
# Not all ID's have animation data.
|
|
if (animation_data := getattr(id_data, "animation_data", None)) is not None:
|
|
return animation_data.action
|
|
return None
|
|
|
|
return tuple(set(
|
|
action for id in context.selected_ids
|
|
if (action := action_from_any_id(id)) is not None
|
|
if action.is_editable
|
|
))
|
|
|
|
@classmethod
|
|
def _data_from_context(cls, context, data_type, only_selected, *, check_context=False):
|
|
def _is_editable(data):
|
|
return data.id_data.is_editable and not data.id_data.override_library
|
|
|
|
mode = context.mode
|
|
scene = context.scene
|
|
space = context.space_data
|
|
space_type = None if (space is None) else space.type
|
|
|
|
data = None
|
|
if space_type == 'SEQUENCE_EDITOR':
|
|
data_type_test = 'SEQUENCE_STRIP'
|
|
if check_context:
|
|
return data_type_test
|
|
if data_type == data_type_test:
|
|
data = (
|
|
context.selected_strips
|
|
if only_selected else
|
|
scene.sequence_editor.strips_all,
|
|
"name",
|
|
iface_("Strip(s)"),
|
|
)
|
|
elif space_type == 'NODE_EDITOR':
|
|
data_type_test = 'NODE'
|
|
if check_context:
|
|
return data_type_test
|
|
if data_type == data_type_test:
|
|
data = (
|
|
context.selected_nodes
|
|
if only_selected else
|
|
list(space.node_tree.nodes),
|
|
"name",
|
|
iface_("Node(s)"),
|
|
)
|
|
elif space_type == 'OUTLINER':
|
|
data_type_test = 'COLLECTION'
|
|
if check_context:
|
|
return data_type_test
|
|
if data_type == data_type_test:
|
|
data = (
|
|
cls._selected_ids_from_outliner_by_type(context, bpy.types.Collection)
|
|
if only_selected else
|
|
scene.collection.children_recursive,
|
|
"name",
|
|
iface_("Collection(s)"),
|
|
)
|
|
else:
|
|
if mode == 'POSE' or (mode == 'WEIGHT_PAINT' and context.pose_object):
|
|
data_type_test = 'BONE'
|
|
if check_context:
|
|
return data_type_test
|
|
if data_type == data_type_test:
|
|
data = (
|
|
[pchan.bone for pchan in context.selected_pose_bones]
|
|
if only_selected else
|
|
[pbone.bone for ob in context.objects_in_mode_unique_data for pbone in ob.pose.bones],
|
|
"name",
|
|
iface_("Bone(s)"),
|
|
)
|
|
elif mode == 'EDIT_ARMATURE':
|
|
data_type_test = 'BONE'
|
|
if check_context:
|
|
return data_type_test
|
|
if data_type == data_type_test:
|
|
data = (
|
|
context.selected_editable_bones
|
|
if only_selected else
|
|
[ebone for ob in context.objects_in_mode_unique_data for ebone in ob.data.edit_bones],
|
|
"name",
|
|
iface_("Edit Bone(s)"),
|
|
)
|
|
|
|
if check_context:
|
|
return 'OBJECT'
|
|
|
|
object_data_type_attrs_map = {
|
|
'MESH': ("meshes", iface_("Mesh(es)"), bpy.types.Mesh),
|
|
'CURVE': ("curves", iface_("Curve(s)"), bpy.types.Curve),
|
|
'META': ("metaballs", iface_("Metaball(s)"), bpy.types.MetaBall),
|
|
'VOLUME': ("volumes", iface_("Volume(s)"), bpy.types.Volume),
|
|
'GPENCIL': ("grease_pencils", iface_("Grease Pencil(s)"), bpy.types.GreasePencil),
|
|
'ARMATURE': ("armatures", iface_("Armature(s)"), bpy.types.Armature),
|
|
'LATTICE': ("lattices", iface_("Lattice(s)"), bpy.types.Lattice),
|
|
'LIGHT': ("lights", iface_("Light(s)"), bpy.types.Light),
|
|
'LIGHT_PROBE': ("lightprobes", iface_("Light Probe(s)"), bpy.types.LightProbe),
|
|
'CAMERA': ("cameras", iface_("Camera(s)"), bpy.types.Camera),
|
|
'SPEAKER': ("speakers", iface_("Speaker(s)"), bpy.types.Speaker),
|
|
}
|
|
|
|
# Finish with space types.
|
|
if data is None:
|
|
|
|
if data_type == 'OBJECT':
|
|
data = (
|
|
(
|
|
# Outliner.
|
|
cls._selected_ids_from_outliner_by_type(context, bpy.types.Object)
|
|
if space_type == 'OUTLINER' else
|
|
# 3D View (default).
|
|
context.selected_editable_objects
|
|
)
|
|
if only_selected else
|
|
[id for id in bpy.data.objects if id.is_editable],
|
|
"name",
|
|
iface_("Object(s)"),
|
|
)
|
|
elif data_type == 'COLLECTION':
|
|
data = (
|
|
# Outliner case is handled already.
|
|
tuple(set(
|
|
ob.instance_collection
|
|
for ob in context.selected_objects
|
|
if ((ob.instance_type == 'COLLECTION') and
|
|
(collection := ob.instance_collection) is not None and
|
|
(collection.is_editable))
|
|
))
|
|
if only_selected else
|
|
[id for id in bpy.data.collections if id.is_editable],
|
|
"name",
|
|
iface_("Collection(s)"),
|
|
)
|
|
elif data_type == 'MATERIAL':
|
|
data = (
|
|
(
|
|
# Outliner.
|
|
cls._selected_ids_from_outliner_by_type(context, bpy.types.Material)
|
|
if space_type == 'OUTLINER' else
|
|
# 3D View (default).
|
|
tuple(set(
|
|
id
|
|
for ob in context.selected_objects
|
|
for slot in ob.material_slots
|
|
if (id := slot.material) is not None and id.is_editable
|
|
))
|
|
)
|
|
if only_selected else
|
|
[id for id in bpy.data.materials if id.is_editable],
|
|
"name",
|
|
iface_("Material(s)"),
|
|
)
|
|
elif data_type == 'ACTION_CLIP':
|
|
data = (
|
|
(
|
|
# Outliner.
|
|
cls._selected_actions_from_outliner(context)
|
|
if space_type == 'OUTLINER' else
|
|
# 3D View (default).
|
|
tuple(set(
|
|
action for ob in context.selected_objects
|
|
if (((animation_data := ob.animation_data) is not None) and
|
|
((action := animation_data.action) is not None) and
|
|
(action.is_editable))
|
|
))
|
|
)
|
|
if only_selected else
|
|
[id for id in bpy.data.actions if id.is_editable],
|
|
"name",
|
|
iface_("Action(s)"),
|
|
)
|
|
elif data_type == 'SCENE':
|
|
data = (
|
|
(
|
|
# Outliner.
|
|
cls._selected_ids_from_outliner_by_type(context, bpy.types.Scene)
|
|
if ((space_type == 'OUTLINER') and only_selected) else
|
|
[id for id in bpy.data.scenes if id.is_editable]
|
|
),
|
|
"name",
|
|
iface_("Scene(s)"),
|
|
)
|
|
elif data_type == 'BRUSH':
|
|
data = (
|
|
(
|
|
# Outliner.
|
|
cls._selected_ids_from_outliner_by_type(context, bpy.types.Brush)
|
|
if ((space_type == 'OUTLINER') and only_selected) else
|
|
[id for id in bpy.data.brushes if id.is_editable]
|
|
),
|
|
"name",
|
|
iface_("Brush(es)"),
|
|
)
|
|
elif data_type in object_data_type_attrs_map.keys():
|
|
attr, descr, ty = object_data_type_attrs_map[data_type]
|
|
data = (
|
|
(
|
|
# Outliner.
|
|
cls._selected_ids_from_outliner_by_type_for_object_data(context, ty)
|
|
if space_type == 'OUTLINER' else
|
|
# 3D View (default).
|
|
tuple(set(
|
|
id
|
|
for ob in context.selected_objects
|
|
if ob.type == data_type
|
|
if (id := ob.data) is not None and id.is_editable
|
|
))
|
|
)
|
|
if only_selected else
|
|
[id for id in getattr(bpy.data, attr) if id.is_editable],
|
|
"name",
|
|
descr,
|
|
)
|
|
|
|
if data is None:
|
|
return None
|
|
|
|
data = ([it for it in data[0] if _is_editable(it)], data[1], data[2])
|
|
|
|
return data
|
|
|
|
@staticmethod
|
|
def _apply_actions(actions, name):
|
|
import string
|
|
import re
|
|
|
|
for action in actions:
|
|
ty = action.type
|
|
if ty == 'SET':
|
|
text = action.set_name
|
|
method = action.set_method
|
|
if method == 'NEW':
|
|
name = text
|
|
elif method == 'PREFIX':
|
|
name = text + name
|
|
elif method == 'SUFFIX':
|
|
name = name + text
|
|
else:
|
|
assert False, "unreachable"
|
|
|
|
elif ty == 'STRIP':
|
|
chars = action.strip_chars
|
|
chars_strip = (
|
|
"{:s}{:s}{:s}"
|
|
).format(
|
|
string.punctuation if 'PUNCT' in chars else "",
|
|
string.digits if 'DIGIT' in chars else "",
|
|
" " if 'SPACE' in chars else "",
|
|
)
|
|
part = action.strip_part
|
|
if 'START' in part:
|
|
name = name.lstrip(chars_strip)
|
|
if 'END' in part:
|
|
name = name.rstrip(chars_strip)
|
|
|
|
elif ty == 'REPLACE':
|
|
if action.use_replace_regex_src:
|
|
replace_src = action.replace_src
|
|
if action.use_replace_regex_dst:
|
|
replace_dst = action.replace_dst
|
|
else:
|
|
replace_dst = action.replace_dst.replace("\\", "\\\\")
|
|
else:
|
|
replace_src = re.escape(action.replace_src)
|
|
replace_dst = action.replace_dst.replace("\\", "\\\\")
|
|
name = re.sub(
|
|
replace_src,
|
|
replace_dst,
|
|
name,
|
|
flags=(
|
|
0 if action.replace_match_case else
|
|
re.IGNORECASE
|
|
),
|
|
)
|
|
elif ty == 'CASE':
|
|
method = action.case_method
|
|
if method == 'UPPER':
|
|
name = name.upper()
|
|
elif method == 'LOWER':
|
|
name = name.lower()
|
|
elif method == 'TITLE':
|
|
name = name.title()
|
|
else:
|
|
assert False, "unreachable"
|
|
else:
|
|
assert False, "unreachable"
|
|
return name
|
|
|
|
def _data_update(self, context):
|
|
only_selected = self.data_source == 'SELECT'
|
|
|
|
self._data = self._data_from_context(context, self.data_type, only_selected)
|
|
if self._data is None:
|
|
self.data_type = self._data_from_context(context, None, False, check_context=True)
|
|
self._data = self._data_from_context(context, self.data_type, only_selected)
|
|
|
|
self._data_source_prev = self.data_source
|
|
self._data_type_prev = self.data_type
|
|
|
|
def draw(self, context):
|
|
import re
|
|
|
|
layout = self.layout
|
|
|
|
split = layout.split(align=True)
|
|
split.row(align=True).prop(self, "data_source", expand=True)
|
|
split.prop(self, "data_type", text="")
|
|
|
|
for action in self.actions:
|
|
box = layout.box()
|
|
split = box.split(factor=0.87)
|
|
|
|
# Column 1: main content.
|
|
col = split.column()
|
|
|
|
# Label's width.
|
|
fac = 0.25
|
|
|
|
# Row 1: type.
|
|
row = col.split(factor=fac)
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Type")
|
|
row.prop(action, "type", text="")
|
|
|
|
ty = action.type
|
|
if ty == 'SET':
|
|
# Row 2: method.
|
|
row = col.split(factor=fac)
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Method")
|
|
row.row().prop(action, "set_method", expand=True)
|
|
|
|
# Row 3: name.
|
|
row = col.split(factor=fac)
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Name")
|
|
row.prop(action, "set_name", text="")
|
|
|
|
elif ty == 'STRIP':
|
|
# Row 2: chars.
|
|
row = col.split(factor=fac)
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Characters")
|
|
row.row().prop(action, "strip_chars")
|
|
|
|
# Row 3: part.
|
|
row = col.split(factor=fac)
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Strip From")
|
|
row.row().prop(action, "strip_part")
|
|
|
|
elif ty == 'REPLACE':
|
|
# Row 2: find.
|
|
row = col.split(factor=fac)
|
|
|
|
re_error_src = None
|
|
if action.use_replace_regex_src:
|
|
try:
|
|
re.compile(action.replace_src)
|
|
except Exception as ex:
|
|
re_error_src = str(ex)
|
|
row.alert = True
|
|
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Find")
|
|
sub = row.row(align=True)
|
|
sub.prop(action, "replace_src", text="")
|
|
sub.prop(action, "use_replace_regex_src", text="", icon='SORTBYEXT')
|
|
|
|
# Row.
|
|
if re_error_src is not None:
|
|
row = col.split(factor=fac)
|
|
row.label(text="")
|
|
row.alert = True
|
|
row.label(text=re_error_src)
|
|
|
|
# Row 3: replace.
|
|
row = col.split(factor=fac)
|
|
|
|
re_error_dst = None
|
|
if action.use_replace_regex_src:
|
|
if action.use_replace_regex_dst:
|
|
if re_error_src is None:
|
|
try:
|
|
re.sub(action.replace_src, action.replace_dst, "")
|
|
except Exception as ex:
|
|
re_error_dst = str(ex)
|
|
row.alert = True
|
|
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Replace")
|
|
sub = row.row(align=True)
|
|
sub.prop(action, "replace_dst", text="")
|
|
subsub = sub.row(align=True)
|
|
subsub.active = action.use_replace_regex_src
|
|
subsub.prop(action, "use_replace_regex_dst", text="", icon='SORTBYEXT')
|
|
|
|
# Row.
|
|
if re_error_dst is not None:
|
|
row = col.split(factor=fac)
|
|
row.label(text="")
|
|
row.alert = True
|
|
row.label(text=re_error_dst)
|
|
|
|
# Row 4: case.
|
|
row = col.split(factor=fac)
|
|
row.label(text="")
|
|
row.prop(action, "replace_match_case")
|
|
|
|
elif ty == 'CASE':
|
|
# Row 2: method.
|
|
row = col.split(factor=fac)
|
|
row.alignment = 'RIGHT'
|
|
row.label(text="Convert To")
|
|
row.row().prop(action, "case_method", expand=True)
|
|
|
|
# Column 2: add-remove.
|
|
row = split.split(align=True)
|
|
row.prop(action, "op_remove", text="", icon='REMOVE')
|
|
row.prop(action, "op_add", text="", icon='ADD')
|
|
|
|
layout.label(text=iface_("Rename {:d} {:s}").format(len(self._data[0]), self._data[2]), translate=False)
|
|
|
|
def check(self, context):
|
|
changed = False
|
|
for i, action in enumerate(self.actions):
|
|
if action.op_add:
|
|
action.op_add = False
|
|
self.actions.add()
|
|
if i + 2 != len(self.actions):
|
|
self.actions.move(len(self.actions) - 1, i + 1)
|
|
changed = True
|
|
break
|
|
if action.op_remove:
|
|
action.op_remove = False
|
|
if len(self.actions) > 1:
|
|
self.actions.remove(i)
|
|
changed = True
|
|
break
|
|
|
|
if (
|
|
(self._data_source_prev != self.data_source) or
|
|
(self._data_type_prev != self.data_type)
|
|
):
|
|
self._data_update(context)
|
|
changed = True
|
|
|
|
return changed
|
|
|
|
def execute(self, context):
|
|
import re
|
|
|
|
seq, attr, descr = self._data
|
|
|
|
actions = self.actions
|
|
|
|
# Sanitize actions.
|
|
for action in actions:
|
|
if action.use_replace_regex_src:
|
|
try:
|
|
re.compile(action.replace_src)
|
|
except Exception as ex:
|
|
self.report({'ERROR'}, "Invalid regular expression (find): " + str(ex))
|
|
return {'CANCELLED'}
|
|
|
|
if action.use_replace_regex_dst:
|
|
try:
|
|
re.sub(action.replace_src, action.replace_dst, "")
|
|
except Exception as ex:
|
|
self.report({'ERROR'}, "Invalid regular expression (replace): " + str(ex))
|
|
return {'CANCELLED'}
|
|
|
|
total_len = 0
|
|
change_len = 0
|
|
for item in seq:
|
|
name_src = getattr(item, attr)
|
|
name_dst = self._apply_actions(actions, name_src)
|
|
if name_src != name_dst:
|
|
setattr(item, attr, name_dst)
|
|
change_len += 1
|
|
total_len += 1
|
|
|
|
self.report({'INFO'}, rpt_("Renamed {:d} of {:d} {:s}").format(change_len, total_len, descr))
|
|
|
|
return {'FINISHED'}
|
|
|
|
def invoke(self, context, event):
|
|
|
|
self._data_update(context)
|
|
|
|
if not self.actions:
|
|
self.actions.add()
|
|
wm = context.window_manager
|
|
return wm.invoke_props_dialog(self, width=400)
|
|
|
|
|
|
class WM_MT_splash_quick_setup(Menu):
|
|
bl_label = "Quick Setup"
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
wm = context.window_manager
|
|
prefs = context.preferences
|
|
|
|
layout.operator_context = 'EXEC_DEFAULT'
|
|
|
|
old_version = bpy.types.PREFERENCES_OT_copy_prev.previous_version()
|
|
can_import = bpy.types.PREFERENCES_OT_copy_prev.poll(context) and old_version
|
|
|
|
if can_import:
|
|
layout.label(text="Import Preferences From Previous Version")
|
|
split = layout.split(factor=0.20) # Left margin.
|
|
split.label()
|
|
|
|
split = split.split(factor=0.73) # Content width.
|
|
col = split.column()
|
|
col.operator(
|
|
"preferences.copy_prev",
|
|
text=iface_("Import Blender {:d}.{:d} Preferences", "Operator").format(*old_version),
|
|
icon='NONE',
|
|
translate=False,
|
|
)
|
|
layout.separator()
|
|
layout.separator(type='LINE')
|
|
|
|
if can_import:
|
|
layout.label(text="Create New Preferences")
|
|
else:
|
|
layout.label(text="Quick Setup")
|
|
|
|
split = layout.split(factor=0.20) # Left margin.
|
|
split.label()
|
|
split = split.split(factor=0.73) # Content width.
|
|
col = split.column()
|
|
col.use_property_split = True
|
|
col.use_property_decorate = False
|
|
|
|
# Languages.
|
|
if bpy.app.build_options.international:
|
|
col.prop(prefs.view, "language")
|
|
|
|
# Themes.
|
|
sub = col.column(heading="Theme")
|
|
label = bpy.types.USERPREF_MT_interface_theme_presets.bl_label
|
|
if label == "Presets":
|
|
label = "Blender Dark"
|
|
sub.menu("USERPREF_MT_interface_theme_presets", text=label)
|
|
|
|
col.separator()
|
|
|
|
# Shortcuts.
|
|
kc = wm.keyconfigs.active
|
|
kc_prefs = kc.preferences
|
|
|
|
sub = col.column(heading="Keymap")
|
|
text = bpy.path.display_name(kc.name)
|
|
if not text:
|
|
text = "Blender"
|
|
sub.menu("USERPREF_MT_keyconfigs", text=text)
|
|
|
|
if hasattr(kc_prefs, "select_mouse"):
|
|
col.row().prop(kc_prefs, "select_mouse", text="Mouse Select", expand=True)
|
|
|
|
if hasattr(kc_prefs, "spacebar_action"):
|
|
col.row().prop(kc_prefs, "spacebar_action", text="Spacebar Action")
|
|
|
|
# Save Preferences.
|
|
sub = col.column()
|
|
sub.separator(factor=2)
|
|
|
|
if can_import:
|
|
sub.operator("wm.save_userpref", text="Save New Preferences", icon='NONE')
|
|
else:
|
|
sub.operator("wm.save_userpref", text="Continue")
|
|
|
|
layout.separator(factor=2.0)
|
|
|
|
|
|
class WM_MT_splash(Menu):
|
|
bl_label = "Splash"
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
layout.operator_context = 'EXEC_DEFAULT'
|
|
layout.emboss = 'PULLDOWN_MENU'
|
|
|
|
split = layout.split()
|
|
|
|
# Templates
|
|
col1 = split.column()
|
|
col1.label(text="New File")
|
|
|
|
bpy.types.TOPBAR_MT_file_new.draw_ex(col1, context, use_splash=True)
|
|
|
|
# Recent
|
|
col2 = split.column()
|
|
col2_title = col2.row()
|
|
|
|
found_recent = col2.template_recent_files()
|
|
|
|
if found_recent:
|
|
col2_title.label(text="Recent Files")
|
|
else:
|
|
# Links if no recent files.
|
|
col2_title.label(text="Getting Started")
|
|
|
|
col2.operator("wm.url_open_preset", text="Manual", icon='URL').type = 'MANUAL'
|
|
col2.operator("wm.url_open", text="Tutorials", icon='URL').url = "https://www.blender.org/tutorials/"
|
|
col2.operator("wm.url_open", text="Support", icon='URL').url = "https://www.blender.org/support/"
|
|
col2.operator("wm.url_open", text="User Communities", icon='URL').url = "https://www.blender.org/community/"
|
|
col2.operator("wm.url_open_preset", text="Blender Website", icon='URL').type = 'BLENDER'
|
|
|
|
layout.separator()
|
|
|
|
split = layout.split()
|
|
|
|
col1 = split.column()
|
|
sub = col1.row()
|
|
sub.operator_context = 'INVOKE_DEFAULT'
|
|
sub.operator("wm.open_mainfile", text="Open...", icon='FILE_FOLDER')
|
|
col1.operator("wm.recover_last_session", icon='RECOVER_LAST')
|
|
|
|
col2 = split.column()
|
|
|
|
col2.operator("wm.url_open_preset", text="Donate", icon='FUND').type = 'FUND'
|
|
col2.operator("wm.url_open_preset", text="What's New", icon='URL').type = 'RELEASE_NOTES'
|
|
|
|
layout.separator()
|
|
|
|
if (not bpy.app.online_access) and bpy.app.online_access_override:
|
|
self.layout.label(text="Running in Offline Mode", icon='INTERNET_OFFLINE')
|
|
|
|
layout.separator()
|
|
|
|
|
|
class WM_MT_splash_about(Menu):
|
|
bl_label = "About"
|
|
|
|
def draw(self, context):
|
|
|
|
layout = self.layout
|
|
layout.operator_context = 'EXEC_DEFAULT'
|
|
|
|
split = layout.split(factor=0.65)
|
|
|
|
col = split.column(align=True)
|
|
col.scale_y = 0.8
|
|
col.label(text=iface_("Version: {:s}").format(bpy.app.version_string), translate=False)
|
|
col.separator(factor=2.5)
|
|
col.label(text=iface_("Date: {:s} {:s}").format(
|
|
bpy.app.build_commit_date.decode("utf-8", "replace"),
|
|
bpy.app.build_commit_time.decode("utf-8", "replace")),
|
|
translate=False,
|
|
)
|
|
col.label(text=iface_("Hash: {:s}").format(bpy.app.build_hash.decode("ascii")), translate=False)
|
|
col.label(text=iface_("Branch: {:s}").format(bpy.app.build_branch.decode("utf-8", "replace")), translate=False)
|
|
|
|
# This isn't useful information on MS-Windows or Apple systems as dynamically switching
|
|
# between windowing systems is only supported between X11/WAYLAND.
|
|
from _bpy import _ghost_backend
|
|
ghost_backend = _ghost_backend()
|
|
if ghost_backend not in {'NONE', 'DEFAULT'}:
|
|
col.label(text=iface_("Windowing Environment: {:s}").format(_ghost_backend()), translate=False)
|
|
del _ghost_backend, ghost_backend
|
|
|
|
col.separator(factor=2.0)
|
|
col.label(text="Blender is free software")
|
|
col.label(text="Licensed under the GNU General Public License")
|
|
|
|
col = split.column(align=True)
|
|
col.emboss = 'PULLDOWN_MENU'
|
|
col.operator("wm.url_open_preset", text="Donate", icon='FUND').type = 'FUND'
|
|
col.operator("wm.url_open_preset", text="What's New", icon='URL').type = 'RELEASE_NOTES'
|
|
col.separator(factor=2.0)
|
|
col.operator("wm.url_open_preset", text="Credits", icon='URL').type = 'CREDITS'
|
|
col.operator("wm.url_open", text="License", icon='URL').url = "https://www.blender.org/about/license/"
|
|
col.operator("wm.url_open", text="Blender Store", icon='URL').url = "https://store.blender.org"
|
|
col.operator("wm.url_open_preset", text="Blender Website", icon='URL').type = 'BLENDER'
|
|
|
|
|
|
class WM_MT_region_toggle_pie(Menu):
|
|
bl_label = "Region Toggle"
|
|
|
|
# Map the `region.type` to the `space_data` attribute & text label.
|
|
# The order of items defines priority, so for example in the sequencer
|
|
# when there is both a toolbar and channels, the toolbar gets the
|
|
# axis-aligned pie, and the channels don't.
|
|
_region_info = {
|
|
'TOOLS': "show_region_toolbar",
|
|
'UI': "show_region_ui",
|
|
# Note that the tool header is enabled/disabled along with the header,
|
|
# no need to include both in this list.
|
|
'HEADER': "show_region_header",
|
|
'FOOTER': "show_region_footer",
|
|
'ASSET_SHELF': "show_region_asset_shelf",
|
|
'CHANNELS': "show_region_channels",
|
|
}
|
|
# Map the `region.alignment` to the axis-aligned pie position.
|
|
_region_align_pie = {
|
|
'LEFT': 0,
|
|
'RIGHT': 1,
|
|
'BOTTOM': 2,
|
|
'TOP': 3,
|
|
}
|
|
# Map the axis-aligned pie to alternative directions, see `ui_radial_dir_order` in C++ source.
|
|
# The value is the preferred direction in order of priority, two diagonals, then the flipped direction.
|
|
_region_dir_pie_alternatives = {
|
|
0: (4, 6, 1),
|
|
1: (5, 7, 0),
|
|
2: (6, 7, 3),
|
|
3: (4, 5, 2),
|
|
}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data is not None
|
|
|
|
@classmethod
|
|
def _draw_pie_regions_from_alignment(cls, context, pie):
|
|
space_data = context.space_data
|
|
# Store each region by it's type.
|
|
region_by_type = {}
|
|
|
|
for region in context.area.regions:
|
|
region_type = region.type
|
|
# If the attribute doesn't exist, the RNA definition is outdated.
|
|
# See: #134339 and its fix for reference.
|
|
attr = cls._region_info.get(region_type, None)
|
|
if attr is None:
|
|
continue
|
|
# In some cases channels exists but can't be toggled.
|
|
assert hasattr(space_data, attr)
|
|
# Technically possible these double-up, in practice this should never happen.
|
|
if region_type in region_by_type:
|
|
print("{:s}: Unexpected double-up of region types {!r}".format(cls.__name__, region_type))
|
|
region_by_type[region_type] = region
|
|
|
|
# Axis aligned pie menu items to populate.
|
|
items = [[], [], [], [], [], [], [], []]
|
|
|
|
# Use predictable ordering.
|
|
for region_type in cls._region_info.keys():
|
|
region = region_by_type.get(region_type)
|
|
if region is None:
|
|
continue
|
|
index = cls._region_align_pie[region.alignment]
|
|
items[index].append(region_type)
|
|
|
|
# Handle any overflow (two or more regions with the same alignment).
|
|
# This happens in the sequencer (channels + toolbar),
|
|
# otherwise it should not be that common.
|
|
items_overflow = []
|
|
for index in range(4):
|
|
if len(items[index]) <= 1:
|
|
continue
|
|
for index_other in cls._region_dir_pie_alternatives[index]:
|
|
if not items[index_other]:
|
|
items[index_other].append(items[index].pop(1))
|
|
if len(items[index]) <= 1:
|
|
break
|
|
del index_other
|
|
|
|
for index in range(4):
|
|
if len(items[index]) <= 1:
|
|
continue
|
|
for index_other in range(4, 8):
|
|
if not items[index_other]:
|
|
items[index_other].append(items[index].pop(1))
|
|
if len(items[index]) <= 1:
|
|
break
|
|
# Only happens when there are more than 8 regions - practically never!
|
|
for index in range(4):
|
|
while len(items[index]) > 1:
|
|
items_overflow.append([items[index].pop(1)])
|
|
|
|
# Use to access the labels.
|
|
enum_items = bpy.types.Region.bl_rna.properties["type"].enum_items_static_ui
|
|
|
|
for region_type_list in (items + items_overflow):
|
|
if not region_type_list:
|
|
pie.separator()
|
|
continue
|
|
assert len(region_type_list) == 1
|
|
region_type = region_type_list[0]
|
|
text = enum_items[region_type].name
|
|
attr = cls._region_info[region_type]
|
|
value = getattr(space_data, attr)
|
|
props = pie.operator(
|
|
"wm.context_toggle",
|
|
text=text,
|
|
text_ctxt=i18n_contexts.default,
|
|
icon='CHECKBOX_HLT' if value else 'CHECKBOX_DEHLT',
|
|
)
|
|
props.data_path = "space_data." + attr
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
pie = layout.menu_pie()
|
|
self._draw_pie_regions_from_alignment(context, pie)
|
|
|
|
|
|
class WM_OT_drop_blend_file(Operator):
|
|
bl_idname = "wm.drop_blend_file"
|
|
bl_label = "Handle dropped .blend file"
|
|
bl_options = {'INTERNAL'}
|
|
|
|
filepath: StringProperty(
|
|
subtype='FILE_PATH',
|
|
options={'SKIP_SAVE'},
|
|
)
|
|
|
|
def invoke(self, context, _event):
|
|
context.window_manager.popup_menu(self.draw_menu, title=bpy.path.basename(self.filepath), icon='QUESTION')
|
|
return {'FINISHED'}
|
|
|
|
def draw_menu(self, menu, _context):
|
|
layout = menu.layout
|
|
|
|
col = layout.column()
|
|
col.operator_context = 'INVOKE_DEFAULT'
|
|
props = col.operator("wm.open_mainfile", text="Open", icon='FILE_FOLDER')
|
|
props.filepath = self.filepath
|
|
props.display_file_selector = False
|
|
|
|
layout.separator()
|
|
col = layout.column()
|
|
col.operator_context = 'INVOKE_DEFAULT'
|
|
col.operator("wm.link", text="Link...", icon='LINK_BLEND').filepath = self.filepath
|
|
col.operator("wm.append", text="Append...", icon='APPEND_BLEND').filepath = self.filepath
|
|
|
|
|
|
classes = (
|
|
WM_OT_context_collection_boolean_set,
|
|
WM_OT_context_cycle_array,
|
|
WM_OT_context_cycle_enum,
|
|
WM_OT_context_cycle_int,
|
|
WM_OT_context_menu_enum,
|
|
WM_OT_context_modal_mouse,
|
|
WM_OT_context_pie_enum,
|
|
WM_OT_context_scale_float,
|
|
WM_OT_context_scale_int,
|
|
WM_OT_context_set_boolean,
|
|
WM_OT_context_set_enum,
|
|
WM_OT_context_set_float,
|
|
WM_OT_context_set_id,
|
|
WM_OT_context_set_int,
|
|
WM_OT_context_set_string,
|
|
WM_OT_context_set_value,
|
|
WM_OT_context_toggle,
|
|
WM_OT_context_toggle_enum,
|
|
WM_OT_doc_view,
|
|
WM_OT_doc_view_manual,
|
|
WM_OT_drop_blend_file,
|
|
WM_OT_operator_cheat_sheet,
|
|
WM_OT_operator_pie_enum,
|
|
WM_OT_path_open,
|
|
WM_OT_properties_add,
|
|
WM_OT_properties_context_change,
|
|
WM_OT_properties_edit,
|
|
WM_OT_properties_edit_value,
|
|
WM_OT_properties_remove,
|
|
WM_OT_sysinfo,
|
|
WM_OT_owner_disable,
|
|
WM_OT_owner_enable,
|
|
WM_OT_url_open,
|
|
WM_OT_url_open_preset,
|
|
WM_OT_tool_set_by_id,
|
|
WM_OT_tool_set_by_index,
|
|
WM_OT_tool_set_by_brush_type,
|
|
WM_OT_toolbar,
|
|
WM_OT_toolbar_fallback_pie,
|
|
WM_OT_toolbar_prompt,
|
|
BatchRenameAction,
|
|
WM_OT_batch_rename,
|
|
WM_MT_splash_quick_setup,
|
|
WM_MT_splash,
|
|
WM_MT_splash_about,
|
|
WM_MT_region_toggle_pie,
|
|
)
|