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 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 @abstractmethod def preserve_data(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 self.scale = scale def preserve_data(self): self.panel.preserve_data() 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}") def _split(self, orientation: str): if self.layout_manager: self.layout_manager.split_node(self, orientation) def _clear(self): if hasattr(self.panel, 'clear_all_series'): self.panel.clear_all_series() def _delete(self): if self.layout_manager: self.layout_manager.delete_node(self) 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 preserve_data(self): for child in self.children: child.preserve_data() def add_child(self, child: LayoutNode, index: int = None): if index is None: self.children.append(child) self.child_proportions.append(0.0) 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 available_width, available_height = dpg.get_item_rect_size(dpg.get_item_parent(self.tag)) for i, container_tag in enumerate(self.child_container_tags): if not dpg.does_item_exist(container_tag): continue proportion = self.child_proportions[i] if i < len(self.child_proportions) else (1.0 / len(self.children)) 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) child = self.children[i] if i < len(self.children) else None if child and isinstance(child, SplitterNode): child.resize_children() def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): self.tag = f"splitter_{self.node_id}" self.child_container_tags = [] 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] 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] 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) 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() 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): 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 node.preserve_data() 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 node.preserve_data() 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 remaining_child.preserve_data() 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): old_child.preserve_data() # save data and for when recreating ui for the node 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): node.preserve_data() 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)