1

I created a custom ribbon UI for Excel, using the Office Ribbon X Editor. On that ribbon are several toggleButtons. The XML looks like this:

<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="OnRibbonLoad">
    <ribbon>
        <tabs>
            <tab id="customTab" label="MyRibbon" insertAfterMso="TabHome">
                <group id="customGroup1" label="MyButtons">

                    <toggleButton id="customButton1" label="Label1" getPressed="GetPressed" onAction="AnyButtonGotPressed"/>
                    <toggleButton id="customButton2" label="Label2" getPressed="GetPressed" onAction="AnyButtonGotPressed"/>
                    <toggleButton id="customButton3" label="Label3" getPressed="GetPressed" onAction="AnyButtonGotPressed"/>
                     [...]

                </group>
            </tab>
        </tabs>
    </ribbon>
</customUI

If a user presses one of the buttons, a macro is executed, that then "listens to" the clicks the user does in the workbook cells and does stuff to the clicked cells, depending on which toggleButton is currently pressed. Since it is important for the user to know, which macro will be executed, it is necessary that always only one toggleButton can be "toggled" at a time (in the sense of active state, true, 1, pressed...).

Therefore I tried to write a routine, that puts all toggleButtons in an array and to loop over the array to invalidate each control.ID that doesn't match the one, which triggered the routine. I implemented it in a way, that every toggleButton on the custom ribbon calls the "AnyButtonGotPressed" event, which then does the looping and invalidating and afterwads calls the regarding macro, using a Select Case statement. The VBA code looks like this and is basically identical for the "First", "Second" and "Third" macro:

Option Explicit

Public myRibbon As IRibbonUI
Public pressed As Boolean
----------------------------------------------------------------------------------

Sub OnRibbonLoad(ribbon As IRibbonUI)

    Set myRibbon = ribbon
    
End Sub
----------------------------------------------------------------------------------

Sub GetPressed(control As IRibbonControl, ByRef returnedVal)

    'However this seems to be neccessary to "unpress" the toggleButtons
    
End Sub
----------------------------------------------------------------------------------

Sub AnyButtonGotPressed(control As IRibbonControl, IsPressed)

Dim arrButtons As Variant
Dim varButton As Variant

    arrButtons = Array("customButton1", "customButton2", "customButton3")

    For Each varButton In arrButtons
    
        If Not varButton = control.ID Then myRibbon.InvalidateControl varButton
        
    Next

    pressed = IsPressed

    Select Case control.ID
    
        Case "customButton1"
            Call FirstMacro(control.ID)
                    
        Case "customButton2"        
            Call SecondMacro(control.ID)
        
         Case "customButton3"        
            Call ThirdMacro(control.ID)
        
    End Select
        
End Sub
----------------------------------------------------------------------------------

Sub FirstMacro(ButtonName As String)
    
    If Not pressed Then GoTo ErrorHandler
    On Error GoTo ErrorHandler
       
    With ThisWorkbook.Sheets(1)
        
Continue:
        
        .Range("A1").Select
                
        Do While Selection.Address = "$A$1"
            
            DoEvents           
            If Not pressed Then GoTo ErrorHandler
                
        Loop

        'Here comes stuff to do with the cell, if the user clicks somewhere out of "A1"

        If pressed Then GoTo Continue
    
    End With

ErrorHandler:

    myRibbon.InvalidateControl ButtonName
        
End Sub

Unfortunately this doesn't work as expected, namely toggleButtons do not get "untoggled" if the user selects a new one. Instead all consecutive selected buttons stay pressed at the same time.

While trying to debug I found out that if I put a breakpoint at "Select Case" of the "AnyButtonGotPressed" it gets weird:

  1. I get as many "Can't execute code in breakmode" warnings as I have toggleButtons that are related to "AnyButtonGotPressed".

  2. After clicking "ok" the code continues execution and works as expected, meaning that all other toggleButtons got unpressed and only the last pressed one is shown as currently pressed on the ribbon.

But unfortunately I cannot get it to work like I want it without the breakpoint.

I already tried to add several "DoEvents" within the "invalidate"-Loop and afterwards and also to set Application.ScreenUpdating to False and True as well as .EnableEvents with no success, the ribbon doesn't unpress the toggleButtons without the breakpoint. I also tried it without loop and put several "If" statements after another to invalidate the toggleButtons that shall get unpressed. But nothing of that helped.

Would you please be so kind an provide me with any ideas, how to fix that?

If possible, I'd prefer the easiest/most simple solution since there will be more than 14 toggleButtons (I also had a debate with chatGPT already but her suggestions weren't really simple and/or didn't work - like adding DoEvents and a 100 milliseconds of break to avoid timing problems).

1 Answer 1

1

Firstly, we need a way to store data in the workbook. You can use named ranges or other stuff but I will use my library LibExcelBookItems because it's very easy to use.

Code also here for LibExcelBookItems module:

'''=============================================================================
''' Excel VBA Tools
''' -----------------------------------------------
''' https://github.com/cristianbuse/Excel-VBA-Tools
''' -----------------------------------------------
''' MIT License
'''
''' Copyright (c) 2018 Ion Cristian Buse
'''
''' Permission is hereby granted, free of charge, to any person obtaining a copy
''' of this software and associated documentation files (the "Software"), to
''' deal in the Software without restriction, including without limitation the
''' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
''' sell copies of the Software, and to permit persons to whom the Software is
''' furnished to do so, subject to the following conditions:
'''
''' The above copyright notice and this permission notice shall be included in
''' all copies or substantial portions of the Software.
'''
''' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
''' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
''' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
''' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
''' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
''' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
''' IN THE SOFTWARE.
'''=============================================================================

'*******************************************************************************
'' Description:
''    - Simple strings can be stored/retrieved using CustomXMLParts per book
''    - This module encapsulates the XML logic and exposes easy-to-use methods
''      without the need to write actual XML
'' Public/Exposed methods:
''    - BookItem - parametric property Get/Let
''    - GetBookItemNames
'' Notes:
''    - To delete a property simply set the value to a null string
''      e.g. BookItem(ThisWorkbook, "itemName") = vbNullString
'*******************************************************************************

Option Explicit
Option Private Module

Private Const XML_NAMESPACE As String = "ManagedExcelCustomXML"
Private Const rootName As String = "root"

'*******************************************************************************
'Returns the Root CustomXMLPart under the custom namespace
'part is created if missing!
'*******************************************************************************
Private Function GetRootXMLPart(ByVal book As Workbook) As CustomXMLPart
    Const xmlDeclaration As String = "<?xml version=""1.0"" encoding=""UTF-8""?>"
    Const rootTag As String = "<" & rootName & " xmlns=""" & XML_NAMESPACE _
                            & """></" & rootName & ">"
    Const rootXmlPart As String = xmlDeclaration & rootTag
    '
    With book.CustomXMLParts.SelectByNamespace(XML_NAMESPACE)
        If .Count = 0 Then
            Set GetRootXMLPart = book.CustomXMLParts.Add(rootXmlPart)
        Else
            Set GetRootXMLPart = .Item(1)
        End If
    End With
End Function

'*******************************************************************************
'Clears all CustomXMLParts under the custom namespace
'*******************************************************************************
Private Sub ClearRootXMLParts(ByVal book As Workbook)
    With book.CustomXMLParts.SelectByNamespace(XML_NAMESPACE)
        Dim i As Long
        For i = .Count To 1 Step -1
            .Item(i).Delete
        Next i
    End With
End Sub

'*******************************************************************************
'Get the Root Node under the custom namespace
'Node is created if missing!
'*******************************************************************************
Private Function GetRootNode(ByVal book As Workbook) As CustomXMLNode
    Dim root As CustomXMLNode
    If root Is Nothing Then
        With GetRootXMLPart(book)
            Dim nsPrefix As String
            nsPrefix = .NamespaceManager.LookupPrefix(XML_NAMESPACE)
            Set root = .SelectSingleNode("/" & nsPrefix & ":" & rootName & "[1]")
        End With
    End If
    Set GetRootNode = root
End Function

'*******************************************************************************
'Get an XML Node. Create it if missing
'*******************************************************************************
Private Function GetNode(ByVal book As Workbook _
                       , ByVal nodeName As String _
                       , ByVal addIfMIssing As Boolean) As CustomXMLNode
    Dim node As CustomXMLNode
    Dim expr As String
    '
    With GetRootNode(book)
        expr = .XPath & "/" & nodeName & "[1]"
        Set node = .SelectSingleNode(expr)
        If node Is Nothing And addIfMIssing Then
            .AppendChildNode nodeName
            Set node = .SelectSingleNode(expr)
        End If
    End With
    Set GetNode = node
End Function

'*******************************************************************************
'Retrieves/sets a book property value from a CustomXMLNode
'*******************************************************************************
Public Property Get BookItem(ByVal book As Workbook _
                           , ByVal itemName As String) As String
    ThrowIfInvalid book, itemName
    Dim node As CustomXMLNode
    Set node = GetNode(book, itemName, False)
    If Not node Is Nothing Then BookItem = node.Text
End Property
Public Property Let BookItem(ByVal book As Workbook _
                           , ByVal itemName As String _
                           , ByVal itemValue As String)
    ThrowIfInvalid book, itemName
    If LenB(itemValue) = 0 Then
        Dim node As CustomXMLNode
        Set node = GetNode(book, itemName, False)
        If Not node Is Nothing Then node.Delete
    Else
        GetNode(book, itemName, True).Text = itemValue
    End If
End Property
Private Sub ThrowIfInvalid(ByRef book As Workbook, ByRef itemName As String)
    Const methodName As String = "BookItem"
    If book Is Nothing Then
        Err.Raise 91, methodName, "Book not set"
    ElseIf LenB(itemName) = 0 Then
        Err.Raise 5, methodName, "Invalid item name"
    End If
End Sub

'*******************************************************************************
'Returns a collection of custom node names within the custom namespace
'*******************************************************************************
Public Function GetBookItemNames(ByVal book As Workbook) As Collection
    If book Is Nothing Then Err.Raise 91, "GetBookItemNames", "Book not set"
    '
    Dim coll As New Collection
    With GetRootNode(book).ChildNodes
        Dim i As Long
        ReDim arr(0 To .Count - 1)
        For i = 1 To .Count
            coll.Add .Item(i).BaseName
        Next i
    End With
    Set GetBookItemNames = coll
End Function

Example call:

BookItem(ThisWorkbook, "myVar") = myTextValue
Debug.Print BookItem(ThisWorkbook, "myVar")

Now that we have a way to store data in a persistant way, let's make sure we can recover the ribbon in case of a state loss. Replace this:

Option Explicit

Public myRibbon As IRibbonUI
Public pressed As Boolean
----------------------------------------------------------------------------------

Sub OnRibbonLoad(ribbon As IRibbonUI)

    Set myRibbon = ribbon
    
End Sub

with this:

Option Explicit

Private myRibbon As IRibbonUI

#If Mac Then
    #If VBA7 Then
        Private Declare PtrSafe Function CopyMemory Lib "/usr/lib/libc.dylib" Alias "memmove" (Destination As Any, Source As Any, ByVal Length As LongPtr) As LongPtr
    #Else
        Private Declare Function CopyMemory Lib "/usr/lib/libc.dylib" Alias "memmove" (Destination As Any, Source As Any, ByVal Length As Long) As Long
    #End If
#Else 'Windows
    'https://msdn.microsoft.com/en-us/library/mt723419(v=vs.85).aspx
    #If VBA7 Then
        Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source As Any, ByVal Length As LongPtr)
    #Else
        Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source As Any, ByVal Length As Long)
    #End If
#End If

Sub OnRibbonLoad(ByVal ribbon As IRibbonUI)
    Set SafeRibbon = ribbon
End Sub

'===============================================================================
'Set/Get Ribbon object
'===============================================================================
Private Property Set SafeRibbon(ByVal ribbonUI As IRibbonUI)
    Set myRibbon = ribbonUI
    Dim mustAvoidSaveDialog As Boolean
    mustAvoidSaveDialog = ThisWorkbook.IsAddin And ThisWorkbook.Saved
    BookItem(ThisWorkbook, "BookPtr") = CStr(ObjPtr(ThisWorkbook))
    BookItem(ThisWorkbook, "RibbonPtr") = CStr(ObjPtr(myRibbon))
    If mustAvoidSaveDialog Then ThisWorkbook.Saved = True
End Property
Public Property Get SafeRibbon() As IRibbonUI
    If myRibbon Is Nothing Then
        If BookItem(ThisWorkbook, "BookPtr") = CStr(ObjPtr(ThisWorkbook)) Then
            'Restore ribbon
            #If Win64 Then
                Dim ptr As LongLong
                Const ptrSize As Long = 8
            #Else
                Dim ptr As Long
                Const ptrSize As Long = 4
            #End If
            Dim obj As Object
            '
            ptr = Int(BookItem(ThisWorkbook, "RibbonPtr"))
            CopyMemory obj, ptr, ptrSize 'Unmanaged - reference not counted
            Set myRibbon = obj
            CopyMemory obj, 0, ptrSize 'Reference count not decremented
        End If
    End If
    Set SafeRibbon = myRibbon
End Property

Notice that myRibbon is now private and calls should only be made to the SafeRibbon parametric property. Also notice I removed the pressed module member - we won't need it anymore.


When a macro is already running in a loop, we cannot simply call another macro. We must wait for the first one to exit. We can use asyncronous calls to achieve that.

Let's also use book item library to retrive the state of the last pressed button when an invalidation occurs:

Sub GetPressed(control As IRibbonControl, ByRef returnedVal)
    Dim lastIDPressed As String
    '
    lastIDPressed = BookItem(ThisWorkbook, "lastIDPressed")
    returnedVal = (lastIDPressed = control.ID)
    If Len(lastIDPressed) > 0 Then AsyncMacro lastIDPressed
End Sub

Sub AnyButtonGotPressed(control As IRibbonControl, IsPressed)
    If Not IsPressed Then
        BookItem(ThisWorkbook, "lastIDPressed") = vbNullString
        Exit Sub
    End If
    '
    Dim lastIDPressed As String
    '
    lastIDPressed = BookItem(ThisWorkbook, "lastIDPressed")
    If Len(lastIDPressed) > 0 Then
        SafeRibbon.InvalidateControl lastIDPressed
    End If
    BookItem(ThisWorkbook, "lastIDPressed") = control.ID
    If Not isMacroRunning Then AsyncMacro control.ID
End Sub

Private Sub AsyncMacro(ByVal ctrlID As String)
    Application.OnTime Now(), CallbackName("AsyncMacroCallback", ctrlID)
End Sub

Private Function CallbackName(ByVal funcName As String, ByVal ctrlID As String) As String
    CallbackName = "'" & Replace(ThisWorkbook.Name, "'", "''") _
                 & "'!'" & funcName & " """ & ctrlID & """'"
End Function

Public Sub AsyncMacroCallback(Optional ByVal ctrlID As String)
    isMacroRunning = True
    Select Case ctrlID
        Case "customButton1": FirstMacro ctrlID
        Case "customButton2": SecondMacro ctrlID
        Case "customButton3": ThirdMacro ctrlID
        Case Else
            '...
    End Select
    SafeRibbon.InvalidateControl ctrlID
    isMacroRunning = False
End Sub

Where Private isMacroRunning As Boolean is declared at module level.


The final code can look like this:

Option Explicit

Private myRibbon As IRibbonUI
Private isMacroRunning As Boolean

#If Mac Then
    #If VBA7 Then
        Private Declare PtrSafe Function CopyMemory Lib "/usr/lib/libc.dylib" Alias "memmove" (Destination As Any, Source As Any, ByVal Length As LongPtr) As LongPtr
    #Else
        Private Declare Function CopyMemory Lib "/usr/lib/libc.dylib" Alias "memmove" (Destination As Any, Source As Any, ByVal Length As Long) As Long
    #End If
#Else 'Windows
    'https://msdn.microsoft.com/en-us/library/mt723419(v=vs.85).aspx
    #If VBA7 Then
        Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source As Any, ByVal Length As LongPtr)
    #Else
        Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source As Any, ByVal Length As Long)
    #End If
#End If

Sub OnRibbonLoad(ByVal ribbon As IRibbonUI)
    Set SafeRibbon = ribbon
End Sub

'===============================================================================
'Set/Get Ribbon object
'===============================================================================
Private Property Set SafeRibbon(ByVal ribbonUI As IRibbonUI)
    Set myRibbon = ribbonUI
    Dim mustAvoidSaveDialog As Boolean
    mustAvoidSaveDialog = ThisWorkbook.IsAddin And ThisWorkbook.Saved
    BookItem(ThisWorkbook, "BookPtr") = CStr(ObjPtr(ThisWorkbook))
    BookItem(ThisWorkbook, "RibbonPtr") = CStr(ObjPtr(myRibbon))
    If mustAvoidSaveDialog Then ThisWorkbook.Saved = True
End Property
Public Property Get SafeRibbon() As IRibbonUI
    If myRibbon Is Nothing Then
        If BookItem(ThisWorkbook, "BookPtr") = CStr(ObjPtr(ThisWorkbook)) Then
            'Restore ribbon
            #If Win64 Then
                Dim ptr As LongLong
                Const ptrSize As Long = 8
            #Else
                Dim ptr As Long
                Const ptrSize As Long = 4
            #End If
            Dim obj As Object
            '
            ptr = Int(BookItem(ThisWorkbook, "RibbonPtr"))
            CopyMemory obj, ptr, ptrSize 'Unmanaged - reference not counted
            Set myRibbon = obj
            CopyMemory obj, 0, ptrSize 'Reference count not decremented
        End If
    End If
    Set SafeRibbon = myRibbon
End Property

Sub GetPressed(control As IRibbonControl, ByRef returnedVal)
    Dim lastIDPressed As String
    '
    lastIDPressed = BookItem(ThisWorkbook, "lastIDPressed")
    returnedVal = (lastIDPressed = control.ID)
    If Len(lastIDPressed) > 0 Then AsyncMacro lastIDPressed
End Sub

Sub AnyButtonGotPressed(control As IRibbonControl, IsPressed)
    If Not IsPressed Then
        BookItem(ThisWorkbook, "lastIDPressed") = vbNullString
        Exit Sub
    End If
    '
    Dim lastIDPressed As String
    '
    lastIDPressed = BookItem(ThisWorkbook, "lastIDPressed")
    If Len(lastIDPressed) > 0 Then
        SafeRibbon.InvalidateControl lastIDPressed
    End If
    BookItem(ThisWorkbook, "lastIDPressed") = control.ID
    If Not isMacroRunning Then AsyncMacro control.ID
End Sub

Private Sub AsyncMacro(ByVal ctrlID As String)
    Application.OnTime Now(), CallbackName("AsyncMacroCallback", ctrlID)
End Sub

Private Function CallbackName(ByVal funcName As String, ByVal ctrlID As String) As String
    CallbackName = "'" & Replace(ThisWorkbook.Name, "'", "''") _
                 & "'!'" & funcName & " """ & ctrlID & """'"
End Function

Public Sub AsyncMacroCallback(Optional ByVal ctrlID As String)
    isMacroRunning = True
    Select Case ctrlID
        Case "customButton1": FirstMacro ctrlID
        Case "customButton2": SecondMacro ctrlID
        Case "customButton3": ThirdMacro ctrlID
        Case Else
            '...
    End Select
    SafeRibbon.InvalidateControl ctrlID
    isMacroRunning = False
End Sub

Sub FirstMacro(ButtonName As String)
    Debug.Print "Enter macro 1 " & Now
    On Error GoTo CleanExit
    Do While BookItem(ThisWorkbook, "lastIDPressed") = ButtonName
        With ThisWorkbook.Sheets(1)
            .Range("A1").Select
            If ActiveCell.Address <> .Range("A1") Then
                'Here comes stuff to do with the cell, if the user clicks somewhere out of "A1"
            End If
            DoEvents
        End With
    Loop
CleanExit:
    Debug.Print "Exit macro 1 " & Now
End Sub
Sub SecondMacro(ButtonName As String)
    Debug.Print "Enter macro 2 " & Now
    On Error GoTo CleanExit
    Do While BookItem(ThisWorkbook, "lastIDPressed") = ButtonName
        With ThisWorkbook.Sheets(1)
            .Range("B2").Select
            If ActiveCell.Address <> .Range("B2") Then
                'Here comes stuff to do with the cell, if the user clicks somewhere out of "B2"
            End If
            DoEvents
        End With
    Loop
CleanExit:
    Debug.Print "Exit macro 2 " & Now
End Sub
Sub ThirdMacro(ButtonName As String)
    Debug.Print "Enter macro 3 " & Now
    On Error GoTo CleanExit
    Do While BookItem(ThisWorkbook, "lastIDPressed") = ButtonName
        With ThisWorkbook.Sheets(1)
            .Range("C3").Select
            If ActiveCell.Address <> .Range("C3") Then
                'Here comes stuff to do with the cell, if the user clicks somewhere out of "C3"
            End If
            DoEvents
        End With
    Loop
CleanExit:
    Debug.Print "Enter macro 3 " & Now
End Sub

Notice that none of the actual macros (First, Second, Third) need to do any more calls to InvalidateControl and we also don't need to loop through all controls in a loop to InvalidateControl in the AnyButtonGotPressed method.

Sign up to request clarification or add additional context in comments.

2 Comments

Man, that's a ton of information and code! Thank you for putting that much effort into answering. I am afraid I won't manage to give a feedback today, since it's already late here, but I will test and come back to you for sure! For now: Thank you very much (event though, or because, it is not as easy as I thought it could be).
You're welcome. Just did a small edit.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.