diff --git a/tools/jotpluggler/assets/plus.png b/tools/jotpluggler/assets/plus.png new file mode 100644 index 0000000000..6f8388b24d --- /dev/null +++ b/tools/jotpluggler/assets/plus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:248b71eafd1b42b0861da92114da3d625221cd88121fff01e0514bf3d79ff3b1 +size 1364 diff --git a/tools/jotpluggler/layout.py b/tools/jotpluggler/layout.py index 917c156f9f..b73b7467a8 100644 --- a/tools/jotpluggler/layout.py +++ b/tools/jotpluggler/layout.py @@ -5,15 +5,135 @@ from openpilot.tools.jotpluggler.views import TimeSeriesPanel GRIP_SIZE = 4 MIN_PANE_SIZE = 60 +class LayoutManager: + def __init__(self, data_manager, playback_manager, worker_manager, scale: float = 1.0): + self.data_manager = data_manager + self.playback_manager = playback_manager + self.worker_manager = worker_manager + self.scale = scale + self.container_tag = "plot_layout_container" + self.tab_bar_tag = "tab_bar_container" + self.tab_content_tag = "tab_content_area" + + self.active_tab = 0 + initial_panel_layout = PanelLayoutManager(data_manager, playback_manager, worker_manager, scale) + self.tabs: dict = {0: {"name": "Tab 1", "panel_layout": initial_panel_layout}} + self._next_tab_id = self.active_tab + 1 + + self._create_tab_themes() + + def _create_tab_themes(self): + for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))): + with dpg.theme(tag=tag): + for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)): + with dpg.theme_component(cmp): + dpg.add_theme_color(target, color) + with dpg.theme(tag="tab_bar_theme"): + with dpg.theme_component(dpg.mvChildWindow): + dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255)) + + 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, no_scroll_with_mouse=True): + self._create_tab_bar() + self._create_tab_content() + dpg.bind_item_theme(self.tab_bar_tag, "tab_bar_theme") + + def _create_tab_bar(self): + text_size = int(13 * self.scale) + with dpg.child_window(tag=self.tab_bar_tag, parent=self.container_tag, height=(text_size + 8), border=False, horizontal_scrollbar=True): + with dpg.group(horizontal=True, tag="tab_bar_group"): + for tab_id, tab_data in self.tabs.items(): + self._create_tab_ui(tab_id, tab_data["name"]) + dpg.add_image_button(texture_tag="plus_texture", callback=self.add_tab, width=text_size, height=text_size, tag="add_tab_button") + dpg.bind_item_theme("add_tab_button", "inactive_tab_theme") + + def _create_tab_ui(self, tab_id: int, tab_name: str): + text_size = int(13 * self.scale) + tab_width = int(120 * self.scale) + with dpg.child_window(width=tab_width, height=-1, border=False, no_scrollbar=True, tag=f"tab_window_{tab_id}", parent="tab_bar_group"): + with dpg.group(horizontal=True, tag=f"tab_group_{tab_id}"): + dpg.add_input_text( + default_value=tab_name, width=tab_width - text_size - 16, callback=lambda s, v, u: self.rename_tab(u, v), user_data=tab_id, tag=f"tab_input_{tab_id}" + ) + dpg.add_image_button( + texture_tag="x_texture", callback=lambda s, a, u: self.close_tab(u), user_data=tab_id, width=text_size, height=text_size, tag=f"tab_close_{tab_id}" + ) + with dpg.item_handler_registry(tag=f"tab_handler_{tab_id}"): + dpg.add_item_clicked_handler(callback=lambda s, a, u: self.switch_tab(u), user_data=tab_id) + dpg.bind_item_handler_registry(f"tab_group_{tab_id}", f"tab_handler_{tab_id}") + + theme_tag = "active_tab_theme" if tab_id == self.active_tab else "inactive_tab_theme" + dpg.bind_item_theme(f"tab_window_{tab_id}", theme_tag) + + def _create_tab_content(self): + with dpg.child_window(tag=self.tab_content_tag, parent=self.container_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True): + active_panel_layout = self.tabs[self.active_tab]["panel_layout"] + active_panel_layout.create_ui() + + def add_tab(self): + new_panel_layout = PanelLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, self.scale) + new_tab = {"name": f"Tab {self._next_tab_id + 1}", "panel_layout": new_panel_layout} + self.tabs[ self._next_tab_id] = new_tab + self._create_tab_ui( self._next_tab_id, new_tab["name"]) + dpg.move_item("add_tab_button", parent="tab_bar_group") # move plus button to end + self.switch_tab( self._next_tab_id) + self._next_tab_id += 1 + + def close_tab(self, tab_id: int): + if len(self.tabs) <= 1: + return # don't allow closing the last tab + + tab_to_close = self.tabs[tab_id] + tab_to_close["panel_layout"].destroy_ui() + for suffix in ["window", "group", "input", "close", "handler"]: + tag = f"tab_{suffix}_{tab_id}" + if dpg.does_item_exist(tag): + dpg.delete_item(tag) + del self.tabs[tab_id] + + if self.active_tab == tab_id: # switch to another tab if we closed the active one + self.active_tab = next(iter(self.tabs.keys())) + self._switch_tab_content() + dpg.bind_item_theme(f"tab_window_{self.active_tab}", "active_tab_theme") + + def switch_tab(self, tab_id: int): + if tab_id == self.active_tab or tab_id not in self.tabs: + return -class PlotLayoutManager: + current_panel_layout = self.tabs[self.active_tab]["panel_layout"] + current_panel_layout.destroy_ui() + dpg.bind_item_theme(f"tab_window_{self.active_tab}", "inactive_tab_theme") # deactivate old tab + self.active_tab = tab_id + dpg.bind_item_theme(f"tab_window_{tab_id}", "active_tab_theme") # activate new tab + self._switch_tab_content() + + def _switch_tab_content(self): + dpg.delete_item(self.tab_content_tag, children_only=True) + active_panel_layout = self.tabs[self.active_tab]["panel_layout"] + active_panel_layout.create_ui() + active_panel_layout.update_all_panels() + + def rename_tab(self, tab_id: int, new_name: str): + if tab_id in self.tabs: + self.tabs[tab_id]["name"] = new_name + + def update_all_panels(self): + self.tabs[self.active_tab]["panel_layout"].update_all_panels() + + def on_viewport_resize(self): + self.tabs[self.active_tab]["panel_layout"].on_viewport_resize() + +class PanelLayoutManager: def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0): self.data_manager = data_manager self.playback_manager = playback_manager self.worker_manager = worker_manager self.scale = scale - self.container_tag = "plot_layout_container" self.active_panels: list = [] + self.parent_tag = "tab_content_area" self.grip_size = int(GRIP_SIZE * self.scale) self.min_pane_size = int(MIN_PANE_SIZE * self.scale) @@ -21,13 +141,18 @@ class PlotLayoutManager: initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager) self.layout: dict = {"type": "panel", "panel": initial_panel} - def create_ui(self, parent_tag: str): - if dpg.does_item_exist(self.container_tag): - dpg.delete_item(self.container_tag) + def create_ui(self): + self.active_panels.clear() - with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=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) + if dpg.does_item_exist(self.parent_tag): + dpg.delete_item(self.parent_tag, children_only=True) + + container_width, container_height = dpg.get_item_rect_size(self.parent_tag) + self._create_ui_recursive(self.layout, self.parent_tag, [], container_width, container_height) + + def destroy_ui(self): + self._cleanup_ui_recursive(self.layout, []) + self.active_panels.clear() def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): if layout["type"] == "panel": @@ -35,14 +160,14 @@ class PlotLayoutManager: else: self._create_split_ui(layout, parent_tag, path, width, height) - def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height:int): + def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): panel_tag = self._path_to_tag(path, "panel") panel = layout["panel"] self.active_panels.append(panel) text_size = int(13 * self.scale) - bar_height = (text_size+24) if width < int(279 * self.scale + 80) else (text_size+8) # adjust height to allow for scrollbar + bar_height = (text_size + 24) if width < int(279 * self.scale + 64) else (text_size + 8) # adjust height to allow for scrollbar - with dpg.child_window(parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): + with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): with dpg.group(horizontal=True): with dpg.child_window(tag=panel_tag, width=-(text_size + 16), height=bar_height, horizontal_scrollbar=True, no_scroll_with_mouse=True, border=False): with dpg.group(horizontal=True): @@ -67,7 +192,7 @@ class PlotLayoutManager: for i, child_layout in enumerate(layout["children"]): child_path = path + [i] container_tag = self._path_to_tag(child_path, "container") - pane_width, pane_height = [(pane_sizes[i], -1), (-1, pane_sizes[i])][orientation] # fill 2nd dim up to the border + pane_width, pane_height = [(pane_sizes[i], -1), (-1, pane_sizes[i])][orientation] # fill 2nd dim up to the border with dpg.child_window(tag=container_tag, width=pane_width, height=pane_height, border=False, no_scrollbar=True): child_width, child_height = [(pane_sizes[i], height), (width, pane_sizes[i])][orientation] self._create_ui_recursive(child_layout, container_tag, child_path, child_width, child_height) @@ -137,7 +262,7 @@ class PlotLayoutManager: if path: container_tag = self._path_to_tag(path, "container") else: # Root update - container_tag = self.container_tag + container_tag = self.parent_tag self._cleanup_ui_recursive(layout, path) dpg.delete_item(container_tag, children_only=True) @@ -181,17 +306,17 @@ class PlotLayoutManager: dpg.configure_item(container_tag, **{size_properties[orientation]: pane_sizes[i]}) child_width, child_height = [(pane_sizes[i], available_sizes[1]), (available_sizes[0], pane_sizes[i])][orientation] self._resize_splits_recursive(child_layout, child_path, child_width, child_height) - else: # leaf node/panel - adjust bar height to allow for scrollbar + else: # leaf node/panel - adjust bar height to allow for scrollbar panel_tag = self._path_to_tag(path, "panel") - if width is not None and width < int(279 * self.scale + 80): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item - dpg.configure_item(panel_tag, height=(int(13*self.scale) + 24)) + if width is not None and width < int(279 * self.scale + 64): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item + dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 24)) else: - dpg.configure_item(panel_tag, height=(int(13*self.scale) + 8)) + dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 8)) def _get_split_geometry(self, layout: dict, available_size: tuple[int, int]) -> tuple[int, int, list[int]]: orientation = layout["orientation"] num_grips = len(layout["children"]) - 1 - usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2-orientation)))) # approximate, scaling is weird + usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2 - orientation)))) # approximate, scaling is weird pane_sizes = [max(self.min_pane_size, int(usable_size * prop)) for prop in layout["proportions"]] return orientation, usable_size, pane_sizes diff --git a/tools/jotpluggler/pluggle.py b/tools/jotpluggler/pluggle.py index 57ba2a245f..41bf520cd9 100755 --- a/tools/jotpluggler/pluggle.py +++ b/tools/jotpluggler/pluggle.py @@ -10,7 +10,7 @@ import signal from openpilot.common.basedir import BASEDIR from openpilot.tools.jotpluggler.data import DataManager from openpilot.tools.jotpluggler.datatree import DataTree -from openpilot.tools.jotpluggler.layout import PlotLayoutManager +from openpilot.tools.jotpluggler.layout import LayoutManager DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" @@ -127,7 +127,7 @@ class MainController: self.worker_manager = WorkerManager() self._create_global_themes() self.data_tree = DataTree(self.data_manager, self.playback_manager) - self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) + self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) self.data_manager.add_observer(self.on_data_loaded) def _create_global_themes(self): @@ -168,7 +168,7 @@ class MainController: def setup_ui(self): with dpg.texture_registry(): script_dir = os.path.dirname(os.path.realpath(__file__)) - for image in ["play", "pause", "x", "split_h", "split_v"]: + for image in ["play", "pause", "x", "split_h", "split_v", "plus"]: texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png")) dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture") @@ -186,7 +186,7 @@ class MainController: # Right panel - Plots and timeline with dpg.group(tag="right_panel"): with dpg.child_window(label="Plot Window", border=True, height=-(32 + 13 * self.scale), tag="main_plot_area"): - self.plot_layout_manager.create_ui("main_plot_area") + self.layout_manager.create_ui("main_plot_area") with dpg.child_window(label="Timeline", border=True): with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False): @@ -205,7 +205,7 @@ class MainController: dpg.set_primary_window("Primary Window", True) def on_plot_resize(self, sender, app_data, user_data): - self.plot_layout_manager.on_viewport_resize() + self.layout_manager.on_viewport_resize() def load_route(self): route_name = dpg.get_value("route_input").strip() @@ -227,7 +227,7 @@ class MainController: if not dpg.is_item_active("timeline_slider"): dpg.set_value("timeline_slider", new_time) - self.plot_layout_manager.update_all_panels() + self.layout_manager.update_all_panels() dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") diff --git a/tools/jotpluggler/views.py b/tools/jotpluggler/views.py index f3da6e3e6f..cb993acf0d 100644 --- a/tools/jotpluggler/views.py +++ b/tools/jotpluggler/views.py @@ -52,6 +52,8 @@ class TimeSeriesPanel(ViewPanel): self._results_deque: deque[tuple[str, list, list]] = deque() self._new_data = False self._last_x_limits = (0.0, 0.0) + self._queued_x_sync: tuple | None = None + self._queued_reallow_x_zoom = False def create_ui(self, parent_tag: str): self.data_manager.add_observer(self.on_data_loaded) @@ -63,8 +65,7 @@ class TimeSeriesPanel(ViewPanel): timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag) dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme") - for series_path in list(self._series_data.keys()): - self.add_series(series_path) + self._new_data = True self._ui_created = True def update(self): @@ -72,6 +73,19 @@ class TimeSeriesPanel(ViewPanel): if not self._ui_created: return + if self._queued_x_sync: + min_time, max_time = self._queued_x_sync + self._queued_x_sync = None + dpg.set_axis_limits(self.x_axis_tag, min_time, max_time) + self._last_x_limits = (min_time, max_time) + self._fit_y_axis(min_time, max_time) + self._queued_reallow_x_zoom = True # must wait a frame before allowing user changes so that axis limits take effect + return + + if self._queued_reallow_x_zoom: + self._queued_reallow_x_zoom = False + dpg.set_axis_limits_auto(self.x_axis_tag) + current_limits = dpg.get_axis_limits(self.x_axis_tag) # downsample if plot zoom changed significantly plot_duration = current_limits[1] - current_limits[0] @@ -112,13 +126,8 @@ class TimeSeriesPanel(ViewPanel): def _on_x_axis_sync(self, min_time: float, max_time: float, source_panel): with self._update_lock: - if source_panel == self or not self._ui_created: - return - dpg.set_axis_limits(self.x_axis_tag, min_time, max_time) - dpg.render_dearpygui_frame() - dpg.set_axis_limits_auto(self.x_axis_tag) - self._last_x_limits = (min_time, max_time) - self._fit_y_axis(min_time, max_time) + if source_panel != self: + self._queued_x_sync = (min_time, max_time) def _fit_y_axis(self, x_min: float, x_max: float): if not self._series_data: