Repository: PennyLaneAI/pennylane

Issue:
Improve drawing of controlled `GlobalPhase`
### Feature details

It would be nice if controlled `GlobalPhase` operators are drawn appropriately.
It is not entirely clear how it should be displayed, but the current appearance is not nice:

```python
def f():
    qml.ctrl(qml.GlobalPhase(0.5), (1,2))

print(qml.draw(f, wire_order=[0,1,2,3], show_all_wires=True)())
```
```pycon
0: ──GlobalPhase(0.50)──┤  
1: ─╭●GlobalPhase(0.50)─┤  
2: ─╰●GlobalPhase(0.50)─┤  
3: ──GlobalPhase(0.50)──┤
```


### Implementation

One option would be to draw the control nodes and only draw the `GlobalPhase` on the non-control wires:

```
0: ─╭GlobalPhase(0.50)──┤  
1: ─├●──────────────────┤  
2: ─├●──────────────────┤  
3: ─╰GlobalPhase(0.50)──┤
```

Another option is to concede that there is no canonical appearance and draw a block:

```
0: ─╭U(M0)─┤  
1: ─├U(M0)─┤  
2: ─├U(M0)─┤  
3: ─╰U(M0)─┤
```
which one could potentially relabel as
```
0: ─╭ctrlGlobalPhase(M0)─┤  
1: ─├ctrlGlobalPhase(M0)─┤  
2: ─├ctrlGlobalPhase(M0)─┤  
3: ─╰ctrlGlobalPhase(M0)─┤
```

Probably there are more options. Between the three above, I heavily would prefer the first.

### How important would you say this feature is?

1: Not important. Would be nice to have.

### Additional information

_No response_

Discussion:
@dwierichs: With `draw_mpl`, drawing controlled `GlobalPhase` actually raises an error:

```python
import pennylane as qml

def f():
    qml.ctrl(qml.GlobalPhase(0.5), (1,2))

qml.draw_mpl(f, wire_order=[0,1,2,3], show_all_wires=True)()

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[3], line 9
      4     qml.ctrl(qml.GlobalPhase(0.5), (1,2))
      5     # qml.GlobalPhase(0.5, wires=[0, 3])
      6     # qml.GlobalPhase(0.5)
      7 
      8 # print(qml.draw(f, wire_order=[0,1,2,3], show_all_wires=True)())
----> 9 qml.draw_mpl(f, wire_order=[0,1,2,3], show_all_wires=True)()

File [~/repos/pennylane/pennylane/drawer/draw.py:722](http://localhost:8888/lab/workspaces/auto-H/tree/devdevs/repos/pennylane/pennylane/drawer/draw.py#line=721), in draw_mpl.<locals>.wrapper(*args, **kwargs)
    719     except TypeError:
    720         _wire_order = tape.wires
--> 722 return tape_mpl(
    723     tape,
    724     wire_order=_wire_order,
    725     show_all_wires=show_all_wires,
    726     decimals=decimals,
    727     max_length=max_length,
    728     style=style,
    729     fig=fig,
    730     level=level,
    731     **kwargs,
    732 )

File [~/repos/pennylane/pennylane/drawer/tape_mpl.py:543](http://localhost:8888/lab/workspaces/auto-H/tree/devdevs/repos/pennylane/pennylane/drawer/tape_mpl.py#line=542), in tape_mpl(tape, wire_order, show_all_wires, decimals, style, fig, max_length, **kwargs)
    540     _set_style(style)
    542 try:
--> 543     return _tape_mpl(
    544         tape,
    545         wire_order=wire_order,
    546         show_all_wires=show_all_wires,
    547         decimals=decimals,
    548         fig=fig,
    549         max_length=max_length,
    550         **kwargs,
    551     )
    552 finally:
    553     if update_style:
    554         # we don't want to mess with how it modifies whether the interface is interactive
    555         # but we want to restore everything else

File [~/repos/pennylane/pennylane/drawer/tape_mpl.py:312](http://localhost:8888/lab/workspaces/auto-H/tree/devdevs/repos/pennylane/pennylane/drawer/tape_mpl.py#line=311), in _tape_mpl(tape, wire_order, show_all_wires, max_length, **kwargs)
    303 config = _Config(
    304     decimals=kwargs.get("decimals", None),
    305     active_wire_notches=kwargs.get("active_wire_notches", True),
   (...)
    308     cwire_wires=cwire_wires,
    309 )
    311 if max_length is None:
--> 312     return _draw_layers(layers, tape.measurements, config=config, wire_map=wire_map, **kwargs)
    314 layer_count = len(layers)
    315 return [
    316     _draw_layers(
    317         layers=layers[i : i + max_length],
   (...)
    324     for i in range(0, layer_count, max_length)
    325 ]

File [~/repos/pennylane/pennylane/drawer/tape_mpl.py:258](http://localhost:8888/lab/workspaces/auto-H/tree/devdevs/repos/pennylane/pennylane/drawer/tape_mpl.py#line=257), in _draw_layers(layers, measurements, wire_map, config, starting_dots, **kwargs)
    256 for layer, layer_ops in enumerate(layers):
    257     for op in layer_ops:
--> 258         _add_operation_to_drawer(op, drawer, layer, config)
    260 if measurements == "dots":
    261     for wire in wire_map.values():

File [/usr/lib/python3.10/functools.py:889](http://localhost:8888/usr/lib/python3.10/functools.py#line=888), in singledispatch.<locals>.wrapper(*args, **kw)
    885 if not args:
    886     raise TypeError(f'{funcname} requires at least '
    887                     '1 positional argument')
--> 889 return dispatch(args[0].__class__)(*args, **kw)

File [~/repos/pennylane/pennylane/drawer/tape_mpl.py:92](http://localhost:8888/lab/workspaces/auto-H/tree/devdevs/repos/pennylane/pennylane/drawer/tape_mpl.py#line=91), in _add_operation_to_drawer(op, drawer, layer, config)
     85 if op_control_wires:
     86     drawer.ctrl(
     87         layer,
     88         op_control_wires,
     89         wires_target=target_wires,
     90         control_values=control_values,
     91     )
---> 92 drawer.box_gate(
     93     layer,
     94     target_wires,
     95     op.label(decimals=config.decimals),
     96     box_options={"zorder": 4},  # make sure box and text above control wires if controlled
     97     text_options={"zorder": 5},
     98     active_wire_notches=config.active_wire_notches,
     99 )

File [~/repos/pennylane/pennylane/drawer/mpldrawer.py:515](http://localhost:8888/lab/workspaces/auto-H/tree/devdevs/repos/pennylane/pennylane/drawer/mpldrawer.py#line=514), in MPLDrawer.box_gate(self, layer, wires, text, box_options, text_options, **kwargs)
    511     new_text_options.update(text_options)
    513 wires = _to_tuple(wires)
--> 515 box_min = min(wires)
    516 box_max = max(wires)
    517 box_center = (box_max + box_min) [/](http://localhost:8888/) 2.0

ValueError: min() arg is an empty sequence
```

Solution patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index bcfa3d367..0ccbbe2e6 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -296,6 +296,12 @@
 
 <h3>Improvements 🛠</h3>
 
+* Improved the drawing of `GlobalPhase`, `ctrl(GlobalPhase)`, `Identity` and `ctrl(Identity)` operations.
+  The labels are grouped together like for other multi-qubit operations, and the drawing
+  no longer depends on the wires of `GlobalPhase` or `Identity`. Control nodes of controlled global phases
+  and identities no longer receive the operator label, which is in line with other controlled operations.
+  [(#7457)](https://github.com/PennyLaneAI/pennylane/pull/7457)
+  
 * :class:`~.QubitUnitary` now supports a decomposition that is compatible with an arbitrary number of qubits. 
   This represents a fundamental improvement over the previous implementation, which was limited to two-qubit systems.
   [(#7277)](https://github.com/PennyLaneAI/pennylane/pull/7277)
@@ -311,10 +317,14 @@
   that contains fewer gates than the previous decomposition.
   [(#7370)](https://github.com/PennyLaneAI/pennylane/pull/7370)
 
-* A transform for applying `cancel_inverses` has been added that can be used with the experimental
-  xDSL Python compiler integration. This transform is optimized to cancel self-inverse operations
-  iteratively to cancel nested self-inverse operations.
-  [(#7363)](https://github.com/PennyLaneAI/pennylane/pull/7363)
+* An xDSL `qml.compiler.python_compiler.transforms.MergeRotationsPass` pass for applying `merge_rotations` to an
+  xDSL module has been added for the experimental xDSL Python compiler integration.
+  [(#7364)](https://github.com/PennyLaneAI/pennylane/pull/7364)
+
+* An xDSL `qml.compiler.python_compiler.transforms.IterativeCancelInversesPass` pass for applying `cancel_inverses`
+  iteratively to an xDSL module has been added for the experimental xDSL Python compiler integration. This pass is
+  optimized to cancel self-inverse operations iteratively to cancel nested self-inverse operations.
+  [(#7364)](https://github.com/PennyLaneAI/pennylane/pull/7364)
  
 * An experimental integration for a Python compiler using [xDSL](https://xdsl.dev/index) has been introduced.
   This is similar to [Catalyst's MLIR dialects](https://docs.pennylane.ai/projects/catalyst/en/stable/dev/dialects.html#mlir-dialects-in-catalyst), 
diff --git a/pennylane/compiler/python_compiler/transforms/__init__.py b/pennylane/compiler/python_compiler/transforms/__init__.py
index 1cb883ce9..4288545ac 100644
--- a/pennylane/compiler/python_compiler/transforms/__init__.py
+++ b/pennylane/compiler/python_compiler/transforms/__init__.py
@@ -16,11 +16,13 @@
 from xdsl.transforms.transform_interpreter import TransformInterpreterPass
 from .apply_transform_sequence import ApplyTransformSequence, register_pass
 from .cancel_inverses import IterativeCancelInversesPass
+from .merge_rotations import MergeRotationsPass
 
 
 __all__ = [
     "ApplyTransformSequence",
     "IterativeCancelInversesPass",
+    "MergeRotationsPass",
     "TransformInterpreterPass",
     "register_pass",
 ]
diff --git a/pennylane/compiler/python_compiler/transforms/cancel_inverses.py b/pennylane/compiler/python_compiler/transforms/cancel_inverses.py
index 4fcb72bde..db9ee2ae5 100644
--- a/pennylane/compiler/python_compiler/transforms/cancel_inverses.py
+++ b/pennylane/compiler/python_compiler/transforms/cancel_inverses.py
@@ -90,7 +90,7 @@ class IterativeCancelInversesPass(passes.ModulePass):
     name = "iterative-cancel-inverses"
 
     # pylint: disable=arguments-renamed,no-self-use
-    def apply(self, _ctx: context.MLContext, module: builtin.ModuleOp) -> None:
+    def apply(self, _ctx: context.Context, module: builtin.ModuleOp) -> None:
         """Apply the iterative cancel inverses pass."""
         pattern_rewriter.PatternRewriteWalker(
             pattern_rewriter.GreedyRewritePatternApplier([IterativeCancelInversesPattern()])
diff --git a/pennylane/compiler/python_compiler/transforms/merge_rotations.py b/pennylane/compiler/python_compiler/transforms/merge_rotations.py
new file mode 100644
index 000000000..9b024787d
--- /dev/null
+++ b/pennylane/compiler/python_compiler/transforms/merge_rotations.py
@@ -0,0 +1,120 @@
+# Copyright 2018-2025 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""This file contains the implementation of the merge_rotations transform,
+written using xDSL."""
+
+from dataclasses import dataclass
+
+from xdsl import context, passes, pattern_rewriter
+from xdsl.dialects import arith, builtin, func
+from xdsl.ir import Operation
+from xdsl.rewriter import InsertPoint
+
+from ..quantum_dialect import CustomOp
+
+# Can handle all composible rotations except Rot... for now
+composable_rotations = [
+    "RX",
+    "RY",
+    "RZ",
+    "PhaseShift",
+    "CRX",
+    "CRY",
+    "CRZ",
+    "ControlledPhaseShift",
+    "IsingXX",
+    "IsingYY",
+    "IsingZZ",
+]
+
+
+def _can_merge(op: CustomOp, next_op: Operation) -> bool:
+    if isinstance(next_op, CustomOp):
+        if op.gate_name.data == next_op.gate_name.data:
+            if op.out_qubits == next_op.in_qubits and op.out_ctrl_qubits == next_op.in_ctrl_qubits:
+                for v1, v2 in zip(op.in_ctrl_values, next_op.in_ctrl_values, strict=True):
+                    if v1.data != v2.data:
+                        return False
+                return True
+
+    return False
+
+
+class MergeRotationsPattern(
+    pattern_rewriter.RewritePattern
+):  # pylint: disable=too-few-public-methods
+    """RewritePattern for merging consecutive composable rotations."""
+
+    # pylint: disable=no-self-use
+    @pattern_rewriter.op_type_rewrite_pattern
+    def match_and_rewrite(
+        self, funcOp: func.FuncOp, rewriter: pattern_rewriter.PatternRewriter
+    ):  # pylint: disable=arguments-differ
+        """Implementation of rewriting FuncOps that may contain operations corresponding to
+        consecutive composable rotations."""
+        for op in funcOp.body.walk():
+            if not isinstance(op, CustomOp):
+                continue
+
+            gate_name = op.gate_name.data
+            if gate_name not in composable_rotations:
+                continue
+
+            param = op.operands[0]
+            while True:
+                next_user = None
+                for use in op.results[0].uses:
+                    user = use.operation
+                    if _can_merge(op, user):
+                        next_user = user
+                        break
+
+                if next_user is None:
+                    break
+
+                for q1, q2 in zip(op.in_qubits, op.out_qubits, strict=True):
+                    rewriter.replace_all_uses_with(q2, q1)
+                for cq1, cq2 in zip(op.in_ctrl_qubits, op.out_ctrl_qubits, strict=True):
+                    rewriter.replace_all_uses_with(cq2, cq1)
+
+                rewriter.erase_op(op)
+                next_param = next_user.operands[0]
+                addOp = arith.AddfOp(param, next_param)
+                rewriter.insert_op(addOp, InsertPoint.before(next_user))
+                param = addOp.result
+                new_op = CustomOp(
+                    in_qubits=next_user.in_qubits,
+                    gate_name=next_user.gate_name,
+                    params=(param,),
+                    in_ctrl_qubits=next_user.in_ctrl_qubits,
+                    in_ctrl_values=next_user.in_ctrl_values,
+                    adjoint=getattr(next_user, "adjoint", False),
+                )
+                rewriter.replace_op(next_user, new_op)
+                op = new_op
+
+
+@dataclass(frozen=True)
+class MergeRotationsPass(passes.ModulePass):
+    """Pass for merging consecutive composable rotation gates."""
+
+    name = "merge-rotations"
+
+    # pylint: disable=arguments-renamed,no-self-use
+    def apply(self, _ctx: context.Context, module: builtin.ModuleOp) -> None:
+        """Apply the merge rotations pass."""
+        pattern_rewriter.PatternRewriteWalker(
+            pattern_rewriter.GreedyRewritePatternApplier([MergeRotationsPattern()])
+        ).rewrite_module(module)
diff --git a/pennylane/drawer/_add_obj.py b/pennylane/drawer/_add_obj.py
index 74d4d0e81..4ec9d34c7 100644
--- a/pennylane/drawer/_add_obj.py
+++ b/pennylane/drawer/_add_obj.py
@@ -29,6 +29,7 @@ The `_add_obj` function is automatically invoked by the text drawer when renderi
 # pylint: disable=unused-argument
 
 from functools import singledispatch
+from typing import Union
 
 from pennylane.measurements import (
     CountsMP,
@@ -42,7 +43,7 @@ from pennylane.measurements import (
     VarianceMP,
 )
 from pennylane.operation import Operator
-from pennylane.ops import Conditional, Controlled
+from pennylane.ops import Conditional, Controlled, GlobalPhase, Identity
 from pennylane.tape import QuantumScript
 
 
@@ -77,17 +78,21 @@ def _add_cond_grouping_symbols(op, layer_str, config):
     return layer_str
 
 
-def _add_grouping_symbols(op, layer_str, config):
-    """Adds symbols indicating the extent of a given object."""
+def _add_grouping_symbols(op_wires, layer_str, config):
+    """Adds symbols indicating the extent of a given sequence of wires.
+    Does nothing if the sequence has length 0 or 1."""
 
-    if len(op.wires) > 1:
-        mapped_wires = [config.wire_map[w] for w in op.wires]
-        min_w, max_w = min(mapped_wires), max(mapped_wires)
-        layer_str[min_w] = "╭"
-        layer_str[max_w] = "╰"
+    if len(op_wires) <= 1:
+        return layer_str
+
+    mapped_wires = [config.wire_map[w] for w in op_wires]
+    min_w, max_w = min(mapped_wires), max(mapped_wires)
 
-        for w in range(min_w + 1, max_w):
-            layer_str[w] = "├" if w in mapped_wires else "│"
+    layer_str[min_w] = "╭"
+    layer_str[max_w] = "╰"
+
+    for w in range(min_w + 1, max_w):
+        layer_str[w] = "├" if w in mapped_wires else "│"
 
     return layer_str
 
@@ -130,18 +135,36 @@ def _add_cond(obj: Conditional, layer_str, config, tape_cache=None, skip_groupin
 def _add_controlled(
     obj: Controlled, layer_str, config, tape_cache=None, skip_grouping_symbols=False
 ):
-    layer_str = _add_grouping_symbols(obj, layer_str, config)
+    if isinstance(obj.base, (GlobalPhase, Identity)):
+        return _add_controlled_global_op(obj, layer_str, config)
+
+    layer_str = _add_grouping_symbols(obj.wires, layer_str, config)
     for w, val in zip(obj.control_wires, obj.control_values):
         layer_str[config.wire_map[w]] += "●" if val else "○"
-
     return _add_obj(obj.base, layer_str, config, skip_grouping_symbols=True)
 
 
+def _add_controlled_global_op(obj, layer_str, config):
+    """This is not another dispatch managed by @_add_obj.register,
+    but a manually managed dispatch."""
+    layer_str = _add_grouping_symbols(list(config.wire_map.keys()), layer_str, config)
+
+    for w, val in zip(obj.control_wires, obj.control_values):
+        layer_str[config.wire_map[w]] += "●" if val else "○"
+
+    label = obj.base.label(decimals=config.decimals, cache=config.cache).replace("\n", "")
+    for w, val in config.wire_map.items():
+        if w not in obj.control_wires:
+            layer_str[val] += label
+
+    return layer_str
+
+
 @_add_obj.register
 def _add_op(obj: Operator, layer_str, config, tape_cache=None, skip_grouping_symbols=False):
     """Updates ``layer_str`` with ``op`` operation."""
     if not skip_grouping_symbols:
-        layer_str = _add_grouping_symbols(obj, layer_str, config)
+        layer_str = _add_grouping_symbols(obj.wires, layer_str, config)
 
     label = obj.label(decimals=config.decimals, cache=config.cache).replace("\n", "")
     if len(obj.wires) == 0:  # operation (e.g. barrier, snapshot) across all wires
@@ -155,6 +178,26 @@ def _add_op(obj: Operator, layer_str, config, tape_cache=None, skip_grouping_sym
     return layer_str
 
 
+@_add_obj.register(Identity)
+@_add_obj.register(GlobalPhase)
+def _add_global_op(
+    obj: Union[GlobalPhase, Identity],
+    layer_str,
+    config,
+    tape_cache=None,
+    skip_grouping_symbols=False,
+):
+    n_wires = len(config.wire_map)
+    if not skip_grouping_symbols:
+        layer_str = _add_grouping_symbols(list(config.wire_map.keys()), layer_str, config)
+
+    label = obj.label(decimals=config.decimals, cache=config.cache).replace("\n", "")
+    for i, s in enumerate(layer_str[:n_wires]):
+        layer_str[i] = s + label
+
+    return layer_str
+
+
 @_add_obj.register
 def _add_mid_measure_op(
     op: MidMeasureMP, layer_str, config, tape_cache=None, skip_grouping_symbols=False
@@ -172,7 +215,7 @@ def _add_mid_measure_op(
 
 @_add_obj.register
 def _add_tape(obj: QuantumScript, layer_str, config, tape_cache, skip_grouping_symbols=False):
-    layer_str = _add_grouping_symbols(obj, layer_str, config)
+    layer_str = _add_grouping_symbols(obj.wires, layer_str, config)
     label = f"Tape:{config.cache['tape_offset']+len(tape_cache)}"
     for w in obj.wires:
         layer_str[config.wire_map[w]] += label
@@ -237,7 +280,7 @@ def _add_measurement(
     if m.mv is not None:
         return _add_cwire_measurement(m, layer_str, config)
 
-    layer_str = _add_grouping_symbols(m, layer_str, config)
+    layer_str = _add_grouping_symbols(m.wires, layer_str, config)
 
     if m.obs is None:
         obs_label = None
diff --git a/pennylane/drawer/draw.py b/pennylane/drawer/draw.py
index 8b3fb8a13..a9ad074c5 100644
--- a/pennylane/drawer/draw.py
+++ b/pennylane/drawer/draw.py
@@ -1,5 +1,3 @@
-# pylint: disable=too-many-arguments
-
 # Copyright 2018-2021 Xanadu Quantum Technologies Inc.
 
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -251,6 +249,42 @@ def draw(
         1: ─╰RandomLayers(M0)─├Permute───────┤
         2: ───────────────────╰Permute───────┤
 
+        **Operators without wires:**
+
+        Some operators deviate from the standard :class:`~.operation.Operator` class in their
+        handling of wires. In particular, tools like :class:`~.Snapshot`
+        always occupy all qubits, and are drawn accordingly:
+
+        >>> draw_kwargs = {"wire_order" : [0, 1, 2], "show_all_wires" : True}
+        >>> print(qml.draw(qml.Snapshot, **draw_kwargs)())
+        0: ──|Snap|─┤
+        1: ──|Snap|─┤
+        2: ──|Snap|─┤
+
+        In addition, globally acting operators like :class:`~.GlobalPhase` or
+        :class:`~.Identity` are always represented on all wires:
+
+        >>> print(qml.draw(qml.GlobalPhase, **draw_kwargs)(phi=0.5, wires=[]))
+        0: ─╭GlobalPhase(0.50)─┤
+        1: ─├GlobalPhase(0.50)─┤
+        2: ─╰GlobalPhase(0.50)─┤
+
+        This is the case even if they are provided with a subset of all wires:
+
+        >>> print(qml.draw(qml.GlobalPhase, **draw_kwargs)(phi=0.5, wires=[0]))
+        0: ─╭GlobalPhase(0.50)─┤
+        1: ─├GlobalPhase(0.50)─┤
+        2: ─╰GlobalPhase(0.50)─┤
+
+        For controlled versions of these globally acting operators, the control
+        nodes are exempt from the expansion:
+
+        >>> ctrl_gphase = qml.ctrl(qml.GlobalPhase, control=[2])
+        >>> print(qml.draw(ctrl_gphase, **draw_kwargs)(phi=0.5, wires=[0]))
+        0: ─╭GlobalPhase(0.50)─┤
+        1: ─├GlobalPhase(0.50)─┤
+        2: ─╰●─────────────────┤
+
     """
     if catalyst_qjit(qnode):
         qnode = qnode.user_function
@@ -683,6 +717,61 @@ def draw_mpl(
             :width: 60%
             :target: javascript:void(0);
 
+        **Operators without wires:**
+
+        Some operators deviate from the standard :class:`~.operation.Operator` class in their
+        handling of wires. In particular, tools like :class:`~.Snapshot`
+        always occupy all qubits, and are drawn accordingly:
+
+        .. code-block:: python
+
+            draw_kwargs = {"wire_order" : [0, 1, 2], "show_all_wires" : True}
+            fig, ax = qml.draw_mpl(qml.Snapshot, **draw_kwargs)()
+            fig.show()
+
+        .. figure:: ../../_static/draw_mpl/snapshot.png
+            :align: center
+            :width: 40%
+            :target: javascript:void(0);
+
+        In addition, globally acting operators like :class:`~.GlobalPhase` or
+        :class:`~.Identity` are always represented on all wires:
+
+        .. code-block:: python
+
+            fig, ax = qml.draw_mpl(qml.GlobalPhase, **draw_kwargs)(phi=0.5, wires=[])
+            fig.show()
+
+        .. figure:: ../../_static/draw_mpl/gphase_no_wires.png
+            :align: center
+            :width: 40%
+            :target: javascript:void(0);
+
+        This is the case even if they are provided with a subset of all wires:
+
+        .. code-block:: python
+
+            fig, ax = qml.draw_mpl(qml.GlobalPhase, **draw_kwargs)(phi=0.5, wires=[0])
+            fig.show()
+
+        .. figure:: ../../_static/draw_mpl/gphase_one_wire.png
+            :align: center
+            :width: 40%
+            :target: javascript:void(0);
+
+        For controlled versions of these globally acting operators, the control
+        nodes are exempt from the expansion:
+
+        .. code-block:: python
+
+            ctrl_gphase = qml.ctrl(qml.GlobalPhase, control=[2])
+            fig, ax = qml.draw_mpl(ctrl_gphase, **draw_kwargs)(phi=0.5, wires=[0])
+            fig.show()
+
+        .. figure:: ../../_static/draw_mpl/ctrl_gphase.png
+            :align: center
+            :width: 40%
+            :target: javascript:void(0);
 
     """
     if catalyst_qjit(qnode):
diff --git a/pennylane/drawer/drawable_layers.py b/pennylane/drawer/drawable_layers.py
index a5d9cfc08..98a916529 100644
--- a/pennylane/drawer/drawable_layers.py
+++ b/pennylane/drawer/drawable_layers.py
@@ -16,9 +16,9 @@ This module contains a helper function to sort operations into layers.
 """
 
 from pennylane.measurements import MeasurementProcess, MidMeasureMP
-from pennylane.ops import Conditional
+from pennylane.ops import Conditional, GlobalPhase, Identity
 
-from .utils import default_wire_map
+from .utils import default_wire_map, unwrap_controls
 
 
 def _recursive_find_layer(layer_to_check, op_occupied_wires, occupied_wires_per_layer):
@@ -96,9 +96,11 @@ def _get_op_occupied_wires(op, wire_map, bit_map):
         max_wire = max(wire_map.values())
         return set(range(min_wire, max_wire + 1))
 
-    if len(op.wires) == 0:
-        # if no wires, then it acts on all wires
-        # for example, qml.state and qml.sample
+    *_, base = unwrap_controls(op)
+
+    if len(op.wires) == 0 or isinstance(base, (GlobalPhase, Identity)):
+        # if no wires, then it acts on all wires. For example qml.state and qml.sample or
+        # (controlled) GlobalPhase or (controlled) Identity
         mapped_wires = set(wire_map.values())
         return mapped_wires
 
diff --git a/pennylane/drawer/tape_mpl.py b/pennylane/drawer/tape_mpl.py
index ab638fb1e..1ef83cab0 100644
--- a/pennylane/drawer/tape_mpl.py
+++ b/pennylane/drawer/tape_mpl.py
@@ -71,13 +71,15 @@ def _add_operation_to_drawer(op: Operator, drawer: MPLDrawer, layer: int, config
         Adds a depiction of ``op`` to ``drawer``
 
     """
-    op_control_wires, control_values = unwrap_controls(op)
-
-    target_wires = (
-        [w for w in op.wires if w not in op_control_wires]
-        if len(op.wires) != 0
-        else list(range(drawer.n_wires))
-    )
+    op_control_wires, control_values, base = unwrap_controls(op)
+    is_global_op = isinstance(base, (ops.GlobalPhase, ops.Identity))
+    if len(op.wires) == 0 or is_global_op:
+        op_wires = list(range(drawer.n_wires))
+    else:
+        op_wires = op.wires
+    target_wires = [w for w in op_wires if w not in op_control_wires]
+    if is_global_op and len(target_wires) == 0:
+        raise ValueError("Can't draw controlled global gate with unknown non-control wires.")
 
     if control_values is None:
         control_values = [True for _ in op_control_wires]
diff --git a/pennylane/drawer/utils.py b/pennylane/drawer/utils.py
index b25021aec..8a33479d0 100644
--- a/pennylane/drawer/utils.py
+++ b/pennylane/drawer/utils.py
@@ -121,8 +121,8 @@ def unwrap_controls(op):
     if isinstance(control_values, list):
         control_values = control_values.copy()
 
+    next_ctrl = op
     if isinstance(op, Controlled):
-        next_ctrl = op
 
         while hasattr(next_ctrl, "base"):
 
@@ -140,7 +140,7 @@ def unwrap_controls(op):
             next_ctrl = next_ctrl.base
 
     control_values = [bool(int(i)) for i in control_values] if control_values else control_values
-    return control_wires, control_values
+    return control_wires, control_values, next_ctrl
 
 
 def cwire_connections(layers, bit_map):


Test patch:
diff --git a/tests/drawer/test_draw.py b/tests/drawer/test_draw.py
index 8865e6e94..ef4d5457e 100644
--- a/tests/drawer/test_draw.py
+++ b/tests/drawer/test_draw.py
@@ -358,10 +358,16 @@ class TestMidCircuitMeasurements:
         assert drawing == expected_drawing
 
     @pytest.mark.parametrize(
-        "op", [qml.GlobalPhase(0.1), qml.Identity(), qml.Snapshot(), qml.Barrier()]
+        "op, grouped",
+        [
+            (qml.GlobalPhase(0.1), True),
+            (qml.Identity(), True),
+            (qml.Snapshot(), False),
+            (qml.Barrier(), False),
+        ],
     )
     @pytest.mark.parametrize("decimals", [None, 2])
-    def test_draw_all_wire_ops(self, op, decimals):
+    def test_draw_all_wire_ops(self, op, grouped, decimals):
         """Test that operators acting on all wires are drawn correctly"""
 
         def func():
@@ -376,9 +382,14 @@ class TestMidCircuitMeasurements:
         # end of the drawing depends on the length of each individual line
         drawing = qml.draw(func, decimals=decimals)().strip()
         label = op.label(decimals=decimals).replace("\n", "")
-        expected_drawing = (
-            f"0: ──X──┤↗├──X──{label}─┤  <Z>\n1: ──X───║───║──{label}─┤     \n         ╚═══╝"
-        )
+        if grouped:
+            expected_drawing = (
+                f"0: ──X──┤↗├──X─╭{label}─┤  <Z>\n1: ──X───║───║─╰{label}─┤     \n         ╚═══╝"
+            )
+        else:
+            expected_drawing = (
+                f"0: ──X──┤↗├──X──{label}─┤  <Z>\n1: ──X───║───║──{label}─┤     \n         ╚═══╝"
+            )
 
         assert drawing == expected_drawing
 
diff --git a/tests/drawer/test_drawer_utils.py b/tests/drawer/test_drawer_utils.py
index c21451e21..179e0c308 100644
--- a/tests/drawer/test_drawer_utils.py
+++ b/tests/drawer/test_drawer_utils.py
@@ -135,20 +135,22 @@ class TestUnwrapControls:
     # pylint:disable=too-few-public-methods
 
     @pytest.mark.parametrize(
-        "op,expected_control_wires,expected_control_values",
+        "op,expected_control_wires,expected_control_values,expected_base_cls",
         [
-            (qml.PauliX(wires="a"), Wires([]), None),
-            (qml.CNOT(wires=["a", "b"]), Wires("a"), [True]),
-            (qml.ctrl(qml.PauliX(wires="b"), control="a"), Wires("a"), [True]),
+            (qml.X(wires="a"), Wires([]), None, qml.X),
+            (qml.CNOT(wires=["a", "b"]), Wires("a"), [True], qml.X),
+            (qml.ctrl(qml.X(wires="b"), control="a"), Wires("a"), [True], qml.X),
             (
-                qml.ctrl(qml.PauliX(wires="b"), control=["a", "c", "d"]),
+                qml.ctrl(qml.X(wires="b"), control=["a", "c", "d"]),
                 Wires(["a", "c", "d"]),
                 [True, True, True],
+                qml.X,
             ),
             (
-                qml.ctrl(qml.PauliZ(wires="c"), control=["a", "d"], control_values=[True, False]),
+                qml.ctrl(qml.Z(wires="c"), control=["a", "d"], control_values=[True, False]),
                 Wires(["a", "d"]),
                 [True, False],
+                qml.Z,
             ),
             (
                 qml.ctrl(
@@ -158,16 +160,19 @@ class TestUnwrapControls:
                 ),
                 Wires(["a", "b", "d", "c"]),
                 [True, False, False, True],
+                qml.RX,
             ),
             (
                 qml.ctrl(qml.CNOT(wires=["c", "d"]), control=["a", "b"]),
                 Wires(["a", "b", "c"]),
                 [True, True, True],
+                qml.X,
             ),
             (
                 qml.ctrl(qml.ctrl(qml.CNOT(wires=["c", "d"]), control=["a", "b"]), control=["e"]),
                 Wires(["e", "a", "b", "c"]),
                 [True, True, True, True],
+                qml.X,
             ),
             (
                 qml.ctrl(
@@ -179,17 +184,19 @@ class TestUnwrapControls:
                 ),
                 Wires(["e", "a", "b", "c"]),
                 [False, False, True, True],
+                qml.X,
             ),
         ],
     )
     def test_multi_defined_control_values(
-        self, op, expected_control_wires, expected_control_values
+        self, op, expected_control_wires, expected_control_values, expected_base_cls
     ):
         """Test a multi-controlled single-qubit operation with defined control values."""
-        control_wires, control_values = unwrap_controls(op)
+        control_wires, control_values, base = unwrap_controls(op)
 
         assert control_wires == expected_control_wires
         assert control_values == expected_control_values
+        assert isinstance(base, expected_base_cls)
 
 
 # pylint: disable=use-implicit-booleaness-not-comparison
diff --git a/tests/drawer/test_tape_mpl.py b/tests/drawer/test_tape_mpl.py
index ee1349fa9..3ed8d3731 100644
--- a/tests/drawer/test_tape_mpl.py
+++ b/tests/drawer/test_tape_mpl.py
@@ -18,6 +18,8 @@ page in the developement guide.
 """
 # pylint: disable=protected-access, expression-not-assigned
 
+import warnings
+
 import numpy as np
 import pytest
 
@@ -656,6 +658,7 @@ class TestGeneralOperations:
         assert isinstance(ax, mpl.axes._axes.Axes)
 
         assert fig.axes == [ax]
+        assert len(ax.patches) == len(ax.texts) == 0
 
         # Test that `qml.Snapshot` works properly when other gates are present
         tape = QuantumScript([qml.Snapshot(), qml.Hadamard(0), qml.Hadamard(1), qml.Hadamard(2)])
@@ -669,6 +672,138 @@ class TestGeneralOperations:
 
         plt.close()
 
+    @pytest.mark.parametrize("input_wires", [tuple(), (0, 1), (0, 2, 1, 3)])
+    @pytest.mark.parametrize("show_all_wires", [False, True])
+    @pytest.mark.parametrize("cls", [qml.GlobalPhase, qml.Identity])
+    def test_global_phase(self, input_wires, show_all_wires, cls):
+        """Test that `GlobalPhase` and `Identity` works properly with `tape_mpl`."""
+
+        data = [0.3625][: cls.num_params]
+        tape = QuantumScript([cls(*data, wires=input_wires), qml.X(0)])
+        fig, ax = tape_mpl(tape, show_all_wires=show_all_wires, wire_order=[0, 1, 2, 3, 4])
+
+        assert isinstance(fig, mpl.figure.Figure)
+        assert isinstance(ax, mpl.axes._axes.Axes)
+
+        assert fig.axes == [ax]
+
+        if show_all_wires:
+            num_wires = 5
+        else:
+            num_wires = max(len(input_wires), 1)
+        assert isinstance(ax.patches[0], mpl.patches.FancyBboxPatch)
+        assert ax.patches[0].get_x() == -self.width / 2.0
+        assert ax.patches[0].get_y() == -self.width / 2.0
+        assert ax.patches[0].get_width() == self.width
+        assert ax.patches[0].get_height() == num_wires - 1 + self.width
+
+        plt.close()
+
+    @pytest.mark.parametrize("cls", [qml.GlobalPhase, qml.Identity])
+    def test_multiple_global_ops(self, cls):
+        """Test that global ops correctly reserve layers for themselves."""
+        data = [0.3625][: cls.num_params]
+        tape = QuantumScript([cls(*data, wires=wires) for wires in [[], [0], [1, 2]]])
+
+        fig, ax = tape_mpl(tape)
+
+        assert isinstance(fig, mpl.figure.Figure)
+        assert isinstance(ax, mpl.axes._axes.Axes)
+
+        assert fig.axes == [ax]
+
+        assert len(ax.patches) == 3  # Three boxes without notches
+        for i, patch in enumerate(ax.patches):
+            assert isinstance(patch, mpl.patches.FancyBboxPatch)
+            assert patch.get_x() == i + -self.width / 2.0
+            assert patch.get_y() == -self.width / 2.0
+            assert patch.get_width() == self.width
+            assert patch.get_height() == 2 + self.width
+
+        plt.close()
+
+    @pytest.mark.parametrize(
+        "input_wires, control_wires",
+        [
+            (tuple(), (0,)),
+            ((0, 1), (2, 3)),
+            ((0, 3), (2,)),
+            ((0, 2, 3), (1,)),
+            ((0, 3), (1, 2)),
+            ((0, 2), (1, 3)),
+        ],
+    )
+    @pytest.mark.parametrize("show_all_wires", [False, True])
+    @pytest.mark.parametrize("cls", [qml.GlobalPhase, qml.Identity])
+    def test_ctrl_global_op(self, input_wires, control_wires, show_all_wires, cls):
+        """Test that controlled `GlobalPhase` and `Identity` works properly with `tape_mpl`."""
+
+        data = [0.3625][: cls.num_params]
+        op = qml.ctrl(cls(*data, wires=input_wires), control=control_wires)
+        tape = QuantumScript([op, qml.X(0), qml.X(1)])
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            fig, ax = tape_mpl(tape, show_all_wires=show_all_wires, wire_order=[0, 1, 2, 3, 4])
+
+        assert isinstance(fig, mpl.figure.Figure)
+        assert isinstance(ax, mpl.axes._axes.Axes)
+
+        assert fig.axes == [ax]
+
+        # Control node circular patches (may be invisible)
+        for i, c in enumerate(control_wires):
+            patch = ax.patches[i]
+            assert isinstance(patch, mpl.patches.Circle)
+            assert patch.center == (0, c)
+
+        # Main box
+        if show_all_wires:
+            covered_wires = range(5)
+        elif len(input_wires) == 0:
+            covered_wires = (0, 1)  # Wires for qml.X operations transfer to GlobalPhase
+        else:
+            covered_wires = range(min(input_wires), max(input_wires) + 1)
+        min_box_wire = min([w for w in covered_wires if w not in control_wires])
+        max_box_wire = max([w for w in covered_wires if w not in control_wires])
+        i = len(control_wires)
+        main_patch = ax.patches[i]
+        assert isinstance(main_patch, mpl.patches.FancyBboxPatch)
+
+        assert main_patch.get_x() == -self.width / 2.0
+        assert main_patch.get_y() == min_box_wire - self.width / 2.0
+        assert main_patch.get_width() == self.width
+        assert main_patch.get_height() == max_box_wire - min_box_wire + self.width
+        for j in range(i):
+            assert main_patch.zorder > ax.patches[j].zorder
+
+        # Notches
+        i += 1
+        for w in control_wires:
+            if min_box_wire < w < max_box_wire:
+                notch_patch = ax.patches[i]
+                assert isinstance(notch_patch, mpl.patches.FancyBboxPatch)
+                assert notch_patch.zorder < main_patch.zorder
+
+        plt.close()
+
+    @pytest.mark.parametrize("cls", [qml.GlobalPhase, qml.Identity])
+    def test_ctrl_global_op_without_target(self, cls):
+        """Test that an error is raised if a controlled GlobalPhase is present that can
+        not infer any target wires."""
+
+        data = [0.251][: cls.num_params]
+        op = qml.ctrl(cls(*data, wires=[]), control=(0, 4))
+        tape = QuantumScript([op])
+        with pytest.raises(ValueError, match="controlled global gate with unknown"):
+            _ = tape_mpl(tape)
+
+        # No error if wire_order provides additional wire(s)
+        _ = tape_mpl(tape, wire_order=[0, 1, 2], show_all_wires=True)
+
+        # Error if wire_order provides additional wire(s) but they are not drawn
+        with pytest.raises(ValueError, match="controlled global gate with unknown"):
+            _ = tape_mpl(tape, wire_order=[0, 1, 2], show_all_wires=False)
+
     @pytest.mark.parametrize("op", general_op_data)
     def test_general_operations_decimals(self, op):
         """Check that the decimals argument affects text strings when applicable."""
diff --git a/tests/drawer/test_tape_text.py b/tests/drawer/test_tape_text.py
index d463ce263..9571180d2 100644
--- a/tests/drawer/test_tape_text.py
+++ b/tests/drawer/test_tape_text.py
@@ -16,6 +16,8 @@ Unit tests for the `pennylane.draw_text` function.
 """
 # pylint: disable=import-outside-toplevel
 
+from copy import copy
+
 import pytest
 
 import pennylane as qml
@@ -100,7 +102,7 @@ class TestHelperFunctions:  # pylint: disable=too-many-arguments, too-many-posit
         config = _Config(
             wire_map=default_wire_map, bit_map=default_bit_map, num_op_layers=4, cur_layer=0
         )
-        assert out == _add_grouping_symbols(op, ["", "", "", ""], config)
+        assert out == _add_grouping_symbols(op.wires, ["", "", "", ""], config)
 
     @pytest.mark.parametrize(
         "op, bit_map, layer_str, out",
@@ -400,6 +402,50 @@ class TestHelperFunctions:  # pylint: disable=too-many-arguments, too-many-posit
         op2 = qml.QubitUnitary(np.eye(2), wires=1)
         assert _add_obj(op2, ["", ""], config) == ["", "U(M0)"]
 
+    @pytest.mark.parametrize("wires", [tuple(), (0, 1), (0, 1, 2, 3)])
+    @pytest.mark.parametrize("wire_map", [default_wire_map, {0: 0, 1: 1}])
+    @pytest.mark.parametrize("cls, label", [(qml.GlobalPhase, "GlobalPhase"), (qml.Identity, "I")])
+    def test_add_global_op(self, wires, wire_map, cls, label):
+        """Test that adding a global op works as expected."""
+        data = [0.5124][: cls.num_params]
+        op = cls(*data, wires=wires)
+        # Expected output does not depend on the wires of GlobalPhase but just
+        # on the number of drawn wires as dictated by the config!
+        n_wires = len(wire_map)
+        expected = [f"╭{label}"] + [f"├{label}"] * (n_wires - 2) + [f"╰{label}"]
+        config = _Config(wire_map=wire_map, bit_map=default_bit_map, num_op_layers=4, cur_layer=1)
+        out = _add_obj(op, ["─"] * n_wires, config)
+        assert expected == out
+
+    @pytest.mark.parametrize(
+        "wires, control_wires, expected",
+        [
+            (tuple(), (0,), ["╭●", "├label", "├label", "╰label"]),
+            (tuple(), (2,), ["╭label", "├label", "├●", "╰label"]),
+            ((2,), (0, 1, 3), ["╭●", "├●", "├label", "╰●"]),
+            ((0, 1), (3,), ["╭label", "├label", "├label", "╰●"]),
+            ((0, 2), (1, 3), ["╭label", "├●", "├label", "╰●"]),
+            ((0, 1, 3), (2,), ["╭label", "├label", "├●", "╰label"]),
+        ],
+    )
+    @pytest.mark.parametrize("wire_map", [default_wire_map, {i: i for i in range(6)}])
+    @pytest.mark.parametrize("cls, label", [(qml.GlobalPhase, "GlobalPhase"), (qml.Identity, "I")])
+    def test_add_controlled_global_op(self, wires, control_wires, expected, wire_map, cls, label):
+        """Test that adding a controlled global op works as expected."""
+        expected = copy(expected)
+        data = [0.5124][: cls.num_params]
+        op = qml.ctrl(cls(*data, wires=wires), control=control_wires)
+        n_wires = len(wire_map)
+        if n_wires > 4:
+            expected[-1] = "├" + expected[-1][1:]
+            expected.extend(["├label"] * (n_wires - 5))
+            expected.append("╰label")
+
+        expected = [line.replace("label", label) for line in expected]
+        config = _Config(wire_map=wire_map, bit_map=default_bit_map, num_op_layers=4, cur_layer=1)
+        out = _add_obj(op, ["─"] * n_wires, config)
+        assert expected == out
+
 
 class TestEmptyTapes:
     """Test that the text for empty tapes is correct."""