Update property when active tool changes?

How do I detect if the active tool changes? I need to update a property when the active tool changes. This seems to work ,but you have to change mode for it to be detected.

import bpy
from bpy.app.handlers import persistent


@persistent
def toolUpdated(scene):  
    tools = bpy.context.workspace.tools
    active_tool = tools.from_space_view3d_mode(bpy.context.mode).idname
    
    depsgraph = bpy.context.evaluated_depsgraph_get()

    for update in depsgraph.updates:
        if update.id.original != active_tool:            
            print(active_tool)        

    
def register():    
    bpy.app.handlers.depsgraph_update_post.append(toolUpdated)     

def unregister():
    bpy.app.handlers.depsgraph_update_post.remove(toolUpdated)
    
if __name__ == "__main__":
    register()

not 100% sure without doing some tests, but I don’t believe changing the active tool will cause the depsgraph to update. if that’s the case, then your handler isn’t running until something forces the depsgraph update to happen. have you tried using a timer and caching the active tool in a local variable instead?

Got a example of something? Don’t know much about depsgraph, handlers and timers. Just finding bits and pieces of info online.

I mean, the same thing you have but with a timer instead of depsgraph_update (which by the way, I just verified does not get triggered when you change tools- looks like a timer is the way to go here):

import bpy
from bpy.app.handlers import persistent

last_tool = None

@persistent
def tool_updated():  
    global last_tool
    tools = bpy.data.workspaces['Scripting'].tools # i did this in the scripting workspace and just forced it to use the correct one rather than relying on context to be correct. you can do whatever here.
    active_tool = tools.from_space_view3d_mode(bpy.context.mode).idname
    if active_tool != last_tool:
        print(f"Tool was changed from {last_tool} to {active_tool}")
        last_tool = active_tool

    # check again once every second.
    return 0.5

bpy.app.timers.register(tool_updated)

Output:

Tool was changed from None to builtin.move
Tool was changed from builtin.move to builtin.select
Tool was changed from builtin.select to builtin.move

@testure

How do I do it without defining which workspace. Both of these with print out info ,but throws a NoneType Error in the timer.

Edit:
Actually I need it to only work in Sculpt, Vertex Paint and Texture Paint mode. Spent the last 8 hours and can’t get anything to work.

tools = bpy.data.workspaces[bpy.context.workspace.name].tools
tools = bpy.context.workspace.tools

Edit:
This is related ,but unrelated. I’m trying to create a poll that only gets textures from my previews. It eventually fails and displays textures from multiple brushes even though the preview is correct. The only way I know to updated it, is clear the previews when changes are made to active tool or brush. Is there a better way to do this?

Previews


preview_collections_textures = {}


pcoll = bpy.utils.previews.new()
pcoll.my_previews_dir = ""
pcoll.my_previews = ()
preview_collections_textures["main"] = pcoll


Pointer Property


bpy.types.Brush.images = bpy.props.PointerProperty(
                        type=bpy.types.Texture, 
                        poll=poll_image_items,                           
                        )
                        

Pointer Property Poll


def poll_image_items(self, object):

    preview_textures = []
    
    # Get preview items        
    preview_items = preview_collections_textures["main"]

    for item in preview_items:
        preview_textures.append(item)
        
    # Check for texture image type and see if texture uses the same image as previews                                                        
    return object.type == 'IMAGE' and object.image.name in preview_textures

it looks like preview_collections_textures["main"] continues to add textures unless cleared each time, instead of just the previews for current brush.

you can’t get a reference to your workspace this way. if you had bpy.context.workspace you wouldn’t need to get it from bpy.data.workspaces, it’s the same object. Once you have a reference to the workspace object, you can get its tools attribute. If you already have an object reference you don’t need to dig through bpy.data to get it (which you dont’, hence the NoneType error). The context the timer is run from has no reference to the current workspace, so you’ll need to get it another way.

If you’re interested in seeing exactly what you have available to you via a Timer’s context, all you have to do is output a copy of bpy.context from inside a timer:

import bpy
from pprint import pprint as pp

def context_check():  
    pp(bpy.context.copy())
    return None

bpy.app.timers.register(context_check)

Output:

{‘area’: None,
‘blend_data’: <bpy_struct, BlendData at 0x0000017599E76148>,
‘collection’: bpy.data.scenes[‘Scene’].collection,
‘engine’: ‘CYCLES’,
‘evaluated_depsgraph_get’: <bpy_func Context.evaluated_depsgraph_get()>,
‘gizmo_group’: None,
‘layer_collection’: bpy.data.scenes[‘Scene’]…LayerCollection,
‘mode’: ‘OBJECT’,
‘preferences’: <bpy_struct, Preferences at 0x00007FF63F7EFF00>,
‘region’: None,
‘region_data’: None,
‘scene’: bpy.data.scenes[‘Scene’],
‘screen’: None,
‘space_data’: None,
‘tool_settings’: bpy.data.scenes[‘Scene’].tool_settings,
‘view_layer’: bpy.data.scenes[‘Scene’].view_layers[“View Layer”],
‘window’: None,
‘window_manager’: bpy.data.window_managers[‘WinMan’],
‘workspace’: None}

Note that workspace is None. You’ll need to use bpy.data.workspaces and find the one you’re interested in by name or index.

@testure

Since it’s for a addon, I don’t see how I could define which workspace. Not being able to get current workspace is becoming a pain, LOL. I came up with this and it seems to work for the modes ,but it fails as soon as you change in another workspace.

active_sculpt_tool = None
last_sculpt_tool = None

active_vertex_tool = None
last_vertex_tool = None

active_texture_tool = None
last_texture_tool = None

tool_changed = False


@persistent
def modeToolUpdated():
    global active_sculpt_tool    
    global last_sculpt_tool
    
    global active_vertex_tool    
    global last_vertex_tool
    
    global active_texture_tool    
    global last_texture_tool
               
    global tool_changed


    for w in bpy.data.workspaces:
                  
        sculpt_tool = w.tools.from_space_view3d_mode(mode='SCULPT')
                                     
        if sculpt_tool:
            active_sculpt_tool = sculpt_tool.idname              
            if active_sculpt_tool is not None:                                
                if active_sculpt_tool != last_sculpt_tool:
                    print(f"(Tool: {last_sculpt_tool}) was changed in (Workspace: {w.name}) to {active_sculpt_tool}")                     
                                            
                    last_sculpt_tool = active_sculpt_tool
                    tool_changed = True
                                                                      
        vertex_tool = w.tools.from_space_view3d_mode(mode='PAINT_VERTEX')
                                
        if vertex_tool:
            active_vertex_tool = vertex_tool.idname               
            if active_vertex_tool is not None:                                 
                if active_vertex_tool != last_vertex_tool:
                    print(f"(Tool: {last_vertex_tool}) was changed in (Workspace: {w.name}) to {active_vertex_tool}")                    
                                            
                    last_vertex_tool = active_vertex_tool 
                    tool_changed = True
                    
        texture_tool = w.tools.from_space_view3d_mode(mode='PAINT_TEXTURE')
                                
        if texture_tool:
            active_texture_tool = texture_tool.idname              
            if active_texture_tool is not None:                                 
                if active_texture_tool != last_texture_tool:
                    print(f"(Tool: {last_texture_tool}) was changed in (Workspace: {w.name}) to {active_texture_tool}")                     
                                            
                    last_texture_tool = active_texture_tool 
                    tool_changed = True

                    
    if tool_changed:
        print("Tool got changed, update previews")
        
        '''for pcoll in preview_collections_textures.values():
            bpy.utils.previews.remove(pcoll)
            
        pcoll = bpy.utils.previews.new()
        pcoll.my_previews_dir = ""
        pcoll.my_previews = ()
        preview_collections_textures["main"] = pcoll'''
   
        tool_changed = False
                                                              
    # check again once every second.
    return 0.05

yeah I dunno man, you’re trying to do something Blender doesn’t directly support. You have an imperfect workaround, might be the best you’re going to get. If you want to get really hacky you can start a modal operator on startup and just run it in the background the entire time Blender is running.

Well, it may be a hack ,but it works, LOL. How do I start it on startup? If I do a handler to start it, Blender goes nuts with the modal operator.

starting a modal operator on startup without user intervention is itself a hack, and requires a few hacks to make it work- just so we’re clear it’s hacks all the way down from here on out, but it does work.

import bpy

modal_op_running = False
class APP_OT_modal_startup(bpy.types.Operator):
    bl_idname = "app.modal_startup"
    bl_label = "This modal operator starts when blender starts."
    bl_options = {'INTERNAL'} # hide it from the operator search so users don't accidentally run it.

    def modal(self, context, event):
        global modal_op_running
        if not modal_op_running:
            return {'FINISHED'}

        # do your stuff here.
        # FYI - modal gets fired a LOT. you should throttle your updates (check current time against last update time, skip this update if it's below some threshold, etc)


        return {'PASS_THROUGH'}

    def execute(self, context):
        global modal_op_running
        if modal_op_running:
            print(f"Something tried to start the op but it was already running")
            return {'FINISHED'}

        modal_op_running = True

        print("Started the global modal operator!")
        context.window_manager.modal_handler_add(self)

        return {'RUNNING_MODAL'}

def get_view3d_override():
    # HACK- we can't fire a modal operator from a timer because it needs a context to run in.
    # the easiest way to do that is to just find the first view3d area we can and build an override out of it.
    for window in bpy.context.window_manager.windows:
        for area in [a for a in window.screen.areas if a.type == 'VIEW_3D']:
            for region in [r for r in area.regions if r.type == 'WINDOW']:
                return {
                    'window': window,
                    'screen': window.screen,
                    'area': area,
                    'region': region,
                    'scene': bpy.context.scene
                    }
    return None

def start():
    # HACK: we have to do this in a timer because we might not have a window manager from which to create a context override yet. Once Blender is fully loaded, the modal operator will start.
    try:
        print("trying to start the modal op...")        
        result = bpy.ops.app.modal_startup(get_view3d_override())
        print("Modal operator startup was successful, shutting down the timer.")
        return None
    except Exception as e:
        print(f"Could not start the modal operator! {str(e)}")
        print("Trying again in 2 seconds.")
        return 2.0

def register():
    bpy.utils.register_class(APP_OT_modal_startup)
    if not bpy.app.timers.is_registered(start):
        bpy.app.timers.register(start)
    print("Starting the modal op using a timer callback.")

def unregister():
    if bpy.app.timers.is_registered(start):
        bpy.app.timers.unregister(start)
    bpy.utils.unregister_class(APP_OT_modal_startup)

Also, it will stop running if you open a new scene, so you’ll need a post_load app handler to re-register the startup timer. If you do something like this you need to be aware that you’re well off the reservation and weird shit can and will happen. As an example, if you try to reload scripts it will fail because you can’t reload scripts while a modal operator is running.

Modals disable auto-save for the duration, so I’d advise against that.

Instead you can wrap activate_by_id to get the activated tool. Note that this function also triggers on certain ui updates eg. scaling a region.

This prints the idname to console on tool activation.

import bpy
from bl_ui import space_toolsystem_common

def factory_callback(func):

    def callback(*args, **kwargs):

        # Do whatever you want with the inbound args
        # Let's print tool idname to console
        idname = args[2]
        print(idname)

        return func(*args, **kwargs)
    return callback

space_toolsystem_common.activate_by_id = factory_callback(
    space_toolsystem_common.activate_by_id
)
2 Likes

@kaio

This seem like a better solution. It’s kind of a mystery code, cause I couldn’t figure out where you got that code info ,but then started looking at the space_toolsystem_common.py file.

Just realized there might be a cleaner way of getting callbacks for active tools using the msgbus. Since workspace tools aren’t rna properties themselves, figured it’s possible to monitor the bpy_prop_collection which changes with the tool.

The handle is the workspace itself, so shouldn’t have to worry about keeping a reference.
The subscription lasts until a new file is loaded, so add a load_post callback which reapplies it.

Note this doesn’t proactively subscribe to workspaces added afterwards.
Might need a separate callback for that :joy:

import bpy

def rna_callback(workspace):
    idname = workspace.tools[-1].idname
    print(idname)

def subscribe(workspace):
    bpy.msgbus.subscribe_rna(
        key=ws.path_resolve("tools", False),
        owner=workspace,
        args=(workspace,),
        notify=rna_callback)

if __name__ == "__main__":
    ws = bpy.context.workspace
    subscribe(bpy.context.workspace)

# Subscribe to all workspaces:
if 0:
    for ws in bpy.data.workspaces:
        subscribe(bpy.context.workspace)

# Clear all workspace subscriptions
if 0:
    for ws in bpy.data.workspaces:
        bpy.msgbus.clear_by_owner(ws)
3 Likes

Would it be possible to do something similar for tools / actions / operators that have been invoked by menus like the rightclick menu or radial menus ?

https://developer.blender.org/D10635

allows tool change detection in a clean way.

1 Like