from typing import Any

import customtkinter as ctk


class ControlPanel(ctk.CTkFrame):
    # See: https://en.wikipedia.org/wiki/Media_control_symbols
    record_sym: str = "\u23fa"
    back_sym: str = "\u23ea"
    forward_sym: str = "\u23e9"
    end_sym: str = "\u23ed"
    recording_color: str = "#EE4B2B"
    paused_color: str = "#FFFFFF"

    def __init__(self, master) -> None:
        super().__init__(master)

        self.profiler: Any = master

        self.do_record: bool = False
        self.sel_frame: int = 0

        self.grid_columnconfigure((0, 3), weight=0)
        self.grid_columnconfigure(4, weight=1)
        self.grid_columnconfigure(5, weight=0)

        self.record_btn: ctk.CTkButton = ctk.CTkButton(self, text=ControlPanel.record_sym, width=20,
                                                       text_color=ControlPanel.paused_color,
                                                       fg_color="transparent",
                                                       hover=False,
                                                       command=self.on_record_btn_clicked)
        self.record_btn.grid(row=0, column=0)

        self.prev_frame_btn: ctk.CTkButton = ctk.CTkButton(self, text=ControlPanel.back_sym, width=20,
                                                           fg_color="transparent",
                                                           hover=False,
                                                           command=self.on_prev_frame_btn_clicked)
        self.prev_frame_btn.grid(row=0, column=1)

        self.next_frame_btn: ctk.CTkButton = ctk.CTkButton(self, text=ControlPanel.forward_sym, width=20,
                                                           fg_color="transparent",
                                                           hover=False,
                                                           command=self.on_next_frame_btn_clicked)
        self.next_frame_btn.grid(row=0, column=2)

        self.cur_frame_btn: ctk.CTkButton = ctk.CTkButton(self, text=ControlPanel.end_sym, width=20,
                                                          fg_color="transparent",
                                                          hover=False,
                                                          command=self.on_cur_frame_btn_clicked)
        self.cur_frame_btn.grid(row=0, column=3)

        self.frame_details_label: ctk.CTkLabel = ctk.CTkLabel(self, text="Frame: -/-", width=150)
        self.frame_details_label.grid(row=0, padx=5, column=4)

        self.clear_frames_btn: ctk.CTkButton = ctk.CTkButton(self, text="clear", width=20,
                                                             fg_color="transparent",
                                                             hover=False,
                                                             command=self.on_clear_frames_btn_clicked)
        self.clear_frames_btn.grid(row=0, column=5)

        self.prev_frame_btn_enabled: bool = True
        self.next_frame_btn_enabled: bool = True
        self.cur_frame_btn_enabled: bool = True
        self.clear_frames_btn_enabled: bool = True

        # Start with buttons disabled.
        self.disable_prev_frame_btn()
        self.disable_next_frame_btn()
        self.disable_cur_frame_btn()
        self.disable_clear_frames_btn()

    def on_record_btn_clicked(self) -> None:
        self.do_record = not self.do_record
        if self.do_record:
            self.record_btn.configure(text_color=ControlPanel.recording_color)
            self.profiler.record()
        else:
            self.record_btn.configure(text_color=ControlPanel.paused_color)
            self.profiler.pause()

    def on_prev_frame_btn_clicked(self) -> None:
        sel_frame, cur_frame = self.profiler.prev_frame()
        if sel_frame > 0 and cur_frame > 0:
            self.set_frame_details(sel_frame, cur_frame)
            self.update_playback_btn_states(self.sel_frame, cur_frame)

    def on_next_frame_btn_clicked(self) -> None:
        sel_frame, cur_frame = self.profiler.next_frame()
        if sel_frame > 0 and cur_frame > 0:
            self.set_frame_details(sel_frame, cur_frame)
            self.update_playback_btn_states(self.sel_frame, cur_frame)

    def on_cur_frame_btn_clicked(self) -> None:
        sel_frame, cur_frame = self.profiler.cur_frame()
        if sel_frame > 0 and cur_frame > 0:
            self.set_frame_details(sel_frame, cur_frame)
            self.update_playback_btn_states(sel_frame, cur_frame)

    def on_frame_selected_from_chart(self, frame_num: int) -> None:
        sel_frame, cur_frame = self.profiler.goto_frame(frame_num)
        self.set_frame_details(sel_frame, cur_frame)

        # Pause frame chart animation.
        self.do_record = False
        self.record_btn.configure(text_color=ControlPanel.paused_color)
        self.profiler.pause()

    def on_clear_frames_btn_clicked(self) -> None:
        self.profiler.clear_frames()
        self.set_frame_details(0, 0)
        self.update_playback_btn_states(0, 0)

    def on_cur_frame_num_changed(self, cur_frame: int) -> None:
        if cur_frame == self.sel_frame:
            return

        self.set_frame_details(self.sel_frame, cur_frame)
        self.update_playback_btn_states(self.sel_frame, cur_frame)

    def set_frame_details(self, sel_frame: int, cur_frame: int) -> None:
        self.sel_frame = sel_frame

        if sel_frame == 0:
            if cur_frame == 0:
                self.frame_details_label.configure(text=f"Frame: -/-")
            else:
                self.frame_details_label.configure(text=f"Frame: -/{cur_frame}")
        else:
            self.frame_details_label.configure(text=f"Frame: {self.sel_frame}/{cur_frame}")

    def update_playback_btn_states(self, sel_frame: int, cur_frame: int) -> None:
        if sel_frame == 1:
            self.disable_prev_frame_btn()
        else:
            self.enable_prev_frame_btn()

        if sel_frame == cur_frame:
            self.disable_next_frame_btn()
            self.disable_cur_frame_btn()
        else:
            self.enable_next_frame_btn()
            self.enable_cur_frame_btn()

        if cur_frame == 0:
            self.disable_prev_frame_btn()
            self.disable_next_frame_btn()
            self.disable_cur_frame_btn()
            self.disable_clear_frames_btn()
        else:
            self.enable_clear_frames_btn()

    def disable_prev_frame_btn(self) -> None:
        if self.prev_frame_btn_enabled:
            self.prev_frame_btn.configure(state="disabled")
            self.prev_frame_btn_enabled = False

    def enable_prev_frame_btn(self) -> None:
        if not self.prev_frame_btn_enabled:
            self.prev_frame_btn.configure(state="normal")
            self.prev_frame_btn_enabled = True

    def disable_next_frame_btn(self) -> None:
        if self.next_frame_btn_enabled:
            self.next_frame_btn.configure(state="disabled")
            self.next_frame_btn_enabled = False

    def enable_next_frame_btn(self) -> None:
        if not self.next_frame_btn_enabled:
            self.next_frame_btn.configure(state="normal")
            self.next_frame_btn_enabled = True

    def disable_cur_frame_btn(self) -> None:
        if self.cur_frame_btn_enabled:
            self.cur_frame_btn.configure(state="disabled")
            self.cur_frame_btn_enabled = False

    def enable_cur_frame_btn(self) -> None:
        if not self.cur_frame_btn_enabled:
            self.cur_frame_btn.configure(state="normal")
            self.cur_frame_btn_enabled = True

    def disable_clear_frames_btn(self) -> None:
        if self.clear_frames_btn_enabled:
            self.clear_frames_btn.configure(state="disabled")
            self.clear_frames_btn_enabled = False

    def enable_clear_frames_btn(self) -> None:
        if not self.clear_frames_btn_enabled:
            self.clear_frames_btn.configure(state="normal")
            self.clear_frames_btn_enabled = True

UdonProfilerApp/Src/ControlPanel.py
bSenpai/UdonProfiler
import customtkinter as ctk

from Src.HierarchyView import HierarchyView
from Src.TimelineView import TimelineView
from Src.StatisticsView import StatisticsView
from Src.FrameInfo import FrameInfo


class DetailsPanel(ctk.CTkFrame):
    def __init__(self, master) -> None:
        super().__init__(master)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=0)
        self.grid_rowconfigure(1, weight=1)

        title: str = "Details Panel"
        self.title: ctk.CTkLabel = ctk.CTkLabel(self, text=title, fg_color="gray30", corner_radius=6)
        self.title.grid(row=0, column=0, sticky="ew")

        self.tabview: ctk.CTkTabview = ctk.CTkTabview(self)
        self.tabview.grid(row=1, column=0, sticky="nsew")

        self.hierarchy_tabview: ctk.CTkFrame = self.tabview.add("Hierarchy View")
        self.hierarchy_tabview.grid_columnconfigure(0, weight=1)
        self.hierarchy_tabview.grid_rowconfigure(0, weight=1)

        self.timeline_tabview: ctk.CTkFrame = self.tabview.add("Timeline View")
        self.timeline_tabview.grid_columnconfigure(0, weight=1)
        self.timeline_tabview.grid_rowconfigure(0, weight=1)

        self.statistics_tabview: ctk.CTkFrame = self.tabview.add("Statistics View")
        self.statistics_tabview.grid_columnconfigure(0, weight=1)
        self.statistics_tabview.grid_rowconfigure(0, weight=1)

        self.tabview.set("Hierarchy View")

        self.hierarchy_view: HierarchyView = HierarchyView(master=self.hierarchy_tabview)
        self.hierarchy_view.grid(row=0, column=0, sticky="nsew")

        self.timeline_view: TimelineView = TimelineView(master=self.timeline_tabview)
        self.timeline_view.grid(row=0, column=0, stick="nsew")

        self.statistics_view: StatisticsView = StatisticsView(master=self.statistics_tabview)
        self.statistics_view.grid(row=0, column=0, sticky="nsew")

    def on_frame_info_received(self, frame_info: FrameInfo, selected: bool = False) -> None:
        if selected:
            self.hierarchy_view.on_frame_info_received(frame_info)
            self.timeline_view.on_frame_info_received(frame_info)

        self.statistics_view.on_frame_info_received(frame_info)

    def on_frame_info_cleared(self) -> None:
        self.hierarchy_view.on_frame_info_cleared()
        self.timeline_view.on_frame_info_cleared()
        self.statistics_view.on_frame_info_cleared()

UdonProfilerApp/Src/DetailsPanel.py
bSenpai/UdonProfiler
from collections import deque
from typing import Any, Dict, Iterable, List, Tuple

import customtkinter as ctk
import numpy as np

from Widgets.AnimatedLinePlot.AnimatedLinePlotWidget import AnimatedLinePlotWidget
from Src.FrameInfo import FrameInfo


class FrameChart(ctk.CTkFrame):
    def __init__(self, master) -> None:
        super().__init__(master)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=0)
        self.grid_rowconfigure(1, weight=1)

        title: str = "Frame Chart"
        self.title = ctk.CTkLabel(self, text=title, fg_color="gray30", corner_radius=6)
        self.title.grid(row=0, column=0, sticky="ew")

        self.sel_frame: int = -1

        # See: https://stackoverflow.com/a/65135081
        self.data: deque = deque([], maxlen=300)
        for i in range(0, 300):
            self.data.append(0)

        self.frame_slice: Tuple[int, int] = (-1, -1)

        self.graph: AnimatedLinePlotWidget = AnimatedLinePlotWidget(
            self,
            height=50,
            animate_cb=self.on_animate,
            mouse_motion_cb=lambda event: self.on_mouse_moved(event),
            mouse_press_cb=lambda event: self.on_mouse_clicked(event),
            animation_interval=25,
            x_range=(0, 300),
            y_range=(0, 70),
            max_points=300
        )
        self.graph.grid(row=1, column=0, sticky="nsew")

        param_dict: Dict[str, Any] = dict()
        # param_dict["linestyle"] = "--"
        self.graph.add_line("frame time", np.arange(0, 300, 1), [0] * 300, param_dict=param_dict)

        self.graph.add_line("1ms line", y_data=1)
        self.graph.add_text("1ms text", (0, 2), "1ms (1000FPS)")

        self.graph.add_line("4ms line", y_data=4)
        self.graph.add_text("4ms text", (0, 5), "4ms (250FPS)")

        self.graph.add_line("5ms line", y_data=5)
        self.graph.add_text("5ms text", (0, 6), "5ms (200FPS)")

        self.graph.add_line("10ms line", y_data=10)
        self.graph.add_text("10ms text", (0, 11), "10ms (100FPS)")

        self.graph.add_line("16ms line", y_data=16)
        self.graph.add_text("16ms text", (0, 17), "16ms (60FPS)")

        self.graph.add_line("22ms line", y_data=22)
        self.graph.add_text("22ms text", (0, 23), "22ms (45FPS)")

        self.graph.add_line("33ms line", y_data=33)
        self.graph.add_text("33ms text", (0, 34), "33ms (30FPS)")

        self.graph.add_line("66ms line", y_data=66)
        self.graph.add_text("66ms text", (0, 67), "66ms (15FPS)")

        self.graph.add_line("selected frame", x_data=self.sel_frame)

    def on_animate(self) -> Iterable[str]:
        self.graph.update_line("frame time", y_data=list(self.data))
        self.graph.update_line("selected frame", x_data=self.sel_frame)
        self.graph.fill_line("frame time", 0, 0.5)
        y_max: float = max(self.data) + 2
        self.graph.update_plot_range(y_range=(0, y_max))

        updated_items: List[str] = list()
        updated_items.append("frame time")
        updated_items.append("selected frame")

        if y_max < 5:
            updated_items.append("1ms line")
            updated_items.append("1ms text")
        elif y_max < 10:
            updated_items.append("5ms line")
            updated_items.append("5ms text")
        elif y_max < 16:
            updated_items.append("5ms line")
            updated_items.append("5ms text")
            updated_items.append("10ms line")
            updated_items.append("10ms text")
        elif y_max < 22:
            updated_items.append("5ms line")
            updated_items.append("5ms text")
            updated_items.append("10ms line")
            updated_items.append("10ms text")
            updated_items.append("16ms line")
            updated_items.append("16ms text")
        elif y_max < 33:
            updated_items.append("10ms line")
            updated_items.append("10ms text")
            updated_items.append("16ms line")
            updated_items.append("16ms text")
            updated_items.append("22ms line")
            updated_items.append("22ms text")
        elif y_max < 66:
            updated_items.append("16ms line")
            updated_items.append("16ms text")
            updated_items.append("22ms line")
            updated_items.append("22ms text")
            updated_items.append("33ms line")
            updated_items.append("33ms text")
        elif y_max < 99:
            updated_items.append("22ms line")
            updated_items.append("22ms text")
            updated_items.append("33ms line")
            updated_items.append("33ms text")
            updated_items.append("66ms line")
            updated_items.append("66ms text")
        else:
            updated_items.append("66ms line")
            updated_items.append("66ms text")

        return iter(updated_items)

    def on_mouse_moved(self, coord: Tuple[float, float]) -> None:
        pass

    def on_mouse_clicked(self, coord: Tuple[float, float]) -> None:
        # Range = [0, 299].
        frame_offset: int = int(coord[0])

        # Always update selected frame so that the chart displays vertical bar on mouse click position.
        self.sel_frame = frame_offset

        if self.frame_slice[1] is -1:
            return

        # Chart is half-full.
        if self.frame_slice[1] < 299:
            # Selection made before chart start position.
            if frame_offset < 299 - self.frame_slice[1]:
                return

            frame_offset = abs((299 - self.frame_slice[1]) - frame_offset)

        root: Any = self.winfo_toplevel()
        frame_num: int = self.frame_slice[0] + frame_offset
        root.control_panel.on_frame_selected_from_chart(frame_num)

    def on_frame_info_received(self, frame_info: FrameInfo, selected: bool = False) -> None:
        # Don't update graph when selecting individual frames. Prevents frame shifting on click.
        if not selected:
            self.data.append(frame_info.frame_time)
            self.frame_slice = (max(frame_info.frame_number - min(self.frame_slice[1], 299), 0),
                                frame_info.frame_number)

    def on_frame_info_cleared(self) -> None:
        self.data.clear()
        for i in range(0, 300):
            self.data.append(0)

        self.frame_slice = (-1, -1)

        self.graph.clear()
        self.sel_frame = -1

UdonProfilerApp/Src/FrameChart.py
bSenpai/UdonProfiler
from dataclasses import dataclass
from typing import List

from Src.Sample import Sample


@dataclass
class FrameInfo:
    frame_number: int
    frame_time: float
    samples: List[Sample]
    max_depth: int

    def __init__(self, frame_number: int) -> None:
        self.frame_number = frame_number
        self.frame_time = -1
        self.samples = list()
        self.max_depth = 0

    def add_sample(self, sample: Sample) -> None:
        if self.frame_time < 0 and sample.path_name == "/UdonBehaviour":
            self.frame_time = sample.total_time_ms

        self.samples.append(sample)

        self.max_depth = max(self.max_depth, sample.depth)

UdonProfilerApp/Src/FrameInfo.py
bSenpai/UdonProfiler
from typing import Any, Dict, List, Union

import customtkinter as ctk

from Widgets.Tree.TreeWidget import TreeWidget, TreeColumnConfig
from Src.Sample import Sample
from Src.FrameInfo import FrameInfo


class HierarchyView(ctk.CTkFrame):
    def __init__(self, master, **kwargs) -> None:
        super().__init__(master, **kwargs)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

        self.tree: TreeWidget = TreeWidget(self)
        self.tree.grid(row=0, column=0, sticky="nsew")

        overview_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Overview",
            minsize=150,
            weight=1
        )

        total_time_percent_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Total",
            minsize=100,
            weight=0
        )

        self_time_percent_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Self",
            minsize=100,
            weight=0
        )

        num_calls_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Calls",
            minsize=100,
            weight=0
        )

        total_time_ms_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Time ms",
            minsize=100,
            weight=0
        )

        self_time_ms_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Self ms",
            minsize=100,
            weight=0
        )

        cols_config: List[TreeColumnConfig] = list()
        cols_config.append(overview_col_config)
        cols_config.append(total_time_percent_col_config)
        cols_config.append(self_time_percent_col_config)
        cols_config.append(num_calls_col_config)
        cols_config.append(total_time_ms_col_config)
        cols_config.append(self_time_ms_col_config)

        self.tree.config_tree(cols_config)

        self.samples_expand_state: Dict[str, bool] = dict()
        self.sel_sample_name: Union[str, None] = None

        root: Any = self.winfo_toplevel()
        info: Union[FrameInfo, None] = root.get_selected_frame_info()
        # if not info:
        #     info = FrameInfo(0)
        #     info.add_sample(Sample("/UdonBehaviour;;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        #     info.add_sample(Sample("/UdonBehaviour/Func1;/UdonBehaviour;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        #     info.add_sample(Sample("/UdonBehaviour/Func1/Func1A;/UdonBehaviour/Func1;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        #     info.add_sample(Sample("/UdonBehaviour/Func1/Func1B;/UdonBehaviour/Func1;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        #     info.add_sample(Sample("/UdonBehaviour/Func2;/UdonBehaviour;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        #     info.add_sample(Sample("/UdonBehaviour/Func2/Func2A;/UdonBehaviour/Func2;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        #     info.add_sample(Sample("/UdonBehaviour/Func3;/UdonBehaviour;100.00;0.00;1;5.00;0.00;0.00,1.00;1.00,2.00"))
        # for i in range(0, len(info.samples)):
        #     self.add_item(info.samples[i])

        self.tree.render_tree()

    def add_item(self, item: Sample, selected: bool = False) -> None:
        if item.path_name not in self.samples_expand_state:
            self.samples_expand_state[item.path_name] = False

        self.tree.add_entry(
            item.path_name,
            item.depth,
            [
                item.name,
                item.total_time_percent,
                item.self_time_percent,
                item.num_calls,
                item.total_time_ms,
                item.self_time_ms
            ],
            item.parent_path_name,
            default_expand=self.samples_expand_state[item.path_name],
            default_select=selected
        )

    def on_frame_info_received(self, frame_info: FrameInfo) -> None:
        # Save previous frame expand states.
        if not self.tree.body:
            raise RuntimeError("Tree body reference lost")

        for k in self.tree.body.entry_expand_states.keys():
            # This can happen if application cleared frame infos.
            if k.entry_config.name not in self.samples_expand_state:
                self.samples_expand_state[k.entry_config.name] = False

            self.samples_expand_state[k.entry_config.name] = self.tree.body.entry_expand_states[k]

        # Save previous frame selected entry.
        sel_entry_name: Union[str, None] = None
        if self.tree.body.sel_entry:
            sel_entry_name = self.tree.body.sel_entry.entry_config.name

        self.tree.clear_tree()
        self.tree.body.sel_entry = None

        # Add new entries to tree.
        for i in range(0, len(frame_info.samples)):
            selected: bool = False
            if sel_entry_name and frame_info.samples[i].path_name == sel_entry_name:
                selected = True

            self.add_item(frame_info.samples[i], selected)

        self.tree.render_tree()

    def on_frame_info_cleared(self) -> None:
        self.samples_expand_state.clear()
        self.sel_sample_name = None
        self.tree.clear_tree()
        self.tree.render_tree()

UdonProfilerApp/Src/HierarchyView.py
bSenpai/UdonProfiler
from dataclasses import dataclass
from typing import List


@dataclass
class Sample:
    name: str
    path_name: str
    parent_path_name: str
    depth: int
    total_time_percent: float
    self_time_percent: float
    num_calls: int
    total_time_ms: float
    self_time_ms: float
    start_times: List[float]
    end_times: List[float]

    def __init__(self, raw_line: str) -> None:
        path_name_idx: int = 0
        parent_path_name_idx: int = 1
        total_time_percent_idx: int = 2
        self_time_percent_idx: int = 3
        num_calls_idx: int = 4
        total_time_ms_idx: int = 5
        self_time_ms_idx: int = 6
        start_times_idx: int = 7
        end_times_idx: int = 8

        arg_delimiter: str = ";"
        time_delimiter: str = ","
        path_delimiter: str = "/"

        args: List[str] = raw_line.split(arg_delimiter)

        self.path_name = args[path_name_idx]
        self.name = self.path_name.split(path_delimiter)[-1]
        self.parent_path_name = args[parent_path_name_idx]
        self.depth = self.path_name.count(path_delimiter) - 1
        self.total_time_percent = float(args[total_time_percent_idx])
        self.self_time_percent = float(args[self_time_percent_idx])
        self.num_calls = int(args[num_calls_idx])
        self.total_time_ms = float(args[total_time_ms_idx])
        self.self_time_ms = float(args[self_time_ms_idx])

        # See: https://stackoverflow.com/a/1614247
        start_times_raw: List[str] = args[start_times_idx].split(time_delimiter)
        self.start_times = [float(t) for t in start_times_raw]

        end_times_raw: List[str] = args[end_times_idx].split(time_delimiter)
        self.end_times = [float(t) for t in end_times_raw]

UdonProfilerApp/Src/Sample.py
bSenpai/UdonProfiler
from dataclasses import dataclass


@dataclass
class SampleStatistics:
    name: str
    min_time: float
    max_time: float
    avg_time: float
    total_time: float
    num_samples: int

    def __init__(self, name: str) -> None:
        self.name = name
        self.min_time = float("inf")
        self.max_time = float("-inf")
        self.avg_time = float("inf")
        self.total_time = 0
        self.num_samples = 0

    def add_statistic(self, time: float) -> None:
        self.min_time = min(time, self.min_time)
        self.max_time = max(time, self.max_time)
        self.total_time += time
        self.num_samples += 1
        self.avg_time = self.total_time / self.num_samples

UdonProfilerApp/Src/SampleStatistics.py
bSenpai/UdonProfiler
from typing import Dict, List, Union

import customtkinter as ctk

from Widgets.Tree.TreeWidget import TreeWidget, TreeColumnConfig
from Src.FrameInfo import FrameInfo
from Src.SampleStatistics import SampleStatistics


class StatisticsView(ctk.CTkFrame):
    def __init__(self, master, **kwargs) -> None:
        super().__init__(master, **kwargs)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

        self.sample_statistics: Dict[str, SampleStatistics] = dict()

        self.tree: TreeWidget = TreeWidget(self)
        self.tree.grid(row=0, column=0, sticky="nsew")

        overview_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Overview",
            minsize=150,
            weight=1
        )

        num_samples_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Sample Count",
            minsize=100,
            weight=0
        )

        min_time_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Min Time (ms)",
            minsize=100,
            weight=0
        )

        max_time_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Max Time (ms)",
            minsize=100,
            weight=0
        )

        avg_time_col_config: TreeColumnConfig = TreeColumnConfig(
            name="Avg Time (ms)",
            minsize=100,
            weight=0
        )

        cols_config: List[TreeColumnConfig] = list()
        cols_config.append(overview_col_config)
        cols_config.append(num_samples_col_config)
        cols_config.append(min_time_col_config)
        cols_config.append(max_time_col_config)
        cols_config.append(avg_time_col_config)

        self.tree.config_tree(cols_config)

        self.sel_sample_name: Union[str, None] = None

        """
        root: Any = self.winfo_toplevel()
        info: Union[FrameInfo, None] = root.get_selected_frame_info()
        if not info:
            info = FrameInfo(0)
            info.add_sample(Sample("/UdonBehaviour,,0,0.400"))
            info.add_sample(Sample("/UdonBehaviour/Func1,/UdonBehaviour,1,0.120"))
            info.add_sample(Sample("/UdonBehaviour/Func1A,/UdonBehaviour/Func1,2,0.050"))
            info.add_sample(Sample("/UdonBehaviour/Func1B,/UdonBehaviour/Func1,2,0.040"))
            info.add_sample(Sample("/UdonBehaviour/Func2,/UdonBehaviour,1,0.150"))
            info.add_sample(Sample("/UdonBehaviour/Func2A,/UdonBehaviour/Func2,2,0.050"))
            info.add_sample(Sample("/UdonBehaviour/Func3,/UdonBehaviour,1,0.100"))
            self.update_statistics(info)
        self.add_items()
        """

        self.tree.render_tree()

    def update_statistics(self, frame_info: FrameInfo) -> None:
        for sample in frame_info.samples:
            name: str = sample.path_name.split("/")[-1]

            if name not in self.sample_statistics:
                self.sample_statistics[name] = SampleStatistics(name)

            self.sample_statistics[name].add_statistic(sample.total_time_ms)

    def add_items(self) -> None:
        for name in self.sample_statistics.keys():
            self.add_item(self.sample_statistics[name])

    def add_item(self, item: SampleStatistics, selected: bool = False) -> None:
        # Clearing tree and re-adding entries freezes application.
        if self.tree.contains_entry(item.name):
            self.tree.update_entry(item.name,
                                   [item.name,
                                    f"{item.num_samples}",
                                    f"{item.min_time:.2f}",
                                    f"{item.max_time:.2f}",
                                    f"{item.avg_time:.2f}"])
        else:
            self.tree.add_entry(item.name,
                                0,
                                [item.name,
                                 f"{item.num_samples}",
                                 f"{item.min_time:.2f}",
                                 f"{item.max_time:.2f}",
                                 f"{item.avg_time:.2f}"],
                                "",
                                default_expand=False,
                                default_select=selected)

    def on_frame_info_received(self, frame_info: FrameInfo) -> None:
        # Save previous frame expand states.
        if not self.tree.body:
            raise RuntimeError("Tree body reference lost")

        # Save previous frame selected entry.
        sel_entry_name: Union[str, None] = None
        if self.tree.body.sel_entry:
            sel_entry_name = self.tree.body.sel_entry.entry_config.name

        self.update_statistics(frame_info)

        # Add new entries to tree.
        for name in self.sample_statistics.keys():
            selected: bool = False
            if sel_entry_name and name == sel_entry_name:
                selected = True
            self.add_item(self.sample_statistics[name], selected=selected)

        self.tree.render_tree()

    def on_frame_info_cleared(self) -> None:
        self.sample_statistics.clear()
        self.sel_sample_name = None
        self.tree.clear_tree()
        self.tree.render_tree()

UdonProfilerApp/Src/StatisticsView.py
bSenpai/UdonProfiler
from typing import List

import customtkinter as ctk

from Src.FrameInfo import FrameInfo

from Widgets.TimelineBarPlot.TimelineBarPlotWidget import TimelineBarPlotWidget


class TimelineView(ctk.CTkFrame):
    colors: List[str] = ['#8be9fd', '#ff79c6', '#50fa7b', '#bd93f9', '#ffb86c', '#ff5555', '#f1fa8c', '#6272a4']
    text_color: str = "#282a36"
    font_size: int = 12
    bar_height: float = 0.1

    def __init__(self, master, **kwargs) -> None:
        super().__init__(master, **kwargs)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

        self.timeline_plot: TimelineBarPlotWidget = TimelineBarPlotWidget(self)
        self.timeline_plot.grid(row=0, column=0, padx=5, pady=5, sticky="nsew")

        # for i in range(0, 50):
        #     h = 1 - TimelineView.bar_height - i * TimelineView.bar_height
        #     c = TimelineView.colors[i % len(TimelineView.colors)]
        #     self.timeline_plot.add_bar(f"VeryLongTextLabel #{i}", h, TimelineView.bar_height, 5, 15, c,
        #                                TimelineView.text_color, TimelineView.font_size)

    def on_frame_info_received(self, frame_info: FrameInfo) -> None:
        self.timeline_plot.clear()

        start = frame_info.samples[0].start_times[0]
        for sample in frame_info.samples:
            # Parent block covers entirety of child blocks.
            if sample.depth < frame_info.max_depth:
                delta: float = sample.end_times[-1] - sample.start_times[0]
                if delta <= 0:
                    continue

                self.timeline_plot.add_bar(sample.name, 1 - TimelineView.bar_height -
                                           sample.depth * TimelineView.bar_height, TimelineView.bar_height,
                                           sample.start_times[0] - start, sample.end_times[-1] - start,
                                           TimelineView.colors[sample.depth % len(TimelineView.colors)],
                                           TimelineView.text_color, TimelineView.font_size)
            else:
                for block in list(zip(sample.start_times, sample.end_times)):
                    if block[1] - block[0] <= 0:
                        continue

                    self.timeline_plot.add_bar(sample.name, 1 - TimelineView.bar_height -
                                               sample.depth * TimelineView.bar_height, TimelineView.bar_height,
                                               block[0] - start, block[1] - start,
                                               TimelineView.colors[sample.depth % len(TimelineView.colors)],
                                               TimelineView.text_color, TimelineView.font_size)

        self.winfo_toplevel().update()

    def on_frame_info_cleared(self) -> None:
        self.timeline_plot.clear()

UdonProfilerApp/Src/TimelineView.py
bSenpai/UdonProfiler
from __future__ import annotations

from typing import Iterator, List, TextIO, Tuple, Union

import os

import tkinter as tk
import customtkinter as ctk

from Src.Sample import Sample
from Src.FrameInfo import FrameInfo
from Src.FrameChart import FrameChart
from Src.DetailsPanel import DetailsPanel
from Src.ControlPanel import ControlPanel
from Utils.PathResolver import resource_path


class UdonProfiler(ctk.CTk):
    frame_begin_symbol: str = "##FRAME_INFO_BEGIN##"
    frame_end_symbol: str = "##FRAME_INFO_END##"
    frame_min: int = 1

    def __init__(self, logfile_name: str) -> None:
        super().__init__()
        self.logfile_name: str = logfile_name

        self.frame_infos: List[FrameInfo] = list()
        self.cur_frame_num: int = UdonProfiler.frame_min - 1
        self.sel_frame_num: int = UdonProfiler.frame_min - 1
        self.is_recording: bool = False
        self.is_running: bool = True

        self.title("Udon Profiler")
        self.geometry("950x950")
        self.iconbitmap(resource_path("Assets\\UdonProfiler.ico"))

        # Layout:
        #   Control Panel
        #   Frame Chart
        #   Details Panel
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=0)
        self.grid_rowconfigure(1, weight=1)
        self.grid_rowconfigure(2, weight=1, minsize=350)

        self.control_panel: ControlPanel = ControlPanel(self)
        self.control_panel.grid(row=0, column=0, padx=10, pady=10, sticky="ew")

        self.frame_chart: FrameChart = FrameChart(self)
        self.frame_chart.grid(row=1, column=0, padx=10, pady=10, sticky="nsew")

        self.details_panel: DetailsPanel = DetailsPanel(self)
        self.details_panel.grid(row=2, column=0, padx=10, pady=10, sticky="nsew")

        # Clean exit.
        self.protocol("WM_DELETE_WINDOW", self.on_exit)

    def run(self) -> None:
        reading_frame: bool = False
        current_frame_info: Union[FrameInfo, None] = None

        logfile: TextIO = open(self.logfile_name, "r")
        lines: Iterator[str] = self.tail(logfile)
        for line in lines:
            # Discard any frames read while recording is paused.
            if not self.is_recording:
                # Clear any frame info that may have been being processed when pause button hit.
                reading_frame = False
                current_frame_info = None
                continue

            # Start reading frame info.
            if UdonProfiler.frame_begin_symbol in line:
                reading_frame = True

                self.cur_frame_num += 1
                current_frame_info = FrameInfo(self.cur_frame_num)
                self.control_panel.on_cur_frame_num_changed(self.cur_frame_num)

                # Initialize frame counter view since it's not set at startup.
                if self.cur_frame_num == UdonProfiler.frame_min:
                    self.control_panel.set_frame_details(UdonProfiler.frame_min, self.cur_frame_num)

                continue

            # Parse remaining frame info.
            if reading_frame:
                # Stop reading frame info.
                if UdonProfiler.frame_end_symbol in line:
                    reading_frame = False

                    if current_frame_info:
                        self.frame_infos.append(current_frame_info)
                        self.frame_chart.on_frame_info_received(current_frame_info)
                        self.details_panel.on_frame_info_received(current_frame_info, selected=False)

                    # Populate frame views since they're empty at startup.
                    if self.cur_frame_num == UdonProfiler.frame_min:
                        self.sel_frame_num = self.cur_frame_num

                        frame_info: Union[FrameInfo, None] = self.get_selected_frame_info()
                        if frame_info:
                            self.details_panel.on_frame_info_received(frame_info, selected=True)

                # Read sample from frame info.
                else:
                    sample: Sample = Sample(line)

                    if current_frame_info:
                        current_frame_info.add_sample(sample)

    def update_loop(self) -> None:
        self.update_idletasks()
        self.update()

    def on_exit(self) -> None:
        self.is_running = False
        tk.Tk.quit(self)

    def tail(self, file: TextIO) -> Iterator[str]:
        """
        generator function that yields new lines in a file
        """

        # seek the end of the file
        file.seek(0, os.SEEK_END)

        # start infinite loop
        while self.is_running:
            # read last line of file
            line: Union[str, None] = file.readline()
            if not line:
                # Run GUI loop if file has not been updated.
                self.update_loop()
                continue

            yield line

    def record(self) -> None:
        self.is_recording = True

    def pause(self) -> None:
        self.is_recording = False

    def prev_frame(self) -> Tuple[int, int]:
        if self.sel_frame_num > UdonProfiler.frame_min:
            self.sel_frame_num -= 1

        return self.change_frame()

    def next_frame(self) -> Tuple[int, int]:
        if self.sel_frame_num < len(self.frame_infos):
            self.sel_frame_num += 1

        return self.change_frame()

    def cur_frame(self) -> Tuple[int, int]:
        self.sel_frame_num = len(self.frame_infos)

        return self.change_frame()

    def goto_frame(self, frame_num: int) -> Tuple[int, int]:
        if frame_num < 0 or frame_num > len(self.frame_infos) - UdonProfiler.frame_min:
            raise RuntimeError(f"can't go to frame {frame_num} - out of range")

        self.sel_frame_num = frame_num + UdonProfiler.frame_min

        return self.change_frame()

    def change_frame(self) -> Tuple[int, int]:
        frame_info: Union[FrameInfo, None] = self.get_selected_frame_info()
        if frame_info:
            self.details_panel.on_frame_info_received(frame_info, selected=True)
            self.frame_chart.on_frame_info_received(frame_info, selected=True)

        return self.sel_frame_num, self.cur_frame_num

    def get_selected_frame_info(self) -> Union[FrameInfo, None]:
        if self.sel_frame_num < UdonProfiler.frame_min:
            return None

        return self.frame_infos[self.sel_frame_num - UdonProfiler.frame_min]

    def clear_frames(self) -> None:
        self.frame_infos.clear()
        self.sel_frame_num = UdonProfiler.frame_min - 1
        self.cur_frame_num = UdonProfiler.frame_min - 1
        self.details_panel.on_frame_info_cleared()
        self.frame_chart.on_frame_info_cleared()


if __name__ == "__main__":
    # See: https://stackoverflow.com/a/29158947
    # See: https://stackoverflow.com/a/59625277
    # See: https://stackoverflow.com/a/3794505
    # See: https://tkdocs.com/tutorial/tree.html
    # See: https://www.pythontutorial.net/tkinter/tkinter-object-oriented-window/
    # See: https://www.digitalocean.com/community/tutorials/tkinter-working-with-classes
    # See: https://www.tutorialspoint.com/how-to-clear-items-from-a-ttk-treeview-widget
    # See: https://www.tutorialspoint.com/python/tk_frame.htm
    # See: https://pythonprogramming.net/how-to-embed-matplotlib-graph-tkinter-gui/
    # See: https://stackoverflow.com/a/21198403
    # See: https://stackoverflow.com/a/61020195
    # See: https://coderslegacy.com/python/tkinter-pack-two-frames-side-by-side/
    # See: https://stackoverflow.com/a/62338378
    # See: https://stackoverflow.com/a/13308493
    # See: https://stackoverflow.com/a/73069099

    from os import path

    # See: https://stackoverflow.com/a/52534405
    #      https://docs.unity3d.com/Manual/LogFiles.html
    log_file: str = path.expandvars(r"%LOCALAPPDATA%\Unity\Editor\Editor.log")

    app: UdonProfiler = UdonProfiler(log_file)
    app.run()

UdonProfilerApp/UdonProfiler.py
bSenpai/UdonProfiler
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, Iterable, Iterator, List, Tuple, Union

import customtkinter as ctk

import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from matplotlib.animation import FuncAnimation
import numpy as np

from Utils.PathResolver import resource_path


class PlotItemType(Enum):
    LINE = 1
    VERTICAL_LINE = 2
    HORIZONTAL_LINE = 3
    FILLED_LINE = 4
    TEXT = 5


@dataclass
class PlotItem:
    item_type: PlotItemType
    item: mpl.artist
    params: Dict[str, Any]

    def __init__(self, item_type: PlotItemType, item: mpl.artist, params: Dict[str, Any]) -> None:
        self.item_type = item_type
        self.item = item
        self.params = params


