[PATCH v3 0/2] perf python TUI report and flamegraph

Ian Rogers posted 2 patches 1 month, 3 weeks ago
There is a newer version of this series
tools/perf/python/treport.py | 560 +++++++++++++++++++++++++++++++++++
1 file changed, 560 insertions(+)
create mode 100755 tools/perf/python/treport.py
[PATCH v3 0/2] perf python TUI report and flamegraph
Posted by Ian Rogers 1 month, 3 weeks ago
Implement a perf TUI using the python textual module. Using the
textual framework means that in most modern terminals the mouse is
supported, the color scheme can be configured, and screenshots made.

The work is dependent on the extensions to the perf python module
merged into perf-tools-next such as commit 88439191ad5e ("perf python:
Add callchain support"). It is also dependent on the python textual
module for TUI support.

v3: Tweaks to commit messages (Sashiko).

v2: Migrate from inbuilt perf python interpreter to perf python
    module. Improve the flamegraph performance. Use the textual's
    color scheme rather than red and white.
https://lore.kernel.org/linux-perf-users/20260808065743.2328212-1-irogers@google.com/

v1:
https://lore.kernel.org/linux-perf-users/20250725082425.20999-1-irogers@google.com/

Alice Rogers (2):
  perf python: New treport script
  perf python: treport add flamegraph support

 tools/perf/python/treport.py | 560 +++++++++++++++++++++++++++++++++++
 1 file changed, 560 insertions(+)
 create mode 100755 tools/perf/python/treport.py

-- 
2.55.0.654.g21b8a5bc05-goog
[PATCH v4 0/2] perf python TUI report and flamegraph
Posted by Ian Rogers 1 month, 3 weeks ago
Implement a perf TUI using the python textual module. Using the
textual framework means that in most modern terminals the mouse is
supported, the color scheme can be configured, and screenshots made.

The work is dependent on the extensions to the perf python module
merged into perf-tools-next such as commit 88439191ad5e ("perf python:
Add callchain support"). It is also dependent on the python textual
module for TUI support.

v4: Fix typo of profle to profile and tweak argparse command
    description to only mention flamegraph after that support is added
    (Sashiko). Sashiko notes a high severity issue because the script
    depends on perf Python module extensions that are already
    merged. This is because Sashiko is testing the changes against the
    perf-tools rather than perf-tools-next. Verified
    rich.errors.MarkupError don't occur for kernel DSOs, primarily as
    there is always a space after the square brackets.

v3: Tweaks to commit messages (Sashiko).
https://lore.kernel.org/linux-perf-users/20260809041638.2402705-1-irogers@google.com/

v2: Migrate from inbuilt perf python interpreter to perf python
    module. Improve the flamegraph performance. Use the textual's
    color scheme rather than red and white.
https://lore.kernel.org/linux-perf-users/20260808065743.2328212-1-irogers@google.com/

v1:
https://lore.kernel.org/linux-perf-users/20250725082425.20999-1-irogers@google.com/

Alice Rogers (2):
  perf python: New treport script
  perf python: treport add flamegraph support

 tools/perf/python/treport.py | 561 +++++++++++++++++++++++++++++++++++
 1 file changed, 561 insertions(+)
 create mode 100755 tools/perf/python/treport.py

-- 
2.55.0.654.g21b8a5bc05-goog
Re: [PATCH v4 0/2] perf python TUI report and flamegraph
Posted by Arnaldo Carvalho de Melo 2 weeks, 5 days ago
On Sat, Aug 08, 2026 at 10:18:17PM -0700, Ian Rogers wrote:
> Implement a perf TUI using the python textual module. Using the
> textual framework means that in most modern terminals the mouse is
> supported, the color scheme can be configured, and screenshots made.
> 
> The work is dependent on the extensions to the perf python module
> merged into perf-tools-next such as commit 88439191ad5e ("perf python:
> Add callchain support"). It is also dependent on the python textual
> module for TUI support.

Cool stuff, just tested it :-)

Thanks, applied to perf-tools-next, for v7.4.

- Arnaldo
 
> v4: Fix typo of profle to profile and tweak argparse command
>     description to only mention flamegraph after that support is added
>     (Sashiko). Sashiko notes a high severity issue because the script
>     depends on perf Python module extensions that are already
>     merged. This is because Sashiko is testing the changes against the
>     perf-tools rather than perf-tools-next. Verified
>     rich.errors.MarkupError don't occur for kernel DSOs, primarily as
>     there is always a space after the square brackets.
> 
> v3: Tweaks to commit messages (Sashiko).
> https://lore.kernel.org/linux-perf-users/20260809041638.2402705-1-irogers@google.com/
> 
> v2: Migrate from inbuilt perf python interpreter to perf python
>     module. Improve the flamegraph performance. Use the textual's
>     color scheme rather than red and white.
> https://lore.kernel.org/linux-perf-users/20260808065743.2328212-1-irogers@google.com/
> 
> v1:
> https://lore.kernel.org/linux-perf-users/20250725082425.20999-1-irogers@google.com/
> 
> Alice Rogers (2):
>   perf python: New treport script
>   perf python: treport add flamegraph support
> 
>  tools/perf/python/treport.py | 561 +++++++++++++++++++++++++++++++++++
>  1 file changed, 561 insertions(+)
>  create mode 100755 tools/perf/python/treport.py
> 
> -- 
> 2.55.0.654.g21b8a5bc05-goog
[PATCH v4 1/2] perf python: New treport script
Posted by Ian Rogers 1 month, 3 weeks ago
From: Alice Rogers <alice.mei.rogers@gmail.com>

A textual app that displays the results of processing samples similar
to perf report. The app displays a tree of first processes and then
functions which drop down to show more detail on the functions they
call. The functions with the largest number of samples are sorted
first, after each function the percentage of time spent within it is
highlighted. If more than one event is recorded then each event
appears first, with the processes and functions sorted for that event
beneath it.

The app is written in python and requires the textual framework. Using
the textual framework means that in most modern terminals the mouse is
supported, the color scheme can be configured, and screenshots made.

With perf report:
```
$ perf record -e cycles:u -g perf test -w brstack
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ]
$ perf report
...
Samples: 57  of event 'cycles:u', Event count (approx.): 15907831
  Children      Self  Command  Shared Object         Symbol
-   88.86%     0.00%  perf     libc.so.6             [.] 0x000079b17df69ca8
     0x79b17df69ca8
     main
     handle_internal_command
     cmd_test
   - brstack
      - 84.27% brstack_bench
         - 40.69% brstack_foo
              brstack_bar
           3.32% brstack_bar
        3.31% brstack_bar
        1.27% brstack_foo
+   88.86%     0.00%  perf     perf                  [.] main
+   88.86%     0.00%  perf     perf                  [.] handle_internal_command
+   88.86%     0.00%  perf     perf                  [.] cmd_test
+   88.86%     0.00%  perf     perf                  [.] brstack
+   84.27%    40.26%  perf     perf                  [.] brstack_bench
+   41.96%    30.71%  perf     perf                  [.] brstack_foo
+   17.89%    17.89%  perf     perf                  [.] brstack_bar
```

With the treport script:
```
$ perf record -e cycles:u -g perf test -w brstack
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ]
$ tools/perf/python/treport.py
 O                       ReportApp
 Report
╸━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
▼ Profile
└── ▼ cycles:u 100%
    └── ▼ perf (32963) 100%
        ├── ▼ /usr/lib/x86_64-linux-gnu/libc.so.6 0x79b17df6
        │   └── ▼ main 56.1%
        │       └── ▼ handle_internal_command 56.1%
        │           └── ▼ cmd_test 56.1%
        │               └── ▼ brstack 56.1%                 ▁▁
        │                   ├── ▼ brstack_bench 52.6%
        │                   │   ├── ▶ brstack_foo 24.6%
        │                   │   └── brstack_bar 1.75%
        │                   ├── brstack_foo 1.75%
        │                   └── brstack_bar 1.75%
        ├── ▶ /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
                                            ▌
^q Quit                                          ▏^p palette
```

Co-developed-by: Ian Rogers <irogers@google.com>
Signed-off-by: Ian Rogers <irogers@google.com>
Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com>
---
 tools/perf/python/treport.py | 207 +++++++++++++++++++++++++++++++++++
 1 file changed, 207 insertions(+)
 create mode 100755 tools/perf/python/treport.py

diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py
new file mode 100755
index 000000000000..71a91775bd6a
--- /dev/null
+++ b/tools/perf/python/treport.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+"""treport.py - perf report like tool written using textual."""
+from typing import Dict, Optional
+import argparse
+import os
+import sys
+import perf
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree
+from textual.widgets.tree import TreeNode
+
+# Global session.
+session :Optional[perf.session] = None
+
+class ProfileNode:
+    """Represents a single node in a call stack tree.
+
+    Generally a ProfileNode corresponds to a symbol in a call stack.
+    The root is special, its children are events and the events
+    children are process names. After the process name come the
+    samples.
+
+    Attributes:
+        name (str): The name of the function, process or event.
+        value (int): The sample count for this node including counts from its
+                     children.
+        parent (ProfileNode): The parent of this node, this node belongs to its
+                              children.
+        children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by
+                                           their names.
+    """
+    def __init__(self, name: str, parent: "ProfileNode"):
+        """Initializes a ProfileNode."""
+        self.name = name
+        self.value: int = 0
+        self.parent = parent if parent else self
+        self.children: Dict[str, ProfileNode] = {}
+
+    def find_or_create_node(self, name: str) -> "ProfileNode":
+        """Finds a child node by name or creates it if it doesn't exist."""
+        if name in self.children:
+            return self.children[name]
+        child = ProfileNode(name, self)
+        self.children[name] = child
+        return child
+
+    def depth(self) -> int:
+        """The maximum depth of the call stack tree from this node down."""
+        if not self.children:
+            return 1
+        return max(child.depth() for child in self.children.values()) + 1
+
+    def process_event(self, sample) -> None:
+        """Processes a single profiling event to update the call stack tree.
+
+        Args:
+            sample: a single profiling sample.
+        """
+        pid = sample.sample_pid
+        try:
+            assert session
+            thread = session.find_thread(sample.sample_tid)
+            comm = thread.comm()
+        except Exception:
+            comm = f"unknown ({pid})"
+
+        period = sample.sample_period
+        self.value += period
+
+        node = self.find_or_create_node(comm)
+        node.value += period
+
+        if sample.callchain:
+            for entry in reversed(sample.callchain):
+                name = entry.symbol
+                if not name or name == "[unknown]":
+                    name = entry.dso or "unknown"
+                    if entry.ip:
+                        name += f" 0x{entry.ip:x}"
+                node = node.find_or_create_node(name)
+                node.value += period
+        else:
+            name = sample.symbol
+            if not name or name == "[unknown]":
+                name = sample.dso or "unknown"
+                if sample.sample_ip:
+                    name += f" 0x{sample.sample_ip:x}"
+            node = node.find_or_create_node(name)
+            node.value += period
+
+    def add_to_tree(self, node: TreeNode, root_value: int) -> None:
+        """Recursively adds this node and its children to a textual TreeNode.
+
+        Args:
+            node (TreeNode): The textual `TreeNode` object to which this
+                             ProfileNode should be added.
+            root_value (int): Value at the root of the tree.
+        """
+        if root_value == 0:
+            root_value = self.value
+
+        # Calculate the percentage for the node, highlighting the
+        # percentage with reversed colors.
+        if root_value != 0:
+            percent = self.value / root_value * 100
+            label = f"{self.name} [r]{percent:.3g}%[/]"
+        else:
+            label = self.name
+
+        # Add a standalone leaf.
+        if not self.children:
+            node.add_leaf(label)
+            return
+
+        # Recursively add children.
+        new_node = node.add(label)
+        for pnode in sorted(self.children.values(),
+                            key=lambda pnode: pnode.value, reverse=True):
+            pnode.add_to_tree(new_node, root_value)
+
+
+class ReportApp(App):
+    """A Textual application to display profiling data."""
+
+    # The ^q binding is implied but having it here adds it in the Footer.
+    BINDINGS = [
+        Binding(key="^q", action="quit", description="Quit",
+                tooltip="Quit the app"),
+    ]
+
+    def __init__(self, root: ProfileNode):
+        """Initialize the application."""
+        super().__init__()
+        self.root = root
+
+    def make_report_tree(self) -> Tree:
+        """Make a Tree widget from the profile data."""
+        tree: Tree[None] = Tree("Profile")
+        # Add events to tree skipping the root.
+        for pnode in sorted(self.root.children.values(),
+                            key=lambda node: node.value, reverse=True):
+            pnode.add_to_tree(tree.root, root_value=0)
+
+        # Expand the root tree (shows all events) and the largest of the children
+        # for each event.
+        def expand_first_child(tnode: TreeNode) -> None:
+            """Recursively expand the first child node"""
+            if not tnode.children:
+                return
+            first = tnode.children[0]
+            first.expand()
+            expand_first_child(first)
+        tree.root.expand()
+        for tnode in tree.root.children:
+            expand_first_child(tnode)
+
+        # If there is only one event, expand it also.
+        if len(tree.root.children) == 1:
+            tree.root.children[0].expand()
+
+        return tree
+
+    def compose(self) -> ComposeResult:
+        """Composes the user interface of the application."""
+        yield Header()
+        with TabbedContent(initial="report"):
+            with TabPane("Report", id="report"):
+                yield self.make_report_tree()
+        yield Footer()
+
+
+class ProfileBuilder:
+    """Constructs a profile tree from a stream of events."""
+    def __init__(self):
+        self.root = ProfileNode("root", parent=None)
+
+    def process_event(self, sample) -> None:
+        """Called by session.process_events to update the profile tree."""
+        ev_name = str(sample.evsel)[6:-1]
+        ev_root = self.root.find_or_create_node(ev_name)
+        ev_root.process_event(sample)
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="TUI report using perf python module.")
+    parser.add_argument("-i", "--input", help="input perf.data file")
+    args = parser.parse_args()
+
+    input_file = args.input or "perf.data"
+    if not os.path.exists(input_file):
+        print(f"Error: {input_file} not found. (try 'perf record' first)", file=sys.stderr)
+        sys.exit(1)
+
+    profile = ProfileBuilder()
+    try:
+        session = perf.session(perf.data(input_file), sample=profile.process_event)
+    except Exception as e:
+        print(f"Error opening session: {e}", file=sys.stderr)
+        sys.exit(1)
+
+    # profile.process_event is called for each perf event to build the profile.
+    session.process_events()
+
+    # Visualize data.
+    app = ReportApp(profile.root)
+    app.run()
-- 
2.55.0.654.g21b8a5bc05-goog
[PATCH v4 2/2] perf python: treport add flamegraph support
Posted by Ian Rogers 1 month, 3 weeks ago
From: Alice Rogers <alice.mei.rogers@gmail.com>

Implement a flamegraph widget that recursively walks down a tree
splitting line segments based on their value (summed up periods across
call chains). A visitor pattern is used so that the same logic can
both draw the line segments and locate which segment had a mouse
click.

Add a tab for the flame graph widget.

Co-developed-by: Ian Rogers <irogers@google.com>
Signed-off-by: Ian Rogers <irogers@google.com>
Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com>
---
 tools/perf/python/treport.py | 356 ++++++++++++++++++++++++++++++++++-
 1 file changed, 355 insertions(+), 1 deletion(-)

diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py
index 71a91775bd6a..43542599a884 100755
--- a/tools/perf/python/treport.py
+++ b/tools/perf/python/treport.py
@@ -1,19 +1,49 @@
 #!/usr/bin/env python3
 # SPDX-License-Identifier: MIT
 """treport.py - perf report like tool written using textual."""
+from abc import ABC, abstractmethod
 from typing import Dict, Optional
 import argparse
 import os
 import sys
 import perf
+from rich.segment import Segment
+from rich.style import Style
+from textual import events
 from textual.app import App, ComposeResult
 from textual.binding import Binding
+from textual.color import Color
+from textual.scroll_view import ScrollView
+from textual.strip import Strip
 from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree
 from textual.widgets.tree import TreeNode
 
 # Global session.
 session :Optional[perf.session] = None
 
+def make_fixed_length_string(s: str, length: int, pad_char=' '):
+    """Make the string s a fixed length.
+
+    Increases or decreases the length of s to be length. If the length is
+    increased then pad_char is inserted on the right.
+    """
+    return s[:length] if len(s) > length else s.ljust(length, pad_char)
+
+
+class FlameVisitor(ABC):
+    """Parent for visitor used by ProfileNode.flame_walk"""
+    @abstractmethod
+    def visit(self, node: Optional["ProfileNode"], width: int) -> None:
+        """Visit a profile node width the specified flame graph width.
+
+        Args:
+            node: The `ProfileNode` for the current segment. This may be `None`
+                to represent a gap or an unknown portion of the stack.
+            width: The calculated width of the flame graph rectangle for this
+                node, which is proportional to its sample count.
+        """
+
+
 class ProfileNode:
     """Represents a single node in a call stack tree.
 
@@ -120,6 +150,327 @@ class ProfileNode:
                             key=lambda pnode: pnode.value, reverse=True):
             pnode.add_to_tree(new_node, root_value)
 
+    def largest_child(self) -> "ProfileNode":
+        """Finds the child with the highest value (sample count)."""
+        if self.children:
+            return max(self.children.values(), key=lambda node: node.value)
+        return self
+
+    def child_after(self, sought: "ProfileNode") -> "ProfileNode":
+        """Finds the next sibling after the given node, sorted by value."""
+        found = False
+        for child in sorted(self.children.values(), key=lambda node: node.value,
+                            reverse=True):
+            if child == sought:
+                found = True
+            elif found:
+                return child
+        return sought
+
+    def child_before(self, sought: "ProfileNode") -> "ProfileNode":
+        """Finds the previous sibling before the given node, sorted by value."""
+        last = None
+        for child in sorted(self.children.values(), key=lambda node: node.value,
+                            reverse=True):
+            if child == sought:
+                return last if last else sought
+            last = child
+        return sought
+
+    def has_parent(self, parent: "ProfileNode") -> bool:
+        """Checks if the parent node is an ancestor of this node."""
+        p = self.parent
+        while True:
+            if p == parent:
+                return True
+            new_p = p.parent
+            if new_p == p:
+                break
+            p = new_p
+        return False
+
+    def has_child(self, sought: "ProfileNode") -> bool:
+        """Checks if the sought node is a descendant of this node."""
+        return sought.has_parent(self)
+
+    def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int,
+                   selected: "ProfileNode", visitor: FlameVisitor) -> None:
+        """Recursively walks the tree to visit a single flame graph row.
+
+        This method calculates the proportional width for each child
+        based on its value (sample count) relative to its parent. It
+        then invokes a `visitor` to process each segment of the flame
+        graph row.
+
+        Args:
+            wanted_strip (int): The target depth (Y-axis) of the flame graph row
+                                to generate.
+            cur_strip (int): The current depth of the traversal.
+            parent_width (int): The width of the parent of this node.
+            selected (ProfileNode): The currently selected node in the UI, used
+                                    to adjust rendering to highlight the
+                                    selected path.
+            visitor (FlameVisitor): A visitor object whose `visit` method is
+                                    called for each segment of the flame graph
+                                    row.
+        """
+        if parent_width == 0:
+            return
+
+        parent_selected = selected == self or self.has_parent(selected)
+        child_selected = not parent_selected and self.has_child(selected)
+        if not parent_selected and not child_selected:
+            # Branches of the tree with no node selected aren't drawn.
+            return
+
+        # left_over is used to check for a gap after the children due
+        # to samples being in the parent.
+        left_over = parent_width
+        for child in sorted(self.children.values(), key=lambda node: node.value,
+                            reverse=True):
+            if parent_selected:
+                if self.value:
+                    desired_width = int((parent_width * child.value) / self.value)
+                else:
+                    desired_width = parent_width // len(self.children)
+                if desired_width == 0:
+                    # Nothing can be drawn for this node or later smaller children.
+                    break
+            elif child == selected or child.has_child(selected):
+                desired_width = parent_width
+            else:
+                # A sibling or its child are selected, but not this branch.
+                continue
+
+            # Either visit the wanted_strip or recurse to the next level.
+            if wanted_strip == cur_strip:
+                visitor.visit(child, desired_width)
+            else:
+                child.flame_walk(wanted_strip, cur_strip + 1, desired_width,
+                                 selected, visitor)
+            left_over -= desired_width
+            if left_over == 0:
+                # No space left to draw in.
+                break
+
+        # Always visit the left_over regardless of the wanted_strip as there
+        # may be additional gap added to a line by a parent.
+        if left_over:
+            visitor.visit(None, left_over)
+
+    def make_flame_strip(self, wanted_strip: int, parent_width: int,
+                         cursor: "ProfileNode", selected: "ProfileNode",
+                         theme_variables: Dict[str, str]) -> Strip:
+        """Creates a renderable 'Strip' for a single row of a flame graph.
+
+        This method orchestrates the `flame_walk` traversal with a specialized
+        visitor to generate a list of segments. The segments are used by a`Strip`
+        object for rendering in the terminal.
+
+        Args:
+            wanted_strip (int): The target depth (Y-axis) of the flame graph row.
+            parent_width (int): The total width (in characters) of the display
+                                area.
+            cursor (ProfileNode): The node currently under the cursor, for
+                                  highlighting.
+            selected (ProfileNode): The node that is actively selected.
+            theme_variables(Dict): Values of colors for the textual theme.
+
+        Returns:
+            Strip: A renderable strip of segments for the specified row.
+        """
+        primary = Color.parse(theme_variables["primary"])
+        secondary = Color.parse(theme_variables["secondary"])
+        surface = Color.parse(theme_variables["surface"])
+        def luminance(color: Color) -> float:
+            """Computes the luminance of a color from the rgb"""
+            return color.r * 0.299 + color.g * 0.587 + color.b * 0.114
+
+        # Set of styles for different flamegraph segments, the styles are
+        # cycled through to provide contrast.
+        normal_styles = []
+        for x in range(0, 125, 25):
+            fgcolor = secondary.blend(primary, x/100)
+            if luminance(fgcolor) > luminance(surface):
+                bgcolor = surface.lighten(0.05+x/500)
+            else:
+                bgcolor = surface.darken(0.05+x/500)
+            normal_styles.append(Style(color=fgcolor.rich_color,
+                                       bgcolor=bgcolor.rich_color))
+
+        # Style for the selected flame graph node.
+        accent = Color.parse(theme_variables["accent"])
+        accent_muted = Color.parse(theme_variables["accent-muted"])
+        cursor_style = Style(color=accent.rich_color, bgcolor=accent_muted.rich_color)
+
+        class StripVisitor(FlameVisitor):
+            """Visitor creating textual flame graph segments.
+
+            Attributes:
+                segments (list): The textual segments that will be placed in a
+                                 `Strip`.
+                gap_width (int): The width of any outstanding gap between the
+                                 last and next node.
+                ctr (int): Used to adjust the flame graph segment's color.
+            """
+            def __init__(self):
+                self.segments = []
+                self.gap_width = 0
+                self.ctr = wanted_strip
+
+            def visit(self, node: Optional[ProfileNode], width: int) -> None:
+                if node:
+                    if self.gap_width > 0:
+                        self.segments.append(Segment(
+                            make_fixed_length_string(" ", self.gap_width)))
+                        self.gap_width = 0
+                    style = cursor_style
+                    if node != cursor:
+                        style = normal_styles[self.ctr % len(normal_styles)]
+                    self.segments.append(Segment(
+                        make_fixed_length_string(node.name, width), style))
+                else:
+                    self.gap_width += width
+                self.ctr += 1
+
+        visitor = StripVisitor()
+        self.flame_walk(wanted_strip, 0, parent_width, selected, visitor)
+        return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width)
+
+    def find_node(self, sought_x: int, sought_y: int, parent_width: int,
+                  selected: "ProfileNode") -> "ProfileNode":
+        """Finds the ProfileNode corresponding to specific X, Y coordinates.
+
+        This translates a mouse click on a flame graph back to the
+        `ProfileNode` that it represents.
+
+        Args:
+            sought_x (int): The X coordinate (character column).
+            sought_y (int): The Y coordinate (row or depth).
+            parent_width (int): The total width of the display area.
+            selected (ProfileNode): The currently selected node, which affects
+                                    layout.
+
+        Returns:
+            Optional[ProfileNode]: The node found at the coordinates, or None.
+
+        """
+        class FindVisitor(FlameVisitor):
+            """Visitor locating a `ProfileNode`.
+
+            Attributes:
+                x (int): offset within line.
+                found (Optional[ProfileNode]): located node
+                gap_width (int): The width of any outstanding gap between the
+                                 last and next node.
+                ctr (int): Used to adjust the flame graph segment's color.
+            """
+            def __init__(self):
+                self.x = 0
+                self.found = None
+
+            def visit(self, node: Optional[ProfileNode], width: int) -> None:
+                if self.x <= sought_x < self.x + width:
+                    self.found = node
+                self.x += width
+
+        visitor = FindVisitor()
+        self.flame_walk(sought_y, 0, parent_width, selected, visitor)
+        return visitor.found
+
+
+class FlameGraph(ScrollView):
+    """A scrollable widget to display a flame graph from a profile.
+
+    Attributes:
+        root (ProfileNode): Root of the profile tree.
+        cursor (ProfileNode): Currently highlighted cursor node.
+        selected (ProfileNode): The currently selected node for zooming.
+    """
+
+    # Define key bindings for navigating the flame graph.
+    # Allows movement with vim-style keys (h,j,k,l) and arrow keys.
+    BINDINGS = [
+        Binding("j,down", "move_down", "Down", key_display="↓",
+                tooltip="Move cursor down to largest child"),
+        Binding("k,up", "move_up", "Up", key_display="↑",
+                tooltip="Move cursor up to parent"),
+        Binding("l,right", "move_right", "Right", key_display="→",
+                tooltip="Move cursor to the right sibling"),
+        Binding("h,left", "move_left", "Left", key_display="←",
+                tooltip="Move cursor to the left sibling"),
+        Binding("enter", "zoom_in", "Zoom In",
+                tooltip="Expand the cursor's node to be screen width"),
+        Binding("escape", "zoom_out", "Zoom Out",
+                tooltip="Zoom out to initial view."),
+     ]
+
+    # Default CSS for the widget to ensure it fills its container's width.
+    DEFAULT_CSS = """
+    FlameGraph {
+        width: 100%;
+    }
+    """
+
+    def __init__(self, root: ProfileNode, *args, **kwargs):
+        """Initialize the FlameGraph widget."""
+        super().__init__(*args, **kwargs)
+        self.root = root
+        self.cursor = root
+        self.selected = root
+
+    def action_move_down(self) -> None:
+        """Handle key press down."""
+        self.cursor = self.cursor.largest_child()
+        self.refresh()
+
+    def action_move_up(self) -> None:
+        """Handle key press up."""
+        if self.cursor.parent != self.cursor.parent.parent:
+            self.cursor = self.cursor.parent
+            self.refresh()
+
+    def action_move_right(self) -> None:
+        """Handle key press right."""
+        self.cursor = self.cursor.parent.child_after(self.cursor)
+        self.refresh()
+
+    def action_move_left(self) -> None:
+        """Handle key press left."""
+        self.cursor = self.cursor.parent.child_before(self.cursor)
+        self.refresh()
+
+    def action_zoom_in(self) -> None:
+        """Handle key press zoom in."""
+        self.selected = self.cursor
+        self.refresh()
+
+    def action_zoom_out(self) -> None:
+        """Handle key press zoom out."""
+        self.selected = self.root
+        self.refresh()
+
+    def render_line(self, y: int) -> Strip:
+        """Render a single line (row) of the flame graph."""
+        _, scroll_y = self.scroll_offset
+        y += scroll_y
+        return self.root.make_flame_strip(y, self.size.width, self.cursor,
+                                          self.selected, self.app.theme_variables)
+
+    def on_mount(self) -> None:
+        """Set the height of the widget when it is displayed."""
+        self.styles.height = self.root.depth()
+
+    def on_click(self, click: events.Click) -> None:
+        """Handles a mouse click and update the cursor position."""
+        _, scroll_y = self.scroll_offset
+        y = scroll_y + click.y
+        clicked_node = self.root.find_node(click.x, y, self.size.width,
+                                           self.selected)
+        if clicked_node:
+            self.cursor = clicked_node
+            self.refresh()
+
 
 class ReportApp(App):
     """A Textual application to display profiling data."""
@@ -168,6 +519,8 @@ class ReportApp(App):
         with TabbedContent(initial="report"):
             with TabPane("Report", id="report"):
                 yield self.make_report_tree()
+            with TabPane("Flame Graph", id="flame"):
+                yield FlameGraph(self.root)
         yield Footer()
 
 
@@ -183,7 +536,8 @@ class ProfileBuilder:
         ev_root.process_event(sample)
 
 if __name__ == "__main__":
-    parser = argparse.ArgumentParser(description="TUI report using perf python module.")
+    parser = argparse.ArgumentParser(
+        description="TUI report and flame graph using perf python module.")
     parser.add_argument("-i", "--input", help="input perf.data file")
     args = parser.parse_args()
 
-- 
2.55.0.654.g21b8a5bc05-goog