rewrite layout

jotpluggler
Quantizr (Jimmy) 2 weeks ago
parent 1cfa906201
commit 628e19ffb0
  1. 430
      tools/jotpluggler/layout.py
  2. 19
      tools/jotpluggler/pluggle.py
  3. 14
      tools/jotpluggler/views.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()

@ -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):

@ -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:

Loading…
Cancel
Save