class AnimatedLinePlotWidget(ctk.CTkFrame):
    def __init__(self, *args,
                 width: int = 350,
                 height: int = 500,
                 animate_cb: Callable[[], Iterable[str]],
                 mouse_motion_cb: Callable[[Tuple[float, float]], None],
                 mouse_press_cb: Callable[[Tuple[float, float]], None],
                 animation_interval: int = 100,
                 x_range: Tuple[float, float] = (0, 100),
                 y_range: Tuple[float, float] = (0, 100),
                 max_points: int = 100,
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.animate_cb: Callable[[], Iterable[str]] = animate_cb
        self.mouse_motion_cb: Callable[[Tuple[float, float]], None] = mouse_motion_cb
        self.mouse_press_cb: Callable[[Tuple[float, float]], None] = mouse_press_cb
        self.animation_interval: int = animation_interval
        self.x_range: Tuple[float, float] = x_range
        self.y_range: Tuple[float, float] = y_range
        self.max_points: int = max_points

        self.plot_items: Dict[str, PlotItem] = dict()

        self.configure(fg_color=("gray78", "gray28"))
        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

        mpl.use("TkAgg")
        plt.style.use(resource_path("Assets\\dracula.mplstyle"))

        self.figure: mpl.Figure = plt.figure(dpi=100)

        # Force graph to fill window.
        # Source: https://stackoverflow.com/a/42620544
        self.axes: mpl.axes.Axes = self.figure.add_axes([0, 0, 1, 1])
        self.axes.axis("off")
        self.axes.margins(0)

        self.background = self.figure.canvas.copy_from_bbox(self.axes.bbox)

        self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg(self.figure, self)
        self.canvas.get_tk_widget().grid(row=0, column=0, sticky="nsew")

        self.animation: FuncAnimation = FuncAnimation(self.figure, self.animate,
                                                      interval=self.animation_interval, blit=True)

        # Bind events.
        # See: https://matplotlib.org/stable/users/explain/event_handling.html
        self.cid_press: int = self.figure.canvas.mpl_connect(
            "button_press_event", lambda event: self.on_mouse_clicked(event))
        self.cid_motion: int = self.figure.canvas.mpl_connect(
            "motion_notify_event", lambda event: self.on_mouse_moved(event))

        self.update_plot_range()

    def animate(self, i: int) -> Iterable[mpl.artist]:
        updated_artist_names: List[str] = list(self.animate_cb())
        updated_artists: List[mpl.artist] = list()
        for artist_name in updated_artist_names:
            if artist_name in self.plot_items:
                if self.plot_items[artist_name].item_type == PlotItemType.FILLED_LINE:
                    # Redraw filled lines.
                    # See: https://stackoverflow.com/a/44413638
                    item: mpl.artist = self.plot_items[artist_name].item
                    params: Dict[str, Any] = self.plot_items[artist_name].params
                    p = self.axes.fill_between(
                        item.get_xdata()[:self.max_points],
                        [params["min_val"]] * self.max_points,
                        item.get_ydata()[:self.max_points],
                        facecolor=params["face_color"],
                        alpha=params["opacity"])
                    updated_artists.append(p)

                updated_artists.append(self.plot_items[artist_name].item)

        return updated_artists

    def on_mouse_moved(self, event) -> None:
        # See: https://stackoverflow.com/a/51349960
        x, y = event.xdata, event.ydata
        self.mouse_motion_cb((x, y))

    def on_mouse_clicked(self, event) -> None:
        x, y = event.xdata, event.ydata
        self.mouse_press_cb((x, y))

    def add_line(self, name: str,
                 x_data: Union[float, List[float], None] = None,
                 y_data: Union[float, List[float], None] = None,
                 param_dict: Union[Dict[str, Any], None] = None) -> None:
        self.remove_line(name)

        if not param_dict:
            param_dict = dict()

        item_type: PlotItemType
        item: mpl.artist
        item_params: Dict[str, Any] = param_dict
        if x_data is not None and y_data is not None:
            item_type = PlotItemType.LINE
            item, = self.axes.plot(x_data, y_data, **param_dict)
        elif x_data is not None:
            item_type = PlotItemType.VERTICAL_LINE
            item = self.axes.axvline(x_data, **param_dict)
        elif y_data is not None:
            item_type = PlotItemType.HORIZONTAL_LINE
            item = self.axes.axhline(y_data, **param_dict)
        else:
            return

        self.plot_items[name] = PlotItem(item_type, item, item_params)

    def update_line(self, name: str, x_data: Union[float, List[float], None] = None,
                    y_data: Union[float, List[float], None] = None) -> None:
        if name not in self.plot_items:
            raise ValueError(f"can't update line {name} - does not exist")

        if self.plot_items[name].item_type == PlotItemType.TEXT:
            raise ValueError(f"can't update item {name} - not a line")

        if x_data:
            self.plot_items[name].item.set_xdata(x_data)

        if y_data:
            self.plot_items[name].item.set_ydata(y_data)

    def fill_line(self, name: str, min_val: float, opacity: float) -> None:
        if name not in self.plot_items:
            raise ValueError(f"can't update line {name} - does not exist")

        if self.plot_items[name].item_type == PlotItemType.TEXT:
            raise ValueError(f"can't update item {name} - not a line")

        x_data: List[float] = self.plot_items[name].item.get_xdata()
        y_data: List[float] = self.plot_items[name].item.get_ydata()

        if len(x_data) < self.max_points or len(y_data) < self.max_points:
            raise RuntimeError(f"can't fill line {name} - not enough points", len(x_data), len(y_data))

        # self.axes.fill_between(x_data[:100], [min_val] * 100, y_data[:100], alpha=opacity)

        item_type: PlotItemType = PlotItemType.FILLED_LINE
        item: mpl.artist = self.plot_items[name].item
        item_params: Dict[str, Any] = self.plot_items[name].params

        item_params["min_val"] = min_val
        item_params["face_color"] = "#8be9fd"
        item_params["opacity"] = opacity

        self.plot_items[name] = PlotItem(item_type, item, item_params)

    def remove_line(self, name: str) -> None:
        # See: https://stackoverflow.com/a/13575495
        if name in self.plot_items and \
            (self.plot_items[name].item_type == PlotItemType.LINE or
             self.plot_items[name].item_type == PlotItemType.VERTICAL_LINE or
             self.plot_items[name].item_type == PlotItemType.HORIZONTAL_LINE):
            self.axes.lines.remove(self.plot_items[name].item)
            del self.plot_items[name]

    def add_text(self, name: str, pos: Tuple[float, float], text: str,
                 param_dict: Union[Dict[str, Any], None] = None) -> None:
        if not param_dict:
            param_dict = dict()

        item_type: PlotItemType = PlotItemType.TEXT
        item: mpl.artist = self.axes.text(*pos, text, **param_dict)
        item_params: Dict[str, Any] = param_dict

        self.plot_items[name] = PlotItem(item_type, item, item_params)

    def remove_text(self, name: str) -> None:
        # See: https://stackoverflow.com/a/26006851
        if name in self.plot_items and self.plot_items[name].item_type == PlotItemType.TEXT:
            self.plot_items[name].item.remove()
            del self.plot_items[name]

    def update_plot_range(self,
                          x_range: Union[Tuple[float, float], None] = None,
                          y_range: Union[Tuple[float, float], None] = None) -> None:
        if x_range is not None:
            self.x_range = x_range

        if y_range is not None:
            self.y_range = y_range

        self.axes.set_xlim(*self.x_range)
        self.axes.set_ylim(*self.y_range)

    def clear(self) -> None:
        self.animation.new_frame_seq()

        # See: https://stackoverflow.com/a/49237860
        self.figure.canvas.restore_region(self.background)
        self.figure.canvas.blit(self.axes.bbox)

    def pause(self) -> None:
        self.animation.pause()

    def resume(self) -> None:
        self.animation.resume()


if __name__ == "__main__":
    # GraphWidget Tests.
    import time
    import random
    from collections import deque


    class AnimatedLinePlotWidgetTest(ctk.CTk):
        def __init__(self) -> None:
            super().__init__()

            self.title("GrpahWidget Test")
            self.geometry("700x350")

            self.grid_rowconfigure(0, weight=1)
            self.grid_columnconfigure(0, weight=1)

            self.sel_frame: int = -1

            # See: https://stackoverflow.com/a/65135081
            self.data: deque = deque([], maxlen=200)
            for i in range(0, 200):
                self.data.append(0)
            self.x: float = 0

            self.graph: AnimatedLinePlotWidget = AnimatedLinePlotWidget(
                self,
                animation_interval=25,
                animate_cb=self.on_animate,
                mouse_motion_cb=lambda event: self.on_mouse_moved(event),
                mouse_press_cb=lambda event: self.on_mouse_clicked(event),
            )
            self.graph.grid(row=0, column=0, sticky="nsew")

            param_dict: Dict[str, Any] = dict()
            param_dict["linestyle"] = "--"
            self.graph.add_line("frame time", np.arange(0, 200, 1), [10] * 200, param_dict=param_dict)

            self.graph.add_line("5ms line", y_data=5)
            self.graph.add_text("5ms text", (0, 6), "5ms (200FPS)")

            self.graph.add_line("10ms line", y_data=10)
            self.graph.add_text("10ms text", (0, 11), "10ms (100FPS)")

            self.graph.add_line("16ms line", y_data=16)
            self.graph.add_text("16ms text", (0, 17), "16ms (60FPS)")

            self.graph.add_line("22ms line", y_data=22)
            self.graph.add_text("22ms text", (0, 23), "22ms (45FPS)")

            self.graph.add_line("33ms line", y_data=33)
            self.graph.add_text("33ms text", (0, 34), "33ms (30FPS)")

            self.graph.add_line("66ms line", y_data=66)
            self.graph.add_text("66ms text", (0, 67), "66ms (15FPS)")

            self.graph.add_line("selected frame", x_data=self.sel_frame)

        def on_animate(self) -> Iterable[str]:
            self.graph.update_line("frame time", y_data=list(self.data))
            self.graph.update_line("selected frame", x_data=self.sel_frame)
            self.graph.fill_line("frame time", min(self.data), 0.5)
            self.graph.update_plot_range(y_range=(min(self.data) - 5, max(self.data) + 5))

            return "frame time", "selected frame", "5ms line", "10ms line", "5ms text", "10ms text",\
                   "16ms line", "22ms line", "33ms line", "16ms text", "22ms text", "33ms text"

        def on_mouse_moved(self, coord: Tuple[float, float]) -> None:
            pass

        def on_mouse_clicked(self, coord: Tuple[float, float]) -> None:
            self.sel_frame = int(coord[0])

        def run(self) -> None:
            while True:
                val: float = random.randint(0, 20) * np.sin(self.x)
                self.data.append(val)
                self.x += 0.1
                time.sleep(0.001)
                self.update_idletasks()
                self.update()


    app: AnimatedLinePlotWidgetTest = AnimatedLinePlotWidgetTest()
    app.run()

UdonProfilerApp/Widgets/AnimatedLinePlot/AnimatedLinePlotWidget.py
bSenpai/UdonProfiler
from matplotlib.backends.backend_tkagg import NavigationToolbar2Tk


# Overwrite navigation toolbar.
# See: https://stackoverflow.com/a/59658768
class NavigationToolbar2CTk(NavigationToolbar2Tk):
    def __init__(self, canvas, window, *, pack_toolbar=True) -> None:
        super().__init__(canvas=canvas, window=window, pack_toolbar=pack_toolbar)

    def mouse_move(self, event) -> None:
        pass

UdonProfilerApp/Widgets/NavigationToolbar/NavigationToolbar2CTk.py
bSenpai/UdonProfiler
from typing import List, Tuple

import customtkinter as ctk

import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from matplotlib.transforms import Bbox, TransformedBbox
from matplotlib.collections import BrokenBarHCollection

from Widgets.NavigationToolbar.NavigationToolbar2CTk import NavigationToolbar2CTk
from Utils.PathResolver import resource_path


class TimelineBarPlotWidget(ctk.CTkFrame):
    # Create scrollable matplotlib frame. Doesn't work?
    # See: https://stackoverflow.com/a/74929672
    """
    def __init__(self, *args,
                 width: int = 350,
                 height: int = 500,
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)
        self.grid_rowconfigure(1, weight=0)
        self.grid_rowconfigure(2, weight=0)

        mpl.use("TkAgg")
        plt.style.use("./mplstyles/dracula.mplstyle")

        self.figure: mpl.Figure = plt.figure(dpi=74)

        # Force graph to fill window.
        # Source: https://stackoverflow.com/a/42620544
        self.axes: mpl.axes.Axes = self.figure.add_axes([0.025, 0.00625, .95, 0.925])
        self.axes.margins(0)

        self.background = self.figure.canvas.copy_from_bbox(self.axes.bbox)

        self.x_scrollbar = ctk.CTkScrollbar(self, orientation="horizontal")
        self.x_scrollbar.grid(row=2, column=0, sticky="ew")

        canvas = ctk.CTkCanvas(self, xscrollcommand=self.x_scrollbar.set)
        canvas.grid(row=0, column=0, sticky="nsew")
        self.x_scrollbar.configure(command=canvas.xview)

        canvas.xview_moveto(0)
        canvas.yview_moveto(0)

        self.interior = interior = ctk.CTkFrame(canvas)
        interior_id = canvas.create_window(0, 0, window=interior, anchor="nw")

        def _configure_interior(event) -> None:
            size = (interior.winfo_reqwidth(), interior.winfo_reqheight())
            canvas.configure(scrollregion="0 0 %s %s" % size)
            if interior.winfo_reqwidth() != canvas.winfo_width():
                canvas.configure(width=interior.winfo_reqwidth())

        interior.bind("<Configure>", _configure_interior)

        def _configure_canvas(event) -> None:
            if interior.winfo_reqwidth() != canvas.winfo_width():
                canvas.itemconfigure(interior_id, width=canvas.winfo_width())

        canvas.bind("<Configure>", _configure_canvas)

        self.axes.set_ylim(0, 1)
        self.axes.set_xlim(0, 1)
        self.axes.set_yticks([])
        self.axes.set_yticklabels([])

        # Set axes at top of graph.
        # See: https://stackoverflow.com/a/14406447
        self.axes.xaxis.tick_top()
        self.axes.grid(True, axis="x", alpha=0.5)

        self.bars = list()
        self.annotations = list()

        self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg(self.figure, self.interior)
        self.canvas.draw()
        self.canvas.get_tk_widget().pack(side="top", fill="both", expand=True)

        # Allow matplotlib toolbar to work with grid layout.
        # See: https://stackoverflow.com/a/51018685
        self.toolbar_frame = ctk.CTkFrame(self)
        self.toolbar_frame.grid(row=1, column=0, sticky="ew")
        self.toolbar = NavigationToolbar2Tk(self.canvas, self.toolbar_frame)
        self.toolbar.update()
    """

    def __init__(self, *args,
                 width: int = 350,
                 height: int = 500,
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)
        self.grid_rowconfigure((1, 2), weight=0)

        mpl.use("TkAgg")
        plt.style.use(resource_path("Assets\\dracula.mplstyle"))

        # TODO: Find a more permanent solution to graph size. Dpi of 74 is a temp fix.
        self.figure: mpl.Figure = plt.figure(dpi=65)

        # Force graph to fill window.
        # Source: https://stackoverflow.com/a/42620544
        self.axes: mpl.axes.Axes = self.figure.add_axes([0.025, 0.00625, .95, 0.925])
        self.axes.margins(0)

        self.background = self.figure.canvas.copy_from_bbox(self.axes.bbox)

        self.axes.set_ylim(0, 1)
        self.axes.set_xlim(0, 1)
        self.axes.set_yticks([])
        self.axes.set_yticklabels([])

        # Set axes at top of graph.
        # See: https://stackoverflow.com/a/14406447
        self.axes.xaxis.tick_top()
        self.axes.grid(True, axis="x", alpha=0.5)

        self.bars = list()
        self.annotations = list()
        self.bar_names = dict()
        self.bar_infos = dict()

        self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg(self.figure, self)
        self.canvas.get_tk_widget().grid(row=0, column=0, sticky="nsew")

        # Remove unnecessary toolbar buttons.
        # See: https://stackoverflow.com/a/59156387
        NavigationToolbar2CTk.toolitems = [t for t in NavigationToolbar2CTk.toolitems if t[0] not in
                                           ('Back', 'Forward', 'Subplots', 'Save')]

        self.toolbar = NavigationToolbar2CTk(self.canvas, self, pack_toolbar=False)
        # print(self.toolbar.configure().keys())
        self.toolbar.configure(background="#282A36")
        for button in self.toolbar.winfo_children():
            button.configure(background="#282A36")
        self.toolbar.grid(row=1, column=0, sticky="ew")
        self.toolbar.update()

        # Scrollbar. Doesn't work?
        # See: https://stackoverflow.com/a/56090565
        # self.scrollbar = ctk.CTkScrollbar(self, orientation="horizontal")
        # self.scrollbar.grid(row=2, column=0, sticky="ew")
        # self.scrollbar["command"] = self.canvas.get_tk_widget().xview
        # self.canvas.get_tk_widget()["xscrollcommand"] = self.scrollbar.set

        # Tooltip setup.
        # See: https://stackoverflow.com/a/66501612
        self.tooltip = self.axes.annotate("", xy=(0, 0), xytext=(20, -20), textcoords="offset points", fontsize=12,
                                          bbox=dict(boxstyle="round", fc="#282A36", ec="#F8F8F2", lw=2))

        self.figure.canvas.mpl_connect("motion_notify_event", lambda event: self.on_hover(event))

        self.canvas.draw()

    def add_bar(self, name: str, y_start: float, height: float, start: float, end: float, facecolor: str,
                textcolor: str, fontsize: int) -> None:
        if start > end or (end - start) == 0:
            raise RuntimeError("bar plot size is invalid", start, end)

        bar = self.axes.broken_barh([(start, end - start)], (y_start, height), facecolors=facecolor, edgecolor="black")

        # Store information for tooltip.
        self.bar_names[bar] = name
        if name not in self.bar_infos.keys():
            self.bar_infos[name] = dict()
            self.bar_infos[name]["instances"] = 1
            self.bar_infos[name]["total_duration"] = end - start
        else:
            self.bar_infos[name]["instances"] += 1
            self.bar_infos[name]["total_duration"] += end - start

        self.bars.append(bar)

        # Don't set y lim to allow vertical scrolling. Doing otherwise would force graph to fit all bars in single view.
        # y_lim_old: Tuple[float, float] = self.axes.get_ylim()
        # y_lim_new: Tuple[float, float] = (min(y_lim_old[0], y_start - height), max(y_lim_old[1], y_start + height))
        # self.axes.set_ylim(y_lim_new)

        x_lim_old: Tuple[float, float] = self.axes.get_xlim()
        x_lim_new: Tuple[float, float] = (min(x_lim_old[0], start), max(x_lim_old[1], end))
        self.axes.set_xlim(x_lim_new)

        # Clip text within bar boundaries.
        # See: https://stackoverflow.com/a/27746640
        box: TransformedBbox = TransformedBbox(
            Bbox(
                [[max(start, x_lim_new[0]), y_start],
                 [min(start + end, x_lim_new[1]), y_start + height]]
            ),
            self.axes.transData
        )

        # Center text within bar boundaries.
        # See: https://stackoverflow.com/a/66837165
        annotation = self.axes.annotate(name, xy=(start + (end - start) / 2, y_start + height / 2), color=textcolor,
                                        ha="center", va="center", fontsize=fontsize, clip_box=box)
        self.annotations.append(annotation)

        # Force tkinter to update canvas.
        # See: https://stackoverflow.com/a/30783010
        self.canvas.draw()

    def clear(self) -> None:
        for bar in self.bars:
            bar.remove()

        self.bars.clear()

        for annotation in self.annotations:
            annotation.remove()

        self.annotations.clear()

        self.bar_names.clear()
        self.bar_infos.clear()

        self.axes.set_ylim(0, 1)
        self.axes.set_xlim(0, 1)

        self.figure.canvas.restore_region(self.background)
        self.figure.canvas.blit(self.axes.bbox)

    def update_tooltip(self, bars: BrokenBarHCollection, ind: int, x: float, y: float) -> None:
        # Update tooltip when hovering over bar.
        # See: https://stackoverflow.com/a/66501612
        self.tooltip.xy = (x, y)
        box = bars.get_paths()[ind].get_extents()

        # Prepare tooltip display info. Emulate Unity's tooltip info.
        name: str = self.bar_names[bars]
        duration: float = box.x1 - box.x0
        instances: int = self.bar_infos[name]["instances"]
        total_duration: float = self.bar_infos[name]["total_duration"]
        text = f"{name}\n{duration:.0f}ms"
        if instances > 1:
            text += f"\nTotal:{total_duration:.0f}ms ({instances} Instances)"

        self.tooltip.set_text(text)
        self.tooltip.get_bbox_patch().set_alpha(0.9)

    def on_hover(self, event) -> None:
        # Handle mouse hover event.
        # See: https://stackoverflow.com/a/66501612
        vis = self.tooltip.get_visible()
        if event.inaxes == self.axes:
            for _, bars in enumerate(self.axes.collections):
                bar_contains, ind = bars.contains(event)
                if bar_contains:
                    self.update_tooltip(bars, ind['ind'][0], event.xdata, event.ydata)
                    self.tooltip.set_visible(True)
                    self.figure.canvas.draw_idle()
                    return

        if vis:
            self.tooltip.set_visible(False)
            self.figure.canvas.draw_idle()


if __name__ == "__main__":
    class TimelineBarPlotWidgetTest(ctk.CTk):
        def __init__(self) -> None:
            super().__init__()

            self.title("Timeline Bar Plot Widget Test")
            self.geometry("700x450")

            self.grid_columnconfigure(0, weight=1)
            self.grid_rowconfigure(0, weight=1)

            self.bar_plot: TimelineBarPlotWidget = TimelineBarPlotWidget(self)
            self.bar_plot.grid(row=0, column=0, sticky="nsew")

            self.bar_plot.add_bar("VeryLongTextLabel", 20, 4, 10, 80, "tab:blue", "white", 8)
            self.bar_plot.add_bar("VeryLongTextLabel", 20, 4, 300, 350, "tab:blue", "white", 8)

            self.bar_plot.clear()

    app: TimelineBarPlotWidgetTest = TimelineBarPlotWidgetTest()
    app.mainloop()

UdonProfilerApp/Widgets/TimelineBarPlot/TimelineBarPlotWidget.py
bSenpai/UdonProfiler
from Widgets.Tree.common import *

from Widgets.Tree.TreeWidget import TreeWidget
from Widgets.Tree.TreeColumnConfig import TreeColumnConfig


class TreeWidgetTest(ctk.CTk):
    def __init__(self) -> None:
        super().__init__()

        self.title("TreeWidget Test")
        self.geometry("750x400")

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

        self.tree_widget: TreeWidget = TreeWidget(self)
        self.tree_widget.grid(row=0, column=0, sticky="nsew")

        name_col_config: TreeColumnConfig = TreeColumnConfig("Overview", 150, 1)
        total_col_config: TreeColumnConfig = TreeColumnConfig("Total", 50, 0)
        self_col_config: TreeColumnConfig = TreeColumnConfig("Self", 50, 0)
        calls_col_config: TreeColumnConfig = TreeColumnConfig("Calls", 50, 0)
        time_col_config: TreeColumnConfig = TreeColumnConfig("Time (ms)", 50, 0)
        selfms_col_config: TreeColumnConfig = TreeColumnConfig("Self (ms)", 50, 0)

        self.tree_widget.config_tree([name_col_config, total_col_config, self_col_config, calls_col_config,
                                      time_col_config, selfms_col_config])

        self.tree_widget.add_entry("rootA",
                                   0,
                                   ["rootA", "95%", "20%", 1, 5.00, 2.00],
                                   "")
        self.tree_widget.add_entry("rootA/FuncA",
                                   1,
                                   ["FuncA", "90%", "10%", 10, 4.00, 1.00],
                                   "rootA")
        self.tree_widget.add_entry("rootA/FuncA1",
                                   2,
                                   ["FuncA1WithReallyLongNameThatTakesUpALotOfSpaceLikeWhyIsItStillGoingWhatIsHappening"
                                    "Here", "50%", "2%", 7, 3.00, 1.50],
                                   "rootA/FuncA")
        self.tree_widget.add_entry("rootA/FuncA2",
                                   2,
                                   ["FuncA2", "45%", "10%", 20, 1.00, 2.25],
                                   "rootA/FuncA")
        self.tree_widget.add_entry("rootA/FuncB",
                                   1,
                                   ["FuncB", "10%", "50%", 4, 1.00, 3.00],
                                   "rootA")
        self.tree_widget.add_entry("rootB",
                                   0,
                                   ["rootB", "5%", "0.2%", 1, 3.00, 1.00],
                                   "")
        self.tree_widget.add_entry("rootB/FuncC",
                                   1,
                                   ["FuncC", "100%", "15%", 25, 3.00, 0.50],
                                   "rootB")

        self.tree_widget.render_tree()

        """
        self.tree_widget.clear_tree()

        self.tree_widget.add_entry("rootA",
                                   0,
                                   ["rootA", "95%", "20%", 1, 5.00, 2.00],
                                   "")
        self.tree_widget.add_entry("rootA/FuncA",
                                   1,
                                   ["FuncA", "90%", "10%", 10, 4.00, 1.00],
                                   "rootA")

        self.tree_widget.render_tree()
        """


if __name__ == "__main__":
    app: TreeWidgetTest = TreeWidgetTest()
    app.mainloop()

UdonProfilerApp/Widgets/Tree/Tests/TreeWidgetTest.py
bSenpai/UdonProfiler
from __future__ import annotations

from Widgets.Tree.common import *

from Widgets.Tree.TreeEntry import TreeEntry
from Widgets.Tree.TreeEntryConfig import TreeEntryConfig
from Widgets.Tree.TreeNode import TreeNode
from Widgets.Tree.TreeDataStructure import TreeDataStructure

if TYPE_CHECKING:
    from Widgets.Tree.TreeColumnConfig import TreeColumnConfig


class TreeBody(ctk.CTkScrollableFrame):
    root_name: str = ""

    def __init__(self, *args,
                 width: int = 100,
                 height: int = 300,
                 col_configs: List[TreeColumnConfig],
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.col_configs: List[TreeColumnConfig] = col_configs

        self.configure(fg_color=("gray78", "gray28"))
        self.grid_columnconfigure(0, weight=1)

        self.tree: TreeDataStructure = TreeDataStructure()
        self.sel_entry: Union[TreeEntry, None] = None
        self.entry_grid_infos: Dict[TreeEntry, Dict[str, Any]] = dict()
        self.entry_expand_states: Dict[TreeEntry, bool] = dict()

    def add_entry(self, name: str, indent: int, data: List[Any], parent_name: str,
                  default_expand: bool = False, default_select: bool = False) -> None:
        # Create tree node.
        entry_config: TreeEntryConfig = TreeEntryConfig(name, indent, data,
                                                        self.on_entry_clicked, self.on_entry_expanded)
        entry: TreeEntry = TreeEntry(master=self, entry_config=entry_config,
                                     col_minsizes=[x.minsize for x in self.col_configs],
                                     col_weights=[x.weight for x in self.col_configs])
        child_node: TreeNode = TreeNode(entry)

        # Start with node collapsed/expanded.
        self.store_entry_expand_state(entry, default_expand)

        # Set select state.
        if default_select:
            self.sel_entry = entry

        # Insert node into tree.
        if parent_name == TreeBody.root_name:
            self.tree.root_node.add_child(child_node)
        else:
            parent_node: Union[TreeNode, None] = None
            for node in self.tree.root_node:
                if not node.entry:
                    continue
                if node.entry.entry_config.name == parent_name:
                    parent_node = node
                    break

            if parent_node:
                parent_node.add_child(child_node)
            else:
                raise RuntimeError(f"Tree entry parent named: {parent_name} does not exist")

    def update_entry(self, name: str, data: List[Any]) -> None:
        for node in self.tree.root_node:
            if not node.entry:
                continue

            if node.entry.entry_config.name == name:
                node.entry.update_entry(data)
                break

    def contains_entry(self, name: str) -> bool:
        for node in self.tree.root_node:
            if not node.entry:
                continue

            if node.entry.entry_config.name == name:
                return True

        return False

    def draw_tree(self) -> None:
        # Flatten tree indices.
        cur_idx: int = 0
        for node in self.tree.root_node:
            if not node.entry:
                continue

            if len(node.children) == 0:
                node.entry.disable_expand()

            node.entry.grid(row=cur_idx, column=0, sticky="ew")
            self.store_entry_grid_info(node.entry)
            cur_idx += 1

        # Needs to be in a separate loop.
        for node in self.tree.root_node:
            if not node.entry:
                continue

            self.restore_entry_expand_state(node.entry)

            # Sets correct button symbol if expanded.
            if self.entry_expand_states[node.entry] and not node.entry.expanded:
                node.entry.on_expanded()

        if self.sel_entry:
            self.sel_entry.select()

    def delete_entries(self) -> None:
        for node in self.tree.root_node:
            if not node.entry:
                continue

            node.entry.grid_forget()
            node.entry.destroy()

        self.sel_entry = None
        self.tree.root_node = TreeNode()

    def sort_by(self, key: int, reverse: bool = False) -> None:
        self.tree.sort(key, reverse)

    def on_entry_clicked(self, entry: TreeEntry, clicked: bool) -> None:
        # Force highlighting of selected node only.
        # TODO: Call a user-defined callback here.
        if clicked:
            self.sel_entry = entry
        else:
            self.sel_entry = None

        for node in self.tree.root_node:
            if not node.entry:
                continue

            if node.entry != entry:
                node.entry.deselect()

    def on_entry_expanded(self, entry: TreeEntry, expanded: bool) -> None:
        node: Union[TreeNode, None] = self.tree.search(entry)
        if not node:
            raise ValueError("Expanded tree entry does not have a node assigned", repr(entry))

        self.store_entry_expand_state(entry, expanded)
        if expanded:
            # Restore expanded state for all immediate children and their descendants.
            self.restore_descendants(node)
        else:
            # Hide all immediate children and their descendants.
            # Store expanded state for when we expand this node.
            self.hide_descendants(node)

    def restore_descendants(self, node: TreeNode) -> None:
        if not node.entry:
            return

        # If current node is not expanded, we won't need to show its children.
        if not self.entry_expand_states[node.entry]:
            return

        # Show children of current node.
        for child in node.children:
            if child.entry:
                self.show_entry(child.entry)
            self.restore_descendants(child)

    def hide_descendants(self, node: TreeNode) -> None:
        for child in node.children:
            if not child.entry:
                continue
            self.hide_entry(child.entry)
            self.hide_descendants(child)

    def hide_entry(self, entry: TreeEntry) -> None:
        entry.grid_forget()

    def show_entry(self, entry: TreeEntry) -> None:
        self.restore_entry_grid_info(entry)

    def store_entry_grid_info(self, entry: TreeEntry) -> None:
        # See: https://stackoverflow.com/a/37732268
        info: Dict[str, Any] = entry.grid_info()
        self.entry_grid_infos[entry] = info

    def restore_entry_grid_info(self, entry: TreeEntry) -> None:
        if entry not in self.entry_grid_infos:
            raise RuntimeError("Tree entry's grid information not stored", repr(entry))

        info: Dict[str, Any] = self.entry_grid_infos[entry]
        entry.grid(**info)

    def store_entry_expand_state(self, entry: TreeEntry, expanded: bool) -> None:
        self.entry_expand_states[entry] = expanded

    def restore_entry_expand_state(self, entry: TreeEntry) -> None:
        if entry not in self.entry_expand_states:
            raise RuntimeError("Tree entry's grid information not stored", repr(entry))

        node: Union[TreeNode, None] = self.tree.search(entry)
        if not node:
            raise ValueError("Tree entry does not have a node assigned", repr(entry))

        for child in node.children:
            if not child.entry:
                continue
            if self.entry_expand_states[entry]:
                self.show_entry(child.entry)
            else:
                self.hide_entry(child.entry)

UdonProfilerApp/Widgets/Tree/TreeBody.py
bSenpai/UdonProfiler
from Widgets.Tree.common import *


@dataclass
class TreeColumnConfig:
    name: str
    minsize: int
    weight: int

    def __init__(self, name: str, minsize: int, weight: int) -> None:
        self.name = name
        self.minsize = minsize
        self.weight = weight

UdonProfilerApp/Widgets/Tree/TreeColumnConfig.py
bSenpai/UdonProfiler
from __future__ import annotations

from Widgets.Tree.common import *

from Widgets.Tree.TreeNode import TreeNode

if TYPE_CHECKING:
    from Widgets.Tree.TreeEntry import TreeEntry


@dataclass
class TreeDataStructure:
    root_node: TreeNode

    def __init__(self) -> None:
        self.root_node = TreeNode()

    def search(self, data: TreeEntry) -> Union[TreeNode, None]:
        for node in self.root_node:
            if node.entry == data:
                return node

        return None

    def sort(self, key: int, reverse: bool = False) -> None:
        # Sorting children of each node preserves tree structure.
        for node in self.root_node:
            node.sort_children(key, reverse)

UdonProfilerApp/Widgets/Tree/TreeDataStructure.py
bSenpai/UdonProfiler
from __future__ import annotations

from Widgets.Tree.common import *

if TYPE_CHECKING:
    from Widgets.Tree.TreeEntryConfig import TreeEntryConfig


class TreeEntry(ctk.CTkFrame):
    default_color: str = "transparent"
    selected_color: str = "#4682B4"
    collapsed_sym: str = "\u2bc8"
    expanded_sym: str = "\u2bc6"

    def __init__(self, *args,
                 width: int = 100,
                 height: int = 32,
                 entry_config: TreeEntryConfig,
                 col_minsizes: List[int],
                 col_weights: List[int],
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.entry_config: TreeEntryConfig = entry_config
        self.col_minsizes: List[int] = col_minsizes
        self.col_weights: List[int] = col_weights

        self.configure(fg_color=("gray78", "gray28"))

        self.grid_columnconfigure(0, weight=0, minsize=25)
        self.grid_rowconfigure(0, weight=1, minsize=32)

        self.expand_btn: ctk.CTkButton = ctk.CTkButton(self, text=TreeEntry.collapsed_sym,
                                                       command=lambda: self.on_expanded(),
                                                       width=20, height=20, corner_radius=0,
                                                       fg_color="transparent",
                                                       hover=False)
        self.expand_btn.grid(row=0, column=0, padx=(0, 10), sticky="ew")

        self.labels: List[ctk.CTkLabel] = list()
        for i in range(0, len(self.entry_config.data)):
            self.grid_columnconfigure(i + 1, weight=self.col_weights[i], minsize=self.col_minsizes[i])

            lbl: ctk.CTkLabel
            if i == 0:
                # Indent first column to signify tree structure.
                lbl = ctk.CTkLabel(self, text=" " * self.entry_config.indent * 5 +
                                              (self.entry_config.data[i]), anchor="w", corner_radius=0)
            else:
                lbl = ctk.CTkLabel(self, text=str(self.entry_config.data[i]), anchor="center", corner_radius=0)
            # Force column contents to fit.
            # lbl.grid(row=0, column=i+1, padx=(5, max(10, self.col_minsizes[i] -
            #                                          len(str(self.entry_config.data)) * 4 - 5)), sticky="ew")
            lbl.grid(row=0, column=i+1, padx=(5, 5), sticky="ew")
            lbl.bind("<Button>", lambda event: self.on_clicked())
            self.labels.append(lbl)

            # See: https://stackoverflow.com/a/60086946
            # self.winfo_toplevel().update()
            # print(lbl.winfo_width(), lbl.winfo_reqwidth())

        self.selected = False
        self.expanded = False

    def on_clicked(self) -> None:
        if not self.selected:
            self.select()
        else:
            self.deselect()

        self.entry_config.click_cb(self, self.selected)

    def on_expanded(self) -> None:
        self.expanded = not self.expanded

        if self.expanded:
            self.expand_btn.configure(text=TreeEntry.expanded_sym)
        else:
            self.expand_btn.configure(text=TreeEntry.collapsed_sym)

        self.entry_config.expand_cb(self, self.expanded)

    def select(self) -> None:
        self.selected = True
        self.configure(fg_color=TreeEntry.selected_color)

    def deselect(self) -> None:
        self.selected = False
        self.configure(fg_color=TreeEntry.default_color)

    def disable_expand(self) -> None:
        self.expand_btn.configure(fg_color=TreeEntry.default_color)
        self.expand_btn.configure(state="disabled")
        self.expand_btn.configure(text="")

    def update_entry(self, data: List[Any]) -> None:
        self.entry_config.data = data

        for i in range(0, len(self.labels)):
            lbl: ctk.CTkLabel = self.labels[i]
            if i == 0:
                # Indent first column to signify tree structure.
                lbl.configure(text=" " * self.entry_config.indent * 5 + (self.entry_config.data[i]))
            else:
                lbl.configure(text=self.entry_config.data[i])

UdonProfilerApp/Widgets/Tree/TreeEntry.py
bSenpai/UdonProfiler
from __future__ import annotations

from Widgets.Tree.common import *

if TYPE_CHECKING:
    from Widgets.Tree.TreeEntry import TreeEntry


@dataclass
class TreeEntryConfig:
    name: str
    indent: int
    data: List[Any]
    click_cb: Callable[[TreeEntry, bool], None]
    expand_cb: Callable[[TreeEntry, bool], None]

    def __init__(self, name: str, indent: int, data: List[Any],
                 click_cb: Callable[[TreeEntry, bool], None],
                 expand_cb: Callable[[TreeEntry, bool], None]) -> None:
        self.name = name
        self.indent = indent
        self.data = data
        self.click_cb = click_cb
        self.expand_cb = expand_cb

UdonProfilerApp/Widgets/Tree/TreeEntryConfig.py
bSenpai/UdonProfiler
from __future__ import annotations

from Widgets.Tree.common import *

from Widgets.Tree.TreeHeaderColumn import TreeHeaderColumn

if TYPE_CHECKING:
    from Widgets.Tree.TreeColumnConfig import TreeColumnConfig


class TreeHeader(ctk.CTkFrame):
    def __init__(self, *args,
                 width: int = 100,
                 height: int = 32,
                 cols_config: List[TreeColumnConfig],
                 sort_cb: Callable[[int, int], None],
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.cols_config: List[TreeColumnConfig] = cols_config
        self.sort_cb: Callable[[int, int], None] = sort_cb

        self.configure(fg_color=("gray78", "gray28"))
        self.grid_rowconfigure(0, weight=1)

        self.placeholder_btn: ctk.CTkButton = ctk.CTkButton(self, text="",
                                                            width=0, corner_radius=0,
                                                            fg_color="transparent",
                                                            hover=False,
                                                            state="disabled")
        self.placeholder_btn.grid(row=0, column=0, padx=(0, 10), sticky="ew")

        self.cols: List[TreeHeaderColumn] = list()
        for i in range(0, len(self.cols_config)):
            self.grid_columnconfigure(i+1, weight=self.cols_config[i].weight, minsize=self.cols_config[i].minsize)

            col: TreeHeaderColumn = TreeHeaderColumn(
                self,
                name=self.cols_config[i].name,
                # Force scope of i variable to lambda expression.
                # Ignore mypy error.
                # See: https://stackoverflow.com/a/938493
                sort_cb=lambda btn_state, col_idx=i: self.on_col_btn_clicked(col_idx, btn_state))

            col.grid(row=0, column=i+1, padx=(5, 5), sticky="ew")

            # See: https://stackoverflow.com/a/60086946
            # self.winfo_toplevel().update()
            # print(col.winfo_width(), col.winfo_reqwidth())

            self.cols.append(col)

    def on_col_btn_clicked(self, col_idx: int, btn_state: int) -> None:
        self.sort_cb(col_idx, btn_state)

    def reset_btn_states(self, ignore_idx: int) -> None:
        """
        Resets column states to prevent multi-sort issues.
        :param ignore_idx: Column to preserve state of.
        """

        for i in range(0, len(self.cols)):
            if i == ignore_idx:
                continue
            self.cols[i].reset_btn_state()

UdonProfilerApp/Widgets/Tree/TreeHeader.py
bSenpai/UdonProfiler
from Widgets.Tree.common import *


class TreeHeaderColumn(ctk.CTkFrame):
    up_down_arrow: str = "\u21C5"
    # Space at end to center.
    up_arrow: str = "\u2191 "
    down_arrow: str = "\u2193 "

    def __init__(self, *args,
                 width: int = 100,
                 height: int = 32,
                 name: str = "Name",
                 sort_cb: Callable[[int], None],
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.name: str = name
        self.sort_cb: Callable[[int], None] = sort_cb

        self.configure(fg_color=("gray78", "gray28"))

        # Layout:
        # sort button | column text
        self.grid_columnconfigure(0, weight=0, minsize=15)
        self.grid_columnconfigure(1, weight=1)
        self.grid_rowconfigure(0, weight=1, minsize=32)

        self.btn: ctk.CTkButton = ctk.CTkButton(self, text=TreeHeaderColumn.up_down_arrow, anchor="w",
                                                command=lambda: self.on_btn_clicked(),
                                                fg_color="transparent",
                                                hover=False,
                                                width=20, height=20,
                                                corner_radius=0)
        self.btn.grid(row=0, column=0, padx=(0, 0), sticky="ew")

        self.lbl: ctk.CTkLabel = ctk.CTkLabel(self, text=self.name, anchor="w", fg_color="transparent")
        self.lbl.grid(row=0, column=1, sticky="ew")

        self.btn_state = -1

    def on_btn_clicked(self) -> None:
        self.btn_state = (self.btn_state + 1) % 2

        if self.btn_state == 0:
            self.btn.configure(text=TreeHeaderColumn.up_arrow)
        else:
            self.btn.configure(text=TreeHeaderColumn.down_arrow)

        self.sort_cb(self.btn_state)

    def reset_btn_state(self) -> None:
        self.btn_state = -1
        self.btn.configure(text=TreeHeaderColumn.up_down_arrow)

UdonProfilerApp/Widgets/Tree/TreeHeaderColumn.py
bSenpai/UdonProfiler
from __future__ import annotations

from Widgets.Tree.common import *

if TYPE_CHECKING:
    from Widgets.Tree.TreeEntry import TreeEntry


@dataclass
class TreeNode:
    entry: Union[TreeEntry, None]
    parent: Union[TreeNode, None]
    children: List[TreeNode]

    def __init__(self, data: Union[TreeEntry, None] = None) -> None:
        self.entry = data
        self.parent = None
        self.children = list()

    def __iter__(self) -> Iterator[TreeNode]:
        # Pre-order traversal.
        # Tree view requires this traversal technique to index entries correctly.
        # See: https://stackoverflow.com/a/47733929
        yield self

        for child in self.children:
            yield from child.__iter__()

    def add_child(self, child: TreeNode) -> None:
        self.children.append(child)

    def sort_children(self, key: int, reverse: bool = False) -> None:
        if len(self.children) < 2:
            return

        if key < 0:
            raise KeyError("Sort index out of range", repr(key), repr(len(self.children)))

        for child in self.children:
            if not child.entry:
                continue
            if key >= len(child.entry.entry_config.data):
                raise KeyError("Sort index out of range", repr(key), repr(len(self.children)))

        # Ignore the mypy warning - c.data is guaranteed to be not None from previous check.
        self.children.sort(key=lambda c: c.entry.entry_config.data[key], reverse=reverse)

UdonProfilerApp/Widgets/Tree/TreeNode.py
bSenpai/UdonProfiler
from Widgets.Tree.common import *

from Widgets.Tree.TreeHeader import TreeHeader
from Widgets.Tree.TreeBody import TreeBody
from Widgets.Tree.TreeColumnConfig import TreeColumnConfig
from Widgets.Tree.TreeEntry import TreeEntry


class TreeWidget(ctk.CTkFrame):
    def __init__(self, *args,
                 width: int = 350,
                 height: int = 500,
                 **kwargs) -> None:
        super().__init__(*args, width=width, height=height, **kwargs)

        self.header: Union[TreeHeader, None] = None
        self.body: Union[TreeBody, None] = None

        self.col_configs: List[TreeColumnConfig] = list()
        self.entries: List[TreeEntry] = list()

        # self.configure(fg_color=("gray78", "gray28"))

        # Layout:
        #  _________________________
        # | ^v Label   ^v Label ... |
        # |-------------------------|
        # | +- Item       Value ... |
        # |   +- Item     Value ... |
        # |_________________________|
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=0, minsize=32)
        self.grid_rowconfigure(1, weight=1)

    def config_tree(self, config: List[TreeColumnConfig]) -> None:
        """
        Initialize new tree header and body with provided parameters.
        :param config: Tree column configuration.
        """

        self.header = TreeHeader(master=self, cols_config=config,
                                 sort_cb=lambda col_idx, btn_state: self.on_sort_btn_clicked(col_idx, btn_state))
        self.header.grid(row=0, column=0, padx=5, pady=5, sticky="ew")

        self.body = TreeBody(master=self, col_configs=config)
        self.body.grid(row=1, column=0, padx=5, pady=5, sticky="nsew")

    def add_column(self, col_name: str, col_size: int, col_weight: int) -> None:
        config: TreeColumnConfig = TreeColumnConfig(col_name, col_size, col_weight)
        self.col_configs.append(config)

    def add_entry(self, name: str, indent: int, data: List[Any], parent_name: str,
                  default_expand: bool = False, default_select: bool = False) -> None:
        if not (self.header and self.body):
            raise RuntimeError("Tree body not initialized")

        self.body.add_entry(name, indent, data, parent_name, default_expand, default_select)

    def update_entry(self, name: str, data: List[Any]) -> None:
        if not (self.header and self.body):
            raise RuntimeError("Tree body not initialized")

        self.body.update_entry(name, data)

    def contains_entry(self, name: str) -> bool:
        if not (self.header and self.body):
            raise RuntimeError("Tree body not initialized")

        return self.body.contains_entry(name)

    def render_tree(self) -> None:
        if not (self.header and self.body):
            raise RuntimeError("Tree body not initialized")

        self.body.draw_tree()

    def clear_tree(self) -> None:
        if not (self.header and self.body):
            raise RuntimeError("Tree body not initialized")

        self.body.delete_entries()

    def on_sort_btn_clicked(self, col_idx: int, btn_state: int) -> None:
        if not (self.header and self.body):
            raise RuntimeError("Tree body not initialized")

        reverse: bool
        if btn_state == 0:
            reverse = False
        else:
            reverse = True

        self.body.sort_by(col_idx, reverse)

        # Clear other column button states to prevent multi-sort issues.
        self.header.reset_btn_states(col_idx)

        self.render_tree()

UdonProfilerApp/Widgets/Tree/TreeWidget.py
bSenpai/UdonProfiler
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterator, List, Union, TYPE_CHECKING

import customtkinter as ctk

__all__ = ["dataclass", "Any", "Callable", "Dict", "Iterator", "List", "Union", "TYPE_CHECKING", "ctk"]

UdonProfilerApp/Widgets/Tree/common.py
bSenpai/UdonProfiler