diff --git a/tools/jotpluggler/layout.py b/tools/jotpluggler/layout.py index b113d4076d..683bf70a22 100644 --- a/tools/jotpluggler/layout.py +++ b/tools/jotpluggler/layout.py @@ -1,282 +1,198 @@ -import uuid import dearpygui.dearpygui as dpg -from abc import ABC, abstractmethod from openpilot.tools.jotpluggler.data import DataManager -from openpilot.tools.jotpluggler.views import ViewPanel, TimeSeriesPanel +from openpilot.tools.jotpluggler.views import TimeSeriesPanel -class LayoutNode(ABC): - def __init__(self, node_id: str | None = None): - self.node_id = node_id or str(uuid.uuid4()) - self.tag: str | None = None - - @abstractmethod - def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): - pass - - @abstractmethod - def destroy_ui(self): - pass - - -class LeafNode(LayoutNode): - """Leaf node that contains a single ViewPanel with controls""" - - def __init__(self, panel: ViewPanel, layout_manager=None, scale: float = 1.0, node_id: str = None): - super().__init__(node_id) - self.panel = panel - self.layout_manager = layout_manager +class PlotLayoutManager: + def __init__(self, data_manager: DataManager, playback_manager, scale: float = 1.0): + self.data_manager = data_manager + self.playback_manager = playback_manager self.scale = scale + self.container_tag = "plot_layout_container" + self.active_panels = [] - def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): - """Create UI container with controls and panel""" - self.tag = f"leaf_{self.node_id}" - - with dpg.child_window(tag=self.tag, parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): - # Control bar - with dpg.group(horizontal=True): - dpg.add_input_text(tag=f"title_{self.node_id}", default_value=self.panel.title, width=int(100 * self.scale), callback=self._on_title_change) - dpg.add_combo( - items=["Time Series"], # "Camera", "Text Log", "Map View"], - tag=f"type_{self.node_id}", - default_value="Time Series", - width=int(100 * self.scale), - callback=self._on_type_change, - ) - dpg.add_button(label="Clear", callback=self._clear, width=int(50 * self.scale)) - dpg.add_button(label="Delete", callback=self._delete, width=int(50 * self.scale)) - dpg.add_button(label="Split H", callback=lambda: self._split("horizontal"), width=int(50 * self.scale)) - dpg.add_button(label="Split V", callback=lambda: self._split("vertical"), width=int(50 * self.scale)) - - dpg.add_separator() - - # Panel content area - panel_area_tag = f"panel_area_{self.node_id}" - with dpg.child_window(tag=panel_area_tag, border=False, height=-1, width=-1, no_scrollbar=True): - self.panel.create_ui(panel_area_tag) - - def destroy_ui(self): - if self.panel: - self.panel.destroy_ui() - if self.tag and dpg.does_item_exist(self.tag): - dpg.delete_item(self.tag) - - def _on_title_change(self, sender, app_data): - self.panel.title = app_data - - def _on_type_change(self, sender, app_data): - print(f"Panel type change requested: {app_data}") + initial_panel = TimeSeriesPanel(data_manager, playback_manager) + self.active_panels.append(initial_panel) + self.layout = {"type": "panel", "panel": initial_panel} - def _split(self, orientation: str): - if self.layout_manager: - self.layout_manager.split_node(self, orientation) + def create_ui(self, parent_tag: str): + if dpg.does_item_exist(self.container_tag): + dpg.delete_item(self.container_tag) - def _clear(self): - if hasattr(self.panel, 'clear_all_series'): - self.panel.clear_all_series() + with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): + container_width, container_height = dpg.get_item_rect_size(self.container_tag) + self._create_ui_recursive(self.layout, self.container_tag, [], container_width, container_height) - def _delete(self): - if self.layout_manager: - self.layout_manager.delete_node(self) + def on_viewport_resize(self): + self._resize_splits_recursive(self.layout, []) + + def split_panel(self, panel_path: list[int], orientation: str): + current_layout = self._get_layout_at_path(panel_path) + existing_panel = current_layout["panel"] + new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager) + self.active_panels.append(new_panel) + + parent, child_index = self._get_parent_and_index(panel_path) + + if parent is None: # Root split + self.layout = { + "type": "split", + "orientation": orientation, + "children": [{"type": "panel", "panel": existing_panel}, {"type": "panel", "panel": new_panel}], + "proportions": [0.5, 0.5], + } + self._rebuild_ui_at_path([]) + elif parent["type"] == "split" and parent["orientation"] == orientation: + parent["children"].insert(child_index + 1, {"type": "panel", "panel": new_panel}) + parent["proportions"] = [1.0 / len(parent["children"])] * len(parent["children"]) + self._rebuild_ui_at_path(panel_path[:-1]) + else: + new_split = {"type": "split", "orientation": orientation, "children": [current_layout, {"type": "panel", "panel": new_panel}], "proportions": [0.5, 0.5]} + self._replace_layout_at_path(panel_path, new_split) + self._rebuild_ui_at_path(panel_path) + + def delete_panel(self, panel_path: list[int]): + if not panel_path: # Root deletion + old_panel = self.layout["panel"] + old_panel.destroy_ui() + self.active_panels.remove(old_panel) + new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager) + self.active_panels.append(new_panel) + self.layout = {"type": "panel", "panel": new_panel} + self._rebuild_ui_at_path([]) + return + parent, child_index = self._get_parent_and_index(panel_path) + layout_to_delete = parent["children"][child_index] + self._cleanup_ui_recursive(layout_to_delete) + + parent["children"].pop(child_index) + parent["proportions"].pop(child_index) + + if len(parent["children"]) == 1: # remove parent and collapse + remaining_child = parent["children"][0] + if len(panel_path) == 1: # parent is at root level - promote remaining child to root + self.layout = remaining_child + self._rebuild_ui_at_path([]) + else: # replace parent with remaining child in grandparent + grandparent_path = panel_path[:-2] + parent_index = panel_path[-2] + self._replace_layout_at_path(grandparent_path + [parent_index], remaining_child) + self._rebuild_ui_at_path(grandparent_path + [parent_index]) + else: # redistribute proportions + equal_prop = 1.0 / len(parent["children"]) + parent["proportions"] = [equal_prop] * len(parent["children"]) + self._rebuild_ui_at_path(panel_path[:-1]) + + def update_all_panels(self): + for panel in self.active_panels: + panel.update() + + def _get_layout_at_path(self, path: list[int]) -> dict: + current = self.layout + for index in path: + current = current["children"][index] + return current + + def _get_parent_and_index(self, path: list[int]) -> tuple: + return (None, -1) if not path else (self._get_layout_at_path(path[:-1]), path[-1]) + + def _replace_layout_at_path(self, path: list[int], new_layout: dict): + if not path: + self.layout = new_layout + else: + parent, index = self._get_parent_and_index(path) + parent["children"][index] = new_layout -class SplitterNode(LayoutNode): - def __init__(self, children: list[LayoutNode], orientation: str = "horizontal", node_id: str | None = None): - super().__init__(node_id) - self.children = children if children else [] - self.orientation = orientation - self.child_proportions = [1.0 / len(self.children) for _ in self.children] if self.children else [] - self.child_container_tags: list[str] = [] # Track container tags for resizing + def _path_to_tag(self, path: list[int], prefix: str = "") -> str: + path_str = "_".join(map(str, path)) if path else "root" + return f"{prefix}_{path_str}" if prefix else path_str - def add_child(self, child: LayoutNode, index: int = None): - if index is None: - self.children.append(child) - self.child_proportions.append(0.0) + def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): + if layout["type"] == "panel": + self._create_panel_ui(layout, parent_tag, path) else: - self.children.insert(index, child) - self.child_proportions.insert(index, 0.0) - self._redistribute_proportions() - - def remove_child(self, child: LayoutNode): - if child in self.children: - index = self.children.index(child) - self.children.remove(child) - self.child_proportions.pop(index) - child.destroy_ui() - if self.children: - self._redistribute_proportions() - - def replace_child(self, old_child: LayoutNode, new_child: LayoutNode): - try: - index = self.children.index(old_child) - self.children[index] = new_child - return index - except ValueError: - return None - - def _redistribute_proportions(self): - if self.children: - equal_proportion = 1.0 / len(self.children) - self.child_proportions = [equal_proportion for _ in self.children] - - def resize_children(self): - if not self.tag or not dpg.does_item_exist(self.tag): - return + self._create_split_ui(layout, parent_tag, path, width, height) - available_width, available_height = dpg.get_item_rect_size(dpg.get_item_parent(self.tag)) + def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int]): + panel_tag = self._path_to_tag(path, "panel") - for i, container_tag in enumerate(self.child_container_tags): - if not dpg.does_item_exist(container_tag): - continue + with dpg.child_window(tag=panel_tag, parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): + with dpg.group(horizontal=True): + dpg.add_input_text(default_value=layout["panel"].title, width=int(100 * self.scale), callback=lambda s, v: setattr(layout["panel"], "title", v)) + dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale)) + dpg.add_button(label="Clear", callback=lambda: self._clear_panel(layout["panel"]), width=int(50 * self.scale)) + dpg.add_button(label="Delete", callback=lambda: self.delete_panel(path), width=int(50 * self.scale)) + dpg.add_button(label="Split H", callback=lambda: self.split_panel(path, "horizontal"), width=int(50 * self.scale)) + dpg.add_button(label="Split V", callback=lambda: self.split_panel(path, "vertical"), width=int(50 * self.scale)) - proportion = self.child_proportions[i] if i < len(self.child_proportions) else (1.0 / len(self.children)) + dpg.add_separator() - if self.orientation == "horizontal": - new_width = max(100, int(available_width * proportion)) - dpg.configure_item(container_tag, width=new_width) - else: - new_height = max(100, int(available_height * proportion)) - dpg.configure_item(container_tag, height=new_height) + content_tag = self._path_to_tag(path, "content") + with dpg.child_window(tag=content_tag, border=False, height=-1, width=-1, no_scrollbar=True): + layout["panel"].create_ui(content_tag) - child = self.children[i] if i < len(self.children) else None - if child and isinstance(child, SplitterNode): - child.resize_children() + def _create_split_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): + split_tag = self._path_to_tag(path, "split") + is_horizontal = layout["orientation"] == "horizontal" - def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): - self.tag = f"splitter_{self.node_id}" - self.child_container_tags = [] + with dpg.group(tag=split_tag, parent=parent_tag, horizontal=is_horizontal): + for i, (child_layout, proportion) in enumerate(zip(layout["children"], layout["proportions"], strict=True)): + child_path = path + [i] + container_tag = self._path_to_tag(child_path, "container") - if self.orientation == "horizontal": - with dpg.group(tag=self.tag, parent=parent_tag, horizontal=True): - for i, child in enumerate(self.children): - proportion = self.child_proportions[i] + if is_horizontal: child_width = max(100, int(width * proportion)) - container_tag = f"child_container_{self.node_id}_{i}" - self.child_container_tags.append(container_tag) - - with dpg.child_window(tag=container_tag, width=child_width, height=-1, border=False, no_scrollbar=True, resizable_x=False): - child.create_ui(container_tag, child_width, height) - else: - with dpg.group(tag=self.tag, parent=parent_tag): - for i, child in enumerate(self.children): - proportion = self.child_proportions[i] + with dpg.child_window(tag=container_tag, width=child_width, height=-1, border=False, no_scrollbar=True): + self._create_ui_recursive(child_layout, container_tag, child_path, child_width, height) + else: child_height = max(100, int(height * proportion)) - container_tag = f"child_container_{self.node_id}_{i}" - self.child_container_tags.append(container_tag) - - with dpg.child_window(tag=container_tag, width=-1, height=child_height, border=False, no_scrollbar=True, resizable_y=False): - child.create_ui(container_tag, width, child_height) + with dpg.child_window(tag=container_tag, width=-1, height=child_height, border=False, no_scrollbar=True): + self._create_ui_recursive(child_layout, container_tag, child_path, width, child_height) - def destroy_ui(self): - for child in self.children: - if child: - child.destroy_ui() - if self.tag and dpg.does_item_exist(self.tag): - dpg.delete_item(self.tag) - self.child_container_tags.clear() + def _rebuild_ui_at_path(self, path: list[int]): + layout = self._get_layout_at_path(path) - -class PlotLayoutManager: - def __init__(self, data_manager: DataManager, playback_manager, scale: float = 1.0): - self.data_manager = data_manager - self.playback_manager = playback_manager - self.scale = scale - self.container_tag = "plot_layout_container" - self._initialize_default_layout() - - def _initialize_default_layout(self): - panel = TimeSeriesPanel(self.data_manager, self.playback_manager) - self.root_node = LeafNode(panel, layout_manager=self, scale=self.scale) - - def create_ui(self, parent_tag: str): - if dpg.does_item_exist(self.container_tag): - dpg.delete_item(self.container_tag) - - with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): + if not path: # Root update + dpg.delete_item(self.container_tag, children_only=True) container_width, container_height = dpg.get_item_rect_size(self.container_tag) - self.root_node.create_ui(self.container_tag, container_width, container_height) - - def on_viewport_resize(self): - if isinstance(self.root_node, SplitterNode): - self.root_node.resize_children() - - def split_node(self, node: LeafNode, orientation: str): - # create new panel for the split - new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager) # TODO: create same type of panel as the split - new_leaf = LeafNode(new_panel, layout_manager=self, scale=self.scale) - - parent_node, child_index = self._find_parent_and_index(node) - - if parent_node is None: # root node - create new splitter as root - self.root_node = SplitterNode([node, new_leaf], orientation) - self._update_ui_for_node(self.root_node, self.container_tag) - elif isinstance(parent_node, SplitterNode) and parent_node.orientation == orientation: # same orientation - add to existing splitter - parent_node.add_child(new_leaf, child_index + 1) - self._update_ui_for_node(parent_node) - else: # different orientation - replace node with new splitter - new_splitter = SplitterNode([node, new_leaf], orientation) - self._replace_child_in_parent(parent_node, node, new_splitter) - - def delete_node(self, node: LeafNode): # TODO: actually delete the node, not just the ui for the node - parent_node, child_index = self._find_parent_and_index(node) - - if parent_node is None: # root deletion - replace with new default - node.destroy_ui() - self._initialize_default_layout() - self._update_ui_for_node(self.root_node, self.container_tag) - elif isinstance(parent_node, SplitterNode): - parent_node.remove_child(node) - if len(parent_node.children) == 1: # collapse splitter --> leaf to just leaf - remaining_child = parent_node.children[0] - grandparent_node, parent_index = self._find_parent_and_index(parent_node) - - if grandparent_node is None: # promote remaining child to root - parent_node.children.remove(remaining_child) - self.root_node = remaining_child - parent_node.destroy_ui() - self._update_ui_for_node(self.root_node, self.container_tag) - else: # replace splitter with remaining child in grandparent node - self._replace_child_in_parent(grandparent_node, parent_node, remaining_child) - else: # update splpitter contents - self._update_ui_for_node(parent_node) - - def _replace_child_in_parent(self, parent_node: SplitterNode, old_child: LayoutNode, new_child: LayoutNode): - child_index = parent_node.children.index(old_child) - child_container_tag = f"child_container_{parent_node.node_id}_{child_index}" - - parent_node.replace_child(old_child, new_child) - - # Clean up old child if it's being replaced (not just moved) - if old_child != new_child: - old_child.destroy_ui() - - if dpg.does_item_exist(child_container_tag): - dpg.delete_item(child_container_tag, children_only=True) - container_width, container_height = dpg.get_item_rect_size(child_container_tag) - new_child.create_ui(child_container_tag, container_width, container_height) - - def _update_ui_for_node(self, node: LayoutNode, container_tag: str = None): - if container_tag: # update node in a specific container (usually root) - dpg.delete_item(container_tag, children_only=True) - container_width, container_height = dpg.get_item_rect_size(container_tag) - node.create_ui(container_tag, container_width, container_height) - else: # update node in its current location (splitter updates) - if node.tag and dpg.does_item_exist(node.tag): - parent_container = dpg.get_item_parent(node.tag) - node.destroy_ui() - if parent_container and dpg.does_item_exist(parent_container): - parent_width, parent_height = dpg.get_item_rect_size(parent_container) - node.create_ui(parent_container, parent_width, parent_height) - - def _find_parent_and_index(self, target_node: LayoutNode): # TODO: probably can be stored in child - def search_recursive(node: LayoutNode | None, parent: LayoutNode | None = None, index: int = 0): - if node == target_node: - return parent, index - if isinstance(node, SplitterNode): - for i, child in enumerate(node.children): - result = search_recursive(child, node, i) - if result[0] is not None: - return result - return None, None - - return search_recursive(self.root_node) + self._create_ui_recursive(layout, self.container_tag, path, container_width, container_height) + else: + container_tag = self._path_to_tag(path, "container") + if dpg.does_item_exist(container_tag): + self._cleanup_ui_recursive(layout) + dpg.delete_item(container_tag, children_only=True) + width, height = dpg.get_item_rect_size(container_tag) + self._create_ui_recursive(layout, container_tag, path, width, height) + + def _cleanup_ui_recursive(self, layout: dict): + if layout["type"] == "panel": + panel = layout["panel"] + panel.destroy_ui() + if panel in self.active_panels: + self.active_panels.remove(panel) + else: + for child in layout["children"]: + self._cleanup_ui_recursive(child) + + def _resize_splits_recursive(self, layout: dict, path: list[int]): + if layout["type"] == "split": + split_tag = self._path_to_tag(path, "split") + if dpg.does_item_exist(split_tag): + parent_tag = dpg.get_item_parent(split_tag) + available_width, available_height = dpg.get_item_rect_size(parent_tag) + + for i, proportion in enumerate(layout["proportions"]): + child_path = path + [i] + container_tag = self._path_to_tag(child_path, "container") + if dpg.does_item_exist(container_tag): + if layout["orientation"] == "horizontal": + dpg.configure_item(container_tag, width=max(100, int(available_width * proportion))) + else: + dpg.configure_item(container_tag, height=max(100, int(available_height * proportion))) + + self._resize_splits_recursive(layout["children"][i], child_path) + + def _clear_panel(self, panel): + if hasattr(panel, 'clear_all_series'): + panel.clear_all_series() diff --git a/tools/jotpluggler/pluggle.py b/tools/jotpluggler/pluggle.py index a44fa2ed3c..616905a5d7 100755 --- a/tools/jotpluggler/pluggle.py +++ b/tools/jotpluggler/pluggle.py @@ -9,7 +9,7 @@ import numpy as np from openpilot.common.basedir import BASEDIR from openpilot.tools.jotpluggler.data import DataManager from openpilot.tools.jotpluggler.views import DataTreeView -from openpilot.tools.jotpluggler.layout import PlotLayoutManager, SplitterNode, LeafNode +from openpilot.tools.jotpluggler.layout import PlotLayoutManager DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" @@ -173,12 +173,12 @@ class MainController: dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") - def _update_visible_set(self): # for some reason, dpg has no way to easily check for visibility, and checking is slow... + def _update_visible_set(self): # for some reason, dpg has no way to easily check for visibility, and checking is slow... all_paths = list(self.data_tree_view.created_leaf_paths) if not all_paths: self.visible_paths.clear() return - chunk_size = min(50, len(all_paths)) # check up to 50 paths per frame + chunk_size = min(50, len(all_paths)) # check up to 50 paths per frame end_index = min(self.check_index + chunk_size, len(all_paths)) for i in range(self.check_index, end_index): path = all_paths[i] @@ -192,7 +192,7 @@ class MainController: def _update_data_values(self): value_column_width = dpg.get_item_rect_size("data_pool_window")[0] // 2 - for path in self.visible_paths.copy(): # avoid modification during iteration + for path in self.visible_paths.copy(): # avoid modification during iteration value_tag = f"value_{path}" group_tag = f"group_{path}" @@ -207,16 +207,7 @@ class MainController: dpg.set_value(value_tag, formatted_value) def _update_timeline_indicators(self, current_time_s: float): - def update_node_recursive(node): - if isinstance(node, LeafNode): - if hasattr(node.panel, 'update_timeline_indicator'): - node.panel.update_timeline_indicator(current_time_s) - elif isinstance(node, SplitterNode): - for child in node.children: - update_node_recursive(child) - - if self.plot_layout_manager.root_node: - update_node_recursive(self.plot_layout_manager.root_node) + self.plot_layout_manager.update_all_panels() def main(route_to_load=None): diff --git a/tools/jotpluggler/views.py b/tools/jotpluggler/views.py index fd9fd09007..94cc4e4174 100644 --- a/tools/jotpluggler/views.py +++ b/tools/jotpluggler/views.py @@ -28,6 +28,9 @@ class ViewPanel(ABC): def get_panel_type(self) -> str: pass + def update(self): + pass + class TimeSeriesPanel(ViewPanel): def __init__(self, data_manager: DataManager, playback_manager, panel_id: str | None = None): @@ -61,6 +64,10 @@ class TimeSeriesPanel(ViewPanel): self._ui_created = True + def update(self): + if self._ui_created: + self.update_timeline_indicator(self.playback_manager.current_time_s) + def update_timeline_indicator(self, current_time_s: float): if not self._ui_created or not dpg.does_item_exist(self.timeline_indicator_tag): return @@ -149,7 +156,7 @@ class DataTreeView: self.ui_lock = ui_lock self.current_search = "" self.data_tree = DataTreeNode(name="root") - self.ui_render_queue: deque[tuple[DataTreeNode, str, str, bool]] = deque() # (node, parent_tag, search_term, is_leaf) + self.ui_render_queue: deque[tuple[DataTreeNode, str, str, bool]] = deque() # (node, parent_tag, search_term, is_leaf) self.visible_expanded_nodes: set[str] = set() self.created_leaf_paths: set[str] = set() self._all_paths_cache: list[str] = [] @@ -176,7 +183,6 @@ class DataTreeView: for child in sorted(self.data_tree.children.values(), key=self._natural_sort_key): self.ui_render_queue.append((child, "data_tree_container", search_term, child.is_leaf)) - def _add_paths_to_tree(self, paths, incremental=False): search_term = self.current_search.strip().lower() filtered_paths = [path for path in paths if self._should_show_path(path, search_term)] @@ -228,7 +234,7 @@ class DataTreeView: def update_frame(self): items_processed = 0 - while self.ui_render_queue and items_processed < self.MAX_ITEMS_PER_FRAME: # process up to MAX_ITEMS_PER_FRAME to maintain performance + while self.ui_render_queue and items_processed < self.MAX_ITEMS_PER_FRAME: # process up to MAX_ITEMS_PER_FRAME to maintain performance node, parent_tag, search_term, is_leaf = self.ui_render_queue.popleft() if is_leaf: self._create_leaf_ui(node, parent_tag) @@ -269,7 +275,7 @@ class DataTreeView: node.ui_tag = node_tag label = f"{node.name} ({node.child_count} fields)" - should_open = (bool(search_term) and len(search_term) > 1 and any(search_term in path for path in self._get_descendant_paths(node))) + should_open = bool(search_term) and len(search_term) > 1 and any(search_term in path for path in self._get_descendant_paths(node)) with dpg.tree_node(label=label, parent=parent_tag, tag=node_tag, default_open=should_open, open_on_arrow=True, open_on_double_click=True) as tree_node: with dpg.item_handler_registry() as handler: