diff --git a/Project.toml b/Project.toml
index d8c7819..1cae511 100644
--- a/Project.toml
+++ b/Project.toml
@@ -12,6 +12,7 @@ FlameGraphs = "08572546-2f56-4bcf-ba4e-bab62c3a3f89"
Graphics = "a2bd30eb-e257-5431-a919-1863eab51364"
Gtk4 = "9db2cae5-386f-4011-9d63-a5602296539b"
GtkObservables = "8710efd8-4ad6-11eb-33ea-2d5ceb25a41c"
+IOCapture = "b5f81e59-6552-4d32-b1f0-c071b021bf89"
InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953"
MethodAnalysis = "85b6ec6f-f7df-4429-9514-a64bcd9ee824"
@@ -36,6 +37,7 @@ FlameGraphs = "0.2.10, 1"
Graphics = "0.4, 1"
Gtk4 = "0.7.12"
GtkObservables = "2"
+IOCapture = "0.2.5"
InteractiveUtils = "1"
IntervalSets = "0.2, 0.3, 0.4, 0.5, 0.6, 0.7"
MethodAnalysis = "0.4, 1"
diff --git a/src/ProfileView.jl b/src/ProfileView.jl
index 9a550a6..ce8eedf 100644
--- a/src/ProfileView.jl
+++ b/src/ProfileView.jl
@@ -16,11 +16,12 @@ import GtkObservables: Canvas
import Cairo
using Graphics
using Preferences
+using IOCapture
using FlameGraphs: Node, NodeData
const CONTROL = Gtk4.ModifierType_CONTROL_MASK
-export @profview, warntype_clicked, descend_clicked, ascend_clicked
+export @profview, warntype_clicked, descend_clicked, ascend_clicked, view_tree
@deprecate warntype_last warntype_clicked
const clicked = Ref{Any}(nothing) # for getting access to the clicked bar
@@ -657,6 +658,40 @@ end
discardfirstcol(A) = A[:,2:end]
discardfirstcol(A::IndirectArray) = IndirectArray(A.index[:,2:end], A.values)
+include("treeview.jl")
+using .TreeView: show_profile_tree
+
+"""
+ view_tree([data]; kwargs...)
+
+View profiling results in a collapsible tree view format, similar to how
+`Profile.print` displays the data. This provides a hierarchical view that
+can be expanded and collapsed interactively.
+
+# Arguments
+- `data`: Optional profile data from `Profile.fetch()`. If not provided, will use the current profile data.
+
+# Keyword Arguments
+- All keyword arguments accepted by `Profile.print` including:
+ - `lidict`: Line info dictionary from `Profile.retrieve()`
+ - `C`: If true, includes C functions in the output
+ - `recur`: How to handle recursion (:off, :flat, or :nested)
+ - `noisefloor`: Hide functions with counts below this threshold
+ - `windowname`: Name for the tree view window. Default is "Profile Tree View".
+
+# Examples
+```julia
+# View current profile data
+view_tree()
+
+# View with specific options
+view_tree(C=true, noisefloor=2)
+```
+"""
+function view_tree(data::Vector{UInt64}=Profile.fetch(); lidict=nothing, kwargs...)
+ return show_profile_tree(data; lidict=lidict, kwargs...)
+end
+
function __init__()
Base.Experimental.register_error_hint(MethodError) do io, exc, argtypes, kwargs
if (exc.f === descend_clicked || exc.f === ascend_clicked) && isempty(argtypes)
diff --git a/src/treeview.jl b/src/treeview.jl
new file mode 100644
index 0000000..eaa69b6
--- /dev/null
+++ b/src/treeview.jl
@@ -0,0 +1,411 @@
+module TreeView
+
+using Gtk4
+using Profile
+using IOCapture
+
+using ..ProfileView
+
+struct Node
+ label::String
+ children::Vector{Node}
+ url::String
+end
+Node(label) = Node(label, Node[], "")
+
+# Helper for converting ANSI SGR codes to Pango markup
+const PANGO_COLOR_MAP = Dict(
+ # Standard FG
+ 30 => "black", 31 => "red", 32 => "green", 33 => "yellow",
+ 34 => "blue", 35 => "magenta", 36 => "cyan", 37 => "lightgray", # ANSI 'white' often maps to light gray
+ 39 => :default_fg, # Special marker for default foreground color
+
+ # Bright FG
+ 90 => "darkgray", 91 => "red", 92 => "green", 93 => "yellow", # Pango might not have "lightyellow", use "yellow"
+ 94 => "blue", 95 => "magenta", 96 => "cyan", 97 => "white" # ANSI 'light_white' often maps to pure white
+)
+
+function ansi_to_pango(s::String)
+ buffer = IOBuffer()
+ last_idx = 1
+ open_tags = String[] # Stack of Pango closing tags
+ active_styles = Dict{Int,String}() # Map SGR codes to their Pango closing tags
+
+ # Regex to find SGR escape sequences: \e[ followed by params (digits and semicolons) followed by m
+ sgr_regex = r"\x1B\[([0-9;]*)m"
+
+ for m in eachmatch(sgr_regex, s)
+ # Write text before this match, escaping Pango special characters
+ if m.offset > last_idx
+ segment = s[last_idx : m.offset-1]
+ write(buffer, Gtk4.GLib.G_.markup_escape_text(segment, ncodeunits(segment)))
+ end
+
+ params_str = m.captures[1]
+ local params = if isempty(params_str)
+ [0] # Reset
+ else
+ # Split params string (e.g., "1;31") and parse to integers
+ parsed_params = filter(!isempty, split(params_str, ';'))
+ isempty(parsed_params) ? [0] : parse.(Int, parsed_params)
+ end
+
+ for p in params
+ if p == 0 # Reset all attributes
+ # Close all open tags in reverse order
+ while !isempty(open_tags)
+ write(buffer, pop!(open_tags))
+ end
+ empty!(active_styles)
+ elseif p == 1 && !haskey(active_styles, p) # Bold
+ write(buffer, "")
+ push!(open_tags, "")
+ active_styles[p] = ""
+ elseif p == 22 && haskey(active_styles, 1) # Normal intensity (undo bold)
+ # Find and remove bold tag
+ idx = findlast(==(active_styles[1]), open_tags)
+ if idx !== nothing
+ # Close all tags after and including bold, then reopen the ones after
+ for i in length(open_tags):-1:idx
+ write(buffer, open_tags[i])
+ end
+ deleteat!(open_tags, idx)
+ for i in idx:length(open_tags)
+ write(buffer, replace(open_tags[i], r"" => s->s[2:end-1]))
+ end
+ delete!(active_styles, 1)
+ end
+ elseif (p >= 30 && p <= 37) || (p >= 90 && p <= 97) # Foreground colors
+ # Close any existing foreground color
+ for code in keys(active_styles)
+ if (code >= 30 && code <= 37) || (code >= 90 && code <= 97)
+ idx = findlast(==(""), open_tags)
+ if idx !== nothing
+ # Same process as with bold - close and reopen spanning tags
+ for i in length(open_tags):-1:idx
+ write(buffer, open_tags[i])
+ end
+ deleteat!(open_tags, idx)
+ for i in idx:length(open_tags)
+ write(buffer, replace(open_tags[i], r"" => s->s[2:end-1]))
+ end
+ delete!(active_styles, code)
+ end
+ break
+ end
+ end
+ # Apply new color
+ if (color_name = get(PANGO_COLOR_MAP, p, nothing)) !== nothing
+ write(buffer, "")
+ push!(open_tags, "")
+ active_styles[p] = ""
+ end
+ elseif p == 39 # Default foreground color
+ # Remove any active foreground color
+ for code in keys(active_styles)
+ if (code >= 30 && code <= 37) || (code >= 90 && code <= 97)
+ idx = findlast(==(""), open_tags)
+ if idx !== nothing
+ for i in length(open_tags):-1:idx
+ write(buffer, open_tags[i])
+ end
+ deleteat!(open_tags, idx)
+ for i in idx:length(open_tags)
+ write(buffer, replace(open_tags[i], r"" => s->s[2:end-1]))
+ end
+ delete!(active_styles, code)
+ end
+ break
+ end
+ end
+ end
+ end
+ last_idx = m.offset + length(m.match)
+ end
+
+ # Write any remaining text after the last SGR sequence
+ if last_idx <= ncodeunits(s)
+ segment = s[last_idx : end]
+ write(buffer, Gtk4.GLib.G_.markup_escape_text(segment, ncodeunits(segment)))
+ end
+
+ # Close any remaining tags in reverse order
+ for tag in reverse(open_tags)
+ write(buffer, tag)
+ end
+
+ return String(take!(buffer))
+end
+
+"""
+ remove_ansi_links(s::String) -> String
+
+Remove ANSI OSC 8 hyperlink escape sequences from a string, keeping the link text.
+"""
+function remove_ansi_links(s::String)
+ # OSC 8 hyperlink format: \e]8;PARAMS;URL\e\\ (String Terminator ST)
+ # This function removes the link sequences, keeping the display text.
+ # It specifically matches ST (\x1B\x5C). For more details on OSC 8:
+ # https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
+
+ # Remove the main hyperlink sequence (link part):
+ s = replace(s, r"\x1B]8;[^;\x1B\x5C]*;[^\x1B\x5C]*\x1B\x5C" => "")
+
+ # Remove the hyperlink text termination sequence:
+ s = replace(s, r"\x1B]8;;\x1B\x5C" => "")
+ return s
+end
+
+"""
+ parse_url_from_ansi_link(s::String)
+
+Extract the URL from an ANSI OSC 8 hyperlink escape sequence.
+"""
+function parse_url_from_ansi_link(s::String)
+ # Extract URL from OSC 8 hyperlink: \e]8;params;URL\e\\
+ m = match(r"\x1B]8;[^;\x1B\x5C]*;([^\x1B\x5C]*)\x1B\x5C", s)
+ return m === nothing ? "" : m.captures[1]
+end
+
+"""
+ parse_profile_output(io::IO)
+
+Parse the text output from Profile.print into a tree structure.
+"""
+function parse_profile_output(io::IO)
+ root = Node("Profile")
+ stack = [root]
+
+ offset = 0
+
+ for ln in eachline(io)
+ # Skip common header or separator lines
+ occursin("===", ln) && continue
+ occursin("Overhead ╎", ln) && continue
+ isempty(strip(ln)) && continue
+ ln = collect(ln)
+
+ seen_spacer = false
+ depth = 0
+ for c in ln
+ if c == ' '
+ elseif c == '╎'
+ seen_spacer = true
+ else
+ seen_spacer && break
+ end
+ depth += textwidth(c)
+ end
+ if offset == 0
+ offset = depth - 1
+ end
+
+ label = String(ln[depth+1:end])
+ url = parse_url_from_ansi_link(label)
+ label = remove_ansi_links(label) # Remove OSC 8 link escape codes
+
+ while length(stack) > depth - offset
+ pop!(stack)
+ end
+
+ # Create node with all fields initialized
+ node = Node(label, Node[], url)
+ push!(stack[end].children, node)
+ push!(stack, node)
+ end
+ return root
+end
+
+"""
+ to_treestore(root::Node)
+
+Convert a Node tree to a GtkTreeStore.
+"""
+function to_treestore(root::Node)
+ # Create tree store with two columns: label (String) and URL (String)
+ ts = GtkTreeStore(String, String)
+ _fill!(ts, nothing, root)
+ return ts
+end
+
+function _fill!(ts, parent_iter, node::Node)
+ # Store both label and URL in the tree store
+ iter = Gtk4.push!(ts, (ansi_to_pango(node.label), node.url), parent_iter)
+ foreach(child -> _fill!(ts, iter, child), node.children)
+end
+
+# Helper function to open URL for a given iterator
+function _open_url_for_iter(model, iter)
+ label, url = model[iter]
+ if !isempty(url)
+ try
+ if Sys.isapple()
+ run(`open $url`)
+ elseif Sys.iswindows()
+ run(`cmd /c start $url`)
+ elseif Sys.islinux()
+ run(`xdg-open $url`)
+ end
+ @info "Opened URL: $url"
+ catch e
+ @warn "Failed to open URL: $url" exception=e
+ end
+ return true # URL was processed
+ end
+ return false # No URL to process
+end
+
+## Broken Gtk4 workarounds
+function _iter(treeModel::Gtk4.GtkTreeStoreLeaf, path::Gtk4.GtkTreePath)
+ it = Ref{Gtk4._GtkTreeIter}()
+ ret = ccall((:gtk_tree_model_get_iter, Gtk4.libgtk4), Cint, (Ptr{Gtk4.GObject}, Ptr{Gtk4._GtkTreeIter}, Ptr{Gtk4.GtkTreePath}),
+ treeModel, it, path) != 0
+ ret, it[]
+end
+
+_path(treeModel, iter) = Gtk4.GtkTreePath(ccall((:gtk_tree_model_get_path, Gtk4.libgtk4), Ptr{Gtk4.GtkTreePath},
+ (Ptr{Gtk4.GObject}, Ref{Gtk4._GtkTreeIter}),
+ treeModel, iter))
+
+
+##
+
+const TREE_CSS = """
+.tree-dark,
+.tree-dark treeview {
+ background-color: #2d2d2d;
+ color: #ffffff;
+}
+
+.tree-dark treeview:hover,
+.tree-dark treeview row:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.tree-dark treeview:selected,
+.tree-dark treeview row:selected {
+ color: #ffffff;
+ background-color: #215d9c;
+}
+
+.tree-dark treeview:selected:backdrop,
+.tree-dark treeview row:selected:backdrop {
+ color: #d0d0d0;
+ background-color: #1a4a7d;
+}
+
+treeview.tree-dark row expander,
+treeview.tree-dark row .indent { /* .indent might be specific; expander is standard */
+ min-width: 2px; /* default ≈18 px */
+}
+"""
+
+"""
+ build_treeview(model::GtkTreeStore)
+
+Create a GtkTreeView from a GtkTreeStore.
+"""
+function build_treeview(model::GtkTreeStore)
+ view = GtkTreeView(; model=model)
+ r = GtkCellRendererText()
+ c = GtkTreeViewColumn("Call stack", r, Dict([(:markup, 0)]))
+
+ # Apply dark theme styling with high priority (GTK_STYLE_PROVIDER_PRIORITY_APPLICATION = 600)
+ provider = GtkCssProvider(TREE_CSS)
+ sc = Gtk4.style_context(view)
+ push!(sc, provider, 600)
+ add_css_class(view, "tree-dark")
+
+ Gtk4.G_.set_resizable(c, true)
+ Gtk4.G_.set_expand(c, true)
+ push!(view, c)
+ Gtk4.G_.set_expander_column(view, c)
+
+ # Add click handler
+ click = GtkGestureClick(view)
+ signal_connect(click, "released") do controller, npress, x, y
+ # Get path at clicked coordinates
+ path_info = Gtk4.G_.get_path_at_pos(view, round(Int, x), round(Int, y))
+ if path_info !== nothing
+ path = path_info[2]
+ # cell_x, cell_y = path_info[end-1:end] # Not used currently
+ if path !== nothing
+ is_valid, iter = Gtk4.G_.get_iter_from_string(model, Gtk4.G_.to_string(path))
+ if is_valid
+ _open_url_for_iter(model, iter)
+ # Potentially return Gtk4.EVENT_STOP if _open_url_for_iter indicates success
+ end
+ end
+ end
+ end
+
+ # Add key press handler
+ key_controller = GtkEventControllerKey(view)
+ signal_connect(key_controller, "key-pressed") do controller, keyval, keycode, state
+ selection = Gtk4.selection(view)
+ if Gtk4.hasselection(selection)
+ iter = Gtk4.selected(selection)
+ if keyval == Gtk4.KEY_space
+ path = _path(model, iter)
+ if Gtk4.G_.row_expanded(view, path)
+ Gtk4.G_.collapse_row(view, path)
+ else
+ Gtk4.G_.expand_row(view, path, true) # Old direct expansion
+ end
+ return Gtk4.EVENT_STOP # Indicate event handled
+ elseif keyval == Gtk4.KEY_Return
+ if _open_url_for_iter(model, iter)
+ return Gtk4.EVENT_STOP # Indicate event handled
+ end
+ end
+ end
+ return Gtk4.EVENT_PROPAGATE # Allow other handlers
+ end
+
+ Gtk4.G_.expand_all(view) # Expand all nodes by default
+ Gtk4.G_.set_level_indentation(view, 0)
+ return view
+end
+
+"""
+ show_profile_tree(data::Vector{UInt64}=Profile.fetch(); lidict=nothing, windowname="Profile Tree View", kwargs...)
+
+Display the output of Profile.print in a collapsible tree view.
+"""
+function show_profile_tree(data::Vector{UInt64}=Profile.fetch(); lidict=nothing, windowname="Profile Tree View", kwargs...)
+ # Capture Profile.print output
+ captured = IOCapture.capture(color=true, io_context = [:displaysize=>(1000,1000)]) do
+ if lidict === nothing
+ Profile.print(data; kwargs...)
+ else
+ Profile.print(data, lidict; kwargs...)
+ end
+ end
+
+ io = IOBuffer(captured.output)
+
+ # Parse and create tree
+ root = parse_profile_output(io)
+ model = to_treestore(root)
+ view = build_treeview(model)
+
+ # Create window with tree view
+ scroll = GtkScrolledWindow(; hexpand=true, vexpand=true)
+ scroll[] = view
+
+ win = GtkWindow(windowname, 900, 600)
+ win[] = scroll
+
+ # Register the window with the window registry so it's closed with ProfileView.closeall()
+ ProfileView.window_wrefs[win] = nothing
+
+ # Add keyboard shortcuts for closing (Ctrl-q and Ctrl-w)
+ kc = GtkEventControllerKey(win)
+ signal_connect(ProfileView.close_cb, kc, "key-pressed", Cint, (UInt32, UInt32, UInt32), false, (win))
+
+ # Show the window
+ Gtk4.show(win)
+ return win
+end
+
+end # module TreeView