Repository: Qiskit/qiskit

Issue:
Add drawer support for `Operations` that are not subclasses of `Instruction`
### What should we add?

We need to support drawing circuits with any operation that is not an `Instruction`, and in particular with `Clifford`s and `AnnotatedOperation`s. We also need to add tests.


Discussion:
@alexanderivrii This circuit
```
from qiskit import QuantumCircuit
from qiskit.quantum_info import Clifford, random_clifford
from qiskit.circuit.annotated_operation import AnnotatedOperation, InverseModifier, ControlModifier
from qiskit.circuit.library import SGate

qc = QuantumCircuit(3)
cliff = random_clifford(2)
qc.append(cliff, [0, 1])
qc.ccx(0, 1, 2)
op1 = AnnotatedOperation(SGate(), [InverseModifier(), ControlModifier(2)])
qc.append(op1, [0, 1, 2])
qc.draw('mpl')
```
produces
![image](https://github.com/Qiskit/qiskit/assets/16268251/4f9bf56c-05a0-4ce7-b0da-32440b11fe1a)
What additional info do you want to be displayed? Also I just noticed there's a bug in the 'text' drawer that crashes on this circuit. :-( 


Resolving patch:
diff --git a/qiskit/visualization/circuit/_utils.py b/qiskit/visualization/circuit/_utils.py
--- a/qiskit/visualization/circuit/_utils.py
+++ b/qiskit/visualization/circuit/_utils.py
@@ -28,6 +28,7 @@
 from qiskit.circuit.controlflow import condition_resources
 from qiskit.circuit.library import PauliEvolutionGate
 from qiskit.circuit import ClassicalRegister, QuantumCircuit, Qubit, ControlFlowOp
+from qiskit.circuit.annotated_operation import AnnotatedOperation, InverseModifier, PowerModifier
 from qiskit.circuit.tools import pi_check
 from qiskit.converters import circuit_to_dag
 from qiskit.utils import optionals as _optionals
@@ -46,6 +47,16 @@ def _is_boolean_expression(gate_text, op):
 
 def get_gate_ctrl_text(op, drawer, style=None, calibrations=None):
     """Load the gate_text and ctrl_text strings based on names and labels"""
+    anno_list = []
+    anno_text = ""
+    if isinstance(op, AnnotatedOperation) and op.modifiers:
+        for modifier in op.modifiers:
+            if isinstance(modifier, InverseModifier):
+                anno_list.append("Inv")
+            elif isinstance(modifier, PowerModifier):
+                anno_list.append("Pow(" + str(round(modifier.power, 1)) + ")")
+        anno_text = ", ".join(anno_list)
+
     op_label = getattr(op, "label", None)
     op_type = type(op)
     base_name = base_label = base_type = None
@@ -53,6 +64,8 @@ def get_gate_ctrl_text(op, drawer, style=None, calibrations=None):
         base_name = op.base_gate.name
         base_label = op.base_gate.label
         base_type = type(op.base_gate)
+    if hasattr(op, "base_op"):
+        base_name = op.base_op.name
     ctrl_text = None
 
     if base_label:
@@ -114,6 +127,9 @@ def get_gate_ctrl_text(op, drawer, style=None, calibrations=None):
         else:
             gate_text = gate_text + "\n(cal)"
 
+    if anno_text:
+        gate_text += " - " + anno_text
+
     return gate_text, ctrl_text, raw_gate_text
 
 
diff --git a/qiskit/visualization/circuit/matplotlib.py b/qiskit/visualization/circuit/matplotlib.py
--- a/qiskit/visualization/circuit/matplotlib.py
+++ b/qiskit/visualization/circuit/matplotlib.py
@@ -36,6 +36,7 @@
 )
 from qiskit.circuit.controlflow import condition_resources
 from qiskit.circuit.classical import expr
+from qiskit.circuit.annotated_operation import _canonicalize_modifiers, ControlModifier
 from qiskit.circuit.library.standard_gates import (
     SwapGate,
     RZZGate,
@@ -1058,6 +1059,15 @@ def _draw_ops(
                     ]
                     self._condition(node, node_data, wire_map, outer_circuit, cond_xy, glob_data)
 
+                # AnnotatedOperation with ControlModifier
+                mod_control = None
+                if getattr(op, "modifiers", None):
+                    canonical_modifiers = _canonicalize_modifiers(op.modifiers)
+                    for modifier in canonical_modifiers:
+                        if isinstance(modifier, ControlModifier):
+                            mod_control = modifier
+                            break
+
                 # draw measure
                 if isinstance(op, Measure):
                     self._measure(node, node_data, outer_circuit, glob_data)
@@ -1076,8 +1086,8 @@ def _draw_ops(
                     self._gate(node, node_data, glob_data)
 
                 # draw controlled gates
-                elif isinstance(op, ControlledGate):
-                    self._control_gate(node, node_data, glob_data)
+                elif isinstance(op, ControlledGate) or mod_control:
+                    self._control_gate(node, node_data, glob_data, mod_control)
 
                 # draw multi-qubit gate as final default
                 else:
@@ -1660,17 +1670,18 @@ def _flow_op_gate(self, node, node_data, glob_data):
 
             fold_level += 1
 
-    def _control_gate(self, node, node_data, glob_data):
+    def _control_gate(self, node, node_data, glob_data, mod_control):
         """Draw a controlled gate"""
         op = node.op
         xy = node_data[node].q_xy
         base_type = getattr(op, "base_gate", None)
         qubit_b = min(xy, key=lambda xy: xy[1])
         qubit_t = max(xy, key=lambda xy: xy[1])
-        num_ctrl_qubits = op.num_ctrl_qubits
+        num_ctrl_qubits = mod_control.num_ctrl_qubits if mod_control else op.num_ctrl_qubits
         num_qargs = len(xy) - num_ctrl_qubits
+        ctrl_state = mod_control.ctrl_state if mod_control else op.ctrl_state
         self._set_ctrl_bits(
-            op.ctrl_state,
+            ctrl_state,
             num_ctrl_qubits,
             xy,
             glob_data,
diff --git a/qiskit/visualization/circuit/text.py b/qiskit/visualization/circuit/text.py
--- a/qiskit/visualization/circuit/text.py
+++ b/qiskit/visualization/circuit/text.py
@@ -26,6 +26,7 @@
 from qiskit.circuit.classical import expr
 from qiskit.circuit.controlflow import node_resources
 from qiskit.circuit.library.standard_gates import IGate, RZZGate, SwapGate, SXGate, SXdgGate
+from qiskit.circuit.annotated_operation import _canonicalize_modifiers, ControlModifier
 from qiskit.circuit.tools.pi_check import pi_check
 from qiskit.qasm3.exporter import QASM3Builder
 from qiskit.qasm3.printer import BasicPrinter
@@ -973,7 +974,7 @@ def draw_wires(self, wires):
     def special_label(node):
         """Some instructions have special labels"""
         labels = {IGate: "I", SXGate: "√X", SXdgGate: "√Xdg"}
-        node_type = node.base_class
+        node_type = getattr(node, "base_class", None)
         return labels.get(node_type, None)
 
     @staticmethod
@@ -1047,7 +1048,7 @@ def normalize_width(layer):
             node.layer_width = longest
 
     @staticmethod
-    def controlled_wires(node, wire_map):
+    def controlled_wires(node, wire_map, ctrl_text, conditional, mod_control):
         """
         Analyzes the node in the layer and checks if the controlled arguments are in
         the box or out of the box.
@@ -1055,6 +1056,10 @@ def controlled_wires(node, wire_map):
         Args:
             node (DAGNode): node to analyse
             wire_map (dict): map of qubits/clbits to position
+            ctrl_text (str): text for a control label
+            conditional (bool): is this a node with a condition
+            mod_control (ControlModifier): an instance of a modifier for an
+                AnnotatedOperation
 
         Returns:
             Tuple(list, list, list):
@@ -1064,10 +1069,11 @@ def controlled_wires(node, wire_map):
               - the rest of the arguments
         """
         op = node.op
-        num_ctrl_qubits = op.num_ctrl_qubits
+        num_ctrl_qubits = mod_control.num_ctrl_qubits if mod_control else op.num_ctrl_qubits
         ctrl_qubits = node.qargs[:num_ctrl_qubits]
         args_qubits = node.qargs[num_ctrl_qubits:]
-        ctrl_state = f"{op.ctrl_state:b}".rjust(num_ctrl_qubits, "0")[::-1]
+        ctrl_state = mod_control.ctrl_state if mod_control else op.ctrl_state
+        ctrl_state = f"{ctrl_state:b}".rjust(num_ctrl_qubits, "0")[::-1]
 
         in_box = []
         top_box = []
@@ -1082,26 +1088,20 @@ def controlled_wires(node, wire_map):
                 bot_box.append(ctrl_qubit)
             else:
                 in_box.append(ctrl_qubit)
-        return (top_box, bot_box, in_box, args_qubits)
 
-    def _set_ctrl_state(self, node, conditional, ctrl_text, bottom):
-        """Takes the ctrl_state from node.op and appends Bullet or OpenBullet
-        to gates depending on whether the bit in ctrl_state is 1 or 0. Returns gates"""
-        op = node.op
         gates = []
-        num_ctrl_qubits = op.num_ctrl_qubits
-        ctrl_qubits = node.qargs[:num_ctrl_qubits]
-        cstate = f"{op.ctrl_state:b}".rjust(num_ctrl_qubits, "0")[::-1]
         for i in range(len(ctrl_qubits)):
             # For sidetext gate alignment, need to set every Bullet with
             # conditional on if there's a condition.
             if getattr(op, "condition", None) is not None:
                 conditional = True
-            if cstate[i] == "1":
-                gates.append(Bullet(conditional=conditional, label=ctrl_text, bottom=bottom))
+            if ctrl_state[i] == "1":
+                gates.append(Bullet(conditional=conditional, label=ctrl_text, bottom=bool(bot_box)))
             else:
-                gates.append(OpenBullet(conditional=conditional, label=ctrl_text, bottom=bottom))
-        return gates
+                gates.append(
+                    OpenBullet(conditional=conditional, label=ctrl_text, bottom=bool(bot_box))
+                )
+        return (gates, top_box, bot_box, in_box, args_qubits)
 
     def _node_to_gate(self, node, layer, gate_wire_map):
         """Convert a dag op node into its corresponding Gate object, and establish
@@ -1133,6 +1133,15 @@ def add_connected_gate(node, gates, layer, current_cons, gate_wire_map):
                     layer.set_qubit(node.qargs[i], gate)
                     current_cons.append((actual_index, gate))
 
+        # AnnotatedOperation with ControlModifier
+        mod_control = None
+        if getattr(op, "modifiers", None):
+            canonical_modifiers = _canonicalize_modifiers(op.modifiers)
+            for modifier in canonical_modifiers:
+                if isinstance(modifier, ControlModifier):
+                    mod_control = modifier
+                    break
+
         if isinstance(op, Measure):
             gate = MeasureFrom()
             layer.set_qubit(node.qargs[0], gate)
@@ -1174,11 +1183,14 @@ def add_connected_gate(node, gates, layer, current_cons, gate_wire_map):
             # unitary gate
             layer.set_qubit(node.qargs[0], BoxOnQuWire(gate_text, conditional=conditional))
 
-        elif isinstance(op, ControlledGate):
-            params_array = TextDrawing.controlled_wires(node, gate_wire_map)
-            controlled_top, controlled_bot, controlled_edge, rest = params_array
-            gates = self._set_ctrl_state(node, conditional, ctrl_text, bool(controlled_bot))
-            if base_gate.name == "z":
+        elif isinstance(op, ControlledGate) or mod_control:
+            controls_array = TextDrawing.controlled_wires(
+                node, gate_wire_map, ctrl_text, conditional, mod_control
+            )
+            gates, controlled_top, controlled_bot, controlled_edge, rest = controls_array
+            if mod_control:
+                gates.append(BoxOnQuWire(gate_text, conditional=conditional))
+            elif base_gate.name == "z":
                 # cz
                 gates.append(Bullet(conditional=conditional))
             elif base_gate.name in ["u1", "p"]:
@@ -1209,6 +1221,7 @@ def add_connected_gate(node, gates, layer, current_cons, gate_wire_map):
                     current_cons.append((index, DrawElement("")))
             else:
                 gates.append(BoxOnQuWire(gate_text, conditional=conditional))
+
             add_connected_gate(node, gates, layer, current_cons, gate_wire_map)
 
         elif len(node.qargs) >= 2 and not node.cargs:


Test patch:
diff --git a/test/python/visualization/test_circuit_text_drawer.py b/test/python/visualization/test_circuit_text_drawer.py
--- a/test/python/visualization/test_circuit_text_drawer.py
+++ b/test/python/visualization/test_circuit_text_drawer.py
@@ -23,6 +23,13 @@
 
 from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister, transpile
 from qiskit.circuit import Gate, Parameter, Qubit, Clbit, Instruction, IfElseOp
+from qiskit.circuit.annotated_operation import (
+    AnnotatedOperation,
+    InverseModifier,
+    ControlModifier,
+    PowerModifier,
+)
+from qiskit.quantum_info import random_clifford
 from qiskit.quantum_info.operators import SuperOp
 from qiskit.quantum_info.random import random_unitary
 from qiskit.test import QiskitTestCase
@@ -40,6 +47,8 @@
     CZGate,
     ZGate,
     YGate,
+    SGate,
+    SXGate,
     U1Gate,
     SwapGate,
     RZZGate,
@@ -6305,5 +6314,39 @@ def test_switch_with_expression(self):
         )
 
 
+class TestCircuitAnnotatedOperations(QiskitVisualizationTestCase):
+    """Test AnnotatedOperations and other non-Instructions."""
+
+    def test_annotated_operation(self):
+        """Test AnnotatedOperation and other non-Instructions."""
+        expected = "\n".join(
+            [
+                "     ┌───────────┐┌───┐                                     ",
+                "q_0: ┤0          ├┤ X ├──■────■────────────■────────────────",
+                "     │  Clifford │├───┤┌─┴─┐  │            │          ┌────┐",
+                "q_1: ┤1          ├┤ H ├┤ S ├──■────────────o──────────┤ √X ├",
+                "     └───────────┘└───┘└─┬─┘┌─┴─┐┌─────────┴─────────┐└────┘",
+                "q_2: ────────────────────o──┤ X ├┤ S - Inv, Pow(3.3) ├──────",
+                "                            └───┘└───────────────────┘      ",
+            ]
+        )
+        circuit = QuantumCircuit(3)
+        cliff = random_clifford(2)
+        circuit.append(cliff, [0, 1])
+        circuit.x(0)
+        circuit.h(1)
+        circuit.append(SGate().control(2, ctrl_state=1), [0, 2, 1])
+        circuit.ccx(0, 1, 2)
+        op1 = AnnotatedOperation(
+            SGate(), [InverseModifier(), ControlModifier(2, 1), PowerModifier(3.29)]
+        )
+        circuit.append(op1, [0, 1, 2])
+        circuit.append(SXGate(), [1])
+        self.assertEqual(
+            str(circuit_drawer(circuit, output="text", initial_state=False)),
+            expected,
+        )
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/test/visual/mpl/circuit/references/annotated.png b/test/visual/mpl/circuit/references/annotated.png
new file mode 100644
Binary files /dev/null and b/test/visual/mpl/circuit/references/annotated.png differ
diff --git a/test/visual/mpl/circuit/test_circuit_matplotlib_drawer.py b/test/visual/mpl/circuit/test_circuit_matplotlib_drawer.py
--- a/test/visual/mpl/circuit/test_circuit_matplotlib_drawer.py
+++ b/test/visual/mpl/circuit/test_circuit_matplotlib_drawer.py
@@ -34,15 +34,23 @@
     DCXGate,
     ZGate,
     SGate,
+    SXGate,
     U1Gate,
     CPhaseGate,
     HamiltonianGate,
     Isometry,
 )
 from qiskit.circuit.library import MCXVChain
+from qiskit.circuit.annotated_operation import (
+    AnnotatedOperation,
+    InverseModifier,
+    ControlModifier,
+    PowerModifier,
+)
 from qiskit.circuit import Parameter, Qubit, Clbit, IfElseOp, SwitchCaseOp
 from qiskit.circuit.library import IQP
 from qiskit.circuit.classical import expr
+from qiskit.quantum_info import random_clifford
 from qiskit.quantum_info.random import random_unitary
 from qiskit.utils import optionals
 
@@ -2209,6 +2217,32 @@ def test_iqx_pendingdeprecation(self):
             ):
                 qc.draw("mpl", style=style)
 
+    def test_annotated_operation(self):
+        """Test AnnotatedOperations and other non-Instructions."""
+        circuit = QuantumCircuit(3)
+        cliff = random_clifford(2)
+        circuit.append(cliff, [0, 1])
+        circuit.x(0)
+        circuit.h(1)
+        circuit.append(SGate().control(2, ctrl_state=1), [0, 2, 1])
+        circuit.ccx(0, 1, 2)
+        op1 = AnnotatedOperation(
+            SGate(), [InverseModifier(), ControlModifier(2, 1), PowerModifier(3.29)]
+        )
+        circuit.append(op1, [0, 1, 2])
+        circuit.append(SXGate(), [1])
+        fname = "annotated.png"
+        self.circuit_drawer(circuit, output="mpl", filename=fname)
+
+        ratio = VisualTestUtilities._save_diff(
+            self._image_path(fname),
+            self._reference_path(fname),
+            fname,
+            FAILURE_DIFF_DIR,
+            FAILURE_PREFIX,
+        )
+        self.assertGreaterEqual(ratio, 0.9999)
+
     def test_no_qreg_names_after_layout(self):
         """Test that full register names are not shown after transpilation.
         See https://github.com/Qiskit/qiskit-terra/issues/11038"""