#include "tools/jotpluggler/app.h" #include "tools/jotpluggler/camera.h" #include "tools/jotpluggler/common.h" #include "tools/jotpluggler/internal.h" #include "tools/jotpluggler/map.h" #include "system/hardware/hw.h" #include "imgui_impl_glfw.h" #include "imgui_internal.h" #include "imgui_impl_opengl3.h" #include "imgui_impl_opengl3_loader.h" #include "implot.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "json11/json11.hpp" namespace fs = std::filesystem; constexpr const char *UNTITLED_PANE_TITLE = "..."; ImFont *g_ui_font = nullptr; ImFont *g_ui_bold_font = nullptr; ImFont *g_mono_font = nullptr; std::string layout_name_from_arg(const std::string &layout_arg) { const fs::path raw(layout_arg); if (raw.extension() == ".xml" || raw.extension() == ".json") { return raw.stem().string(); } if (raw.filename() != raw) { return raw.filename().replace_extension("").string(); } fs::path stem_path = raw; return stem_path.replace_extension("").string(); } fs::path layouts_dir() { return repo_root() / "tools" / "jotpluggler" / "layouts"; } std::string sanitize_layout_stem(std::string_view name) { std::string out; out.reserve(name.size()); bool last_was_dash = false; for (const char raw : name) { const unsigned char c = static_cast(raw); if (std::isalnum(c) != 0) { out.push_back(static_cast(std::tolower(c))); last_was_dash = false; } else if (raw == '-' || raw == '_') { out.push_back(raw); last_was_dash = false; } else if (!last_was_dash && !out.empty()) { out.push_back('-'); last_was_dash = true; } } while (!out.empty() && out.back() == '-') { out.pop_back(); } return out.empty() ? "untitled" : out; } fs::path autosave_dir() { return layouts_dir() / ".jotpluggler_autosave"; } fs::path resolve_layout_path(const std::string &layout_arg) { const fs::path direct(layout_arg); if (fs::exists(direct)) { if (direct.extension() == ".json") return fs::absolute(direct); const fs::path sibling_json = direct.parent_path() / (direct.stem().string() + ".json"); if (direct.extension() == ".xml" && fs::exists(sibling_json)) { return fs::absolute(sibling_json); } } const fs::path candidate = layouts_dir() / (layout_name_from_arg(layout_arg) + ".json"); if (!fs::exists(candidate)) throw std::runtime_error("Unknown layout: " + layout_arg); return candidate; } fs::path autosave_path_for_layout(const fs::path &layout_path) { const std::string stem = layout_path.empty() ? "untitled" : layout_path.stem().string(); return autosave_dir() / (sanitize_layout_stem(stem) + ".json"); } std::vector available_layout_names() { std::vector names; const fs::path root = layouts_dir(); if (!fs::exists(root) || !fs::is_directory(root)) { return names; } for (const auto &entry : fs::directory_iterator(root)) { if (!entry.is_regular_file() || entry.path().extension() != ".json") { continue; } names.push_back(entry.path().stem().string()); } std::sort(names.begin(), names.end()); return names; } void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks) { state->tabs.clear(); cancel_rename_tab(state); sync_ui_state(state, session->layout); sync_layout_buffers(state, *session); if (mark_docks) { mark_all_docks_dirty(state); } } void start_new_layout(AppSession *session, UiState *state, const std::string &status_text) { session->layout = make_empty_layout(); session->layout_path.clear(); session->autosave_path.clear(); state->undo.reset(session->layout); state->layout_dirty = false; state->status_text = status_text; refresh_replaced_layout_ui(session, state, true); reset_shared_range(state, *session); } bool is_decoded_can_series_path(std::string_view path) { const std::string value(path); return util::starts_with(value, "/can/") || util::starts_with(value, "/sendcan/"); } bool apply_route_can_decode_update(AppSession *session, UiState *state); void rebuild_series_lookup_preserving_formats(AppSession *session, std::string_view updated_prefix, bool refresh_updated_formats_only) { const std::string prefix(updated_prefix); if (!updated_prefix.empty()) { for (auto it = session->route_data.series_formats.begin(); it != session->route_data.series_formats.end();) { if (util::starts_with(it->first, prefix)) { it = session->route_data.series_formats.erase(it); } else { ++it; } } } session->series_by_path.clear(); session->series_by_path.reserve(session->route_data.series.size()); for (RouteSeries &series : session->route_data.series) { session->series_by_path.emplace(series.path, &series); if (refresh_updated_formats_only) { if (!updated_prefix.empty() && util::starts_with(series.path, prefix)) { const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like); } } else { const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like); } } } bool apply_route_can_decode_update(AppSession *session, UiState *state) { const std::string active_dbc_name = !session->dbc_override.empty() ? session->dbc_override : session->route_data.dbc_name; if (!active_dbc_name.empty() && !load_dbc_by_name(active_dbc_name).has_value()) { state->error_text = "DBC not found: " + active_dbc_name; state->open_error_popup = true; return false; } std::unordered_map can_enum_info; std::vector can_series = decode_can_messages(session->route_data.can_messages, active_dbc_name, &can_enum_info); std::vector updated_series; updated_series.reserve(session->route_data.series.size() + can_series.size()); for (RouteSeries &series : session->route_data.series) { if (!is_decoded_can_series_path(series.path)) { updated_series.push_back(std::move(series)); } } for (RouteSeries &series : can_series) { updated_series.push_back(std::move(series)); } std::sort(updated_series.begin(), updated_series.end(), [](const RouteSeries &a, const RouteSeries &b) { return a.path < b.path; }); std::unordered_map updated_enum_info; updated_enum_info.reserve(session->route_data.enum_info.size() + can_enum_info.size()); for (auto &[path, info] : session->route_data.enum_info) { if (!is_decoded_can_series_path(path)) { updated_enum_info.emplace(path, std::move(info)); } } for (auto &[path, info] : can_enum_info) { updated_enum_info[path] = std::move(info); } session->route_data.series = std::move(updated_series); session->route_data.enum_info = std::move(updated_enum_info); session->route_data.paths.clear(); session->route_data.paths.reserve(session->route_data.series.size()); for (const RouteSeries &series : session->route_data.series) { session->route_data.paths.push_back(series.path); } std::sort(session->route_data.paths.begin(), session->route_data.paths.end()); session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths); rebuild_route_index(session); rebuild_browser_nodes(session, state); refresh_all_custom_curves(session, state); sync_camera_feeds(session); return true; } void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override) { session->dbc_override = dbc_override; if (session->data_mode == SessionDataMode::Stream) { start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, false); } else if (!session->route_name.empty()) { const bool ok = apply_route_can_decode_update(session, state); if (ok) { state->status_text = dbc_override.empty() ? "DBC auto-detect enabled" : "DBC set to " + dbc_override; } else { state->status_text = "Failed to apply DBC"; } } else if (dbc_override.empty()) { state->status_text = "DBC auto-detect enabled"; } else { state->status_text = "DBC set to " + dbc_override; } } void configure_style() { ImGui::StyleColorsLight(); ImPlot::StyleColorsLight(); ImGuiIO &io = ImGui::GetIO(); g_ui_font = nullptr; g_ui_bold_font = nullptr; g_mono_font = nullptr; const fs::path fonts_dir = repo_root() / "selfdrive" / "assets" / "fonts"; ImFontConfig font_cfg; font_cfg.OversampleH = 2; font_cfg.OversampleV = 2; font_cfg.RasterizerDensity = 1.0f; icon_add_font(16.0f); const auto add_font_with_icons = [&](const fs::path &path, float size) -> ImFont * { ImFont *font = io.Fonts->AddFontFromFileTTF(path.c_str(), size, &font_cfg); if (font != nullptr) { icon_add_font(size, true, font); } return font; }; if (ImFont *font = add_font_with_icons(fonts_dir / "Inter-Regular.ttf", 16.0f); font != nullptr) { g_ui_font = font; io.FontDefault = font; } g_ui_bold_font = add_font_with_icons(fonts_dir / "Inter-SemiBold.ttf", 16.75f); if (g_ui_font == nullptr) { if (ImFont *font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); font != nullptr) { g_mono_font = font; io.FontDefault = font; } } if (g_mono_font == nullptr) { g_mono_font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); } if (g_ui_bold_font == nullptr) { g_ui_bold_font = g_ui_font; } ImGuiStyle &style = ImGui::GetStyle(); style.WindowRounding = 0.0f; style.ChildRounding = 0.0f; style.PopupRounding = 0.0f; style.FrameRounding = 2.0f; style.ScrollbarRounding = 2.0f; style.GrabRounding = 2.0f; style.TabRounding = 0.0f; style.WindowBorderSize = 1.0f; style.ChildBorderSize = 1.0f; style.FrameBorderSize = 1.0f; style.WindowPadding = ImVec2(8.0f, 7.0f); style.FramePadding = ImVec2(6.0f, 3.0f); style.ItemSpacing = ImVec2(8.0f, 5.0f); style.ItemInnerSpacing = ImVec2(6.0f, 3.0f); struct ColorDef { ImGuiCol idx; int r, g, b; }; constexpr ColorDef COLORS[] = { {ImGuiCol_WindowBg, 250, 250, 251}, {ImGuiCol_ChildBg, 255, 255, 255}, {ImGuiCol_Border, 194, 198, 204}, {ImGuiCol_TitleBg, 252, 252, 253}, {ImGuiCol_TitleBgActive, 252, 252, 253}, {ImGuiCol_TitleBgCollapsed, 252, 252, 253}, {ImGuiCol_Text, 74, 80, 88}, {ImGuiCol_TextDisabled, 108, 118, 128}, {ImGuiCol_Button, 255, 255, 255}, {ImGuiCol_ButtonHovered, 246, 248, 250}, {ImGuiCol_ButtonActive, 238, 240, 244}, {ImGuiCol_FrameBg, 255, 255, 255}, {ImGuiCol_FrameBgHovered, 248, 249, 251}, {ImGuiCol_FrameBgActive, 241, 244, 248}, {ImGuiCol_Header, 243, 245, 248}, {ImGuiCol_HeaderHovered, 237, 240, 244}, {ImGuiCol_HeaderActive, 232, 236, 240}, {ImGuiCol_PopupBg, 248, 249, 251}, {ImGuiCol_MenuBarBg, 232, 236, 241}, {ImGuiCol_Separator, 194, 198, 204}, {ImGuiCol_ScrollbarBg, 240, 242, 245}, {ImGuiCol_ScrollbarGrab, 202, 207, 214}, {ImGuiCol_ScrollbarGrabHovered, 180, 186, 194}, {ImGuiCol_ScrollbarGrabActive, 164, 171, 180}, {ImGuiCol_Tab, 219, 224, 230}, {ImGuiCol_TabHovered, 232, 236, 241}, {ImGuiCol_TabSelected, 250, 251, 253}, {ImGuiCol_TabSelectedOverline, 92, 109, 136}, {ImGuiCol_TabDimmed, 213, 219, 226}, {ImGuiCol_TabDimmedSelected, 244, 247, 249}, {ImGuiCol_TabDimmedSelectedOverline, 92, 109, 136}, {ImGuiCol_DockingEmptyBg, 244, 246, 248}, }; for (const auto &c : COLORS) { style.Colors[c.idx] = color_rgb(c.r, c.g, c.b); } style.Colors[ImGuiCol_DockingPreview] = color_rgb(69, 115, 184, 0.22f); ImPlotStyle &plot_style = ImPlot::GetStyle(); plot_style.PlotBorderSize = 1.0f; plot_style.MinorAlpha = 0.65f; plot_style.LegendPadding = ImVec2(6.0f, 5.0f); plot_style.LegendInnerPadding = ImVec2(6.0f, 3.0f); plot_style.LegendSpacing = ImVec2(7.0f, 2.0f); plot_style.PlotPadding = ImVec2(4.0f, 8.0f); plot_style.FitPadding = ImVec2(0.02f, static_cast(PLOT_Y_PADDING_FRACTION)); ImPlot::MapInputDefault(); ImPlotInputMap &input_map = ImPlot::GetInputMap(); input_map.Pan = ImGuiMouseButton_Right; input_map.PanMod = ImGuiMod_None; input_map.Select = ImGuiMouseButton_Left; input_map.SelectCancel = ImGuiMouseButton_Right; input_map.SelectMod = ImGuiMod_None; } void app_push_mono_font() { if (g_mono_font != nullptr) { ImGui::PushFont(g_mono_font); } } void app_pop_mono_font() { if (g_mono_font != nullptr) { ImGui::PopFont(); } } void app_push_bold_font() { if (g_ui_bold_font != nullptr) { ImGui::PushFont(g_ui_bold_font); } } void app_pop_bold_font() { if (g_ui_bold_font != nullptr) { ImGui::PopFont(); } } UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width) { UiMetrics ui; ui.width = size.x; ui.height = size.y; ui.top_offset = top_offset; ui.sidebar_width = sidebar_width <= 0.0f ? 0.0f : std::clamp(sidebar_width, SIDEBAR_MIN_WIDTH, std::min(SIDEBAR_MAX_WIDTH, size.x * 0.6f)); ui.content_x = ui.sidebar_width; ui.content_y = top_offset; ui.content_w = std::max(1.0f, size.x - ui.content_x); ui.content_h = std::max(1.0f, size.y - ui.content_y - STATUS_BAR_HEIGHT); ui.status_bar_y = std::max(0.0f, size.y - STATUS_BAR_HEIGHT); return ui; } void sync_ui_state(UiState *state, const SketchLayout &layout) { const bool initializing = state->tabs.empty(); state->tabs.resize(layout.tabs.size()); if (layout.tabs.empty()) { state->active_tab_index = 0; state->requested_tab_index = -1; return; } if (initializing) { state->active_tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1); state->requested_tab_index = state->active_tab_index; } state->active_tab_index = std::clamp(state->active_tab_index, 0, static_cast(layout.tabs.size()) - 1); for (size_t i = 0; i < layout.tabs.size(); ++i) { if (state->tabs[i].runtime_id == 0) { state->tabs[i].runtime_id = state->next_tab_runtime_id++; } const int pane_count = static_cast(layout.tabs[i].panes.size()); state->tabs[i].map_panes.resize(static_cast(std::max(0, pane_count))); state->tabs[i].camera_panes.resize(static_cast(std::max(0, pane_count))); state->tabs[i].active_pane_index = pane_count <= 0 ? 0 : std::clamp(state->tabs[i].active_pane_index, 0, pane_count - 1); } } void resize_tab_pane_state(TabUiState *tab_state, size_t pane_count) { if (tab_state == nullptr) return; tab_state->map_panes.resize(pane_count); tab_state->camera_panes.resize(pane_count); } void sync_route_buffers(UiState *state, const AppSession &session) { state->route_buffer = session.route_name; state->data_dir_buffer = session.data_dir; } void sync_stream_buffers(UiState *state, const AppSession &session) { state->stream_address_buffer = session.stream_source.address; state->stream_source_kind = session.stream_source.kind; state->stream_buffer_seconds = session.stream_buffer_seconds; } fs::path default_layout_save_path(const AppSession &session) { return session.layout_path.empty() ? layouts_dir() / "new-layout.json" : session.layout_path; } void sync_layout_buffers(UiState *state, const AppSession &session) { state->load_layout_buffer = session.layout_path.empty() ? std::string() : session.layout_path.string(); state->save_layout_buffer = default_layout_save_path(session).string(); } const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state) { if (layout.tabs.empty()) return nullptr; const int index = std::clamp(state.active_tab_index, 0, static_cast(layout.tabs.size()) - 1); return &layout.tabs[static_cast(index)]; } WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state) { if (layout->tabs.empty()) return nullptr; const int index = std::clamp(state.active_tab_index, 0, static_cast(layout->tabs.size()) - 1); return &layout->tabs[static_cast(index)]; } TabUiState *app_active_tab_state(UiState *state) { if (state->tabs.empty()) return nullptr; const int index = std::clamp(state->active_tab_index, 0, static_cast(state->tabs.size()) - 1); return &state->tabs[static_cast(index)]; } std::string pane_window_name(int tab_runtime_id, int pane_index, const Pane &pane) { const char *title = pane.title.empty() ? UNTITLED_PANE_TITLE : pane.title.c_str(); return util::string_format("%s###tab%d_pane%d", title, tab_runtime_id, pane_index); } std::string tab_item_label(const WorkspaceTab &tab, int tab_runtime_id) { return util::string_format("%s##workspace_tab_%d", tab.tab_name.c_str(), tab_runtime_id); } void request_tab_selection(UiState *state, int tab_index) { state->active_tab_index = tab_index; state->requested_tab_index = tab_index; } void begin_rename_tab(const SketchLayout &layout, UiState *state, int tab_index) { if (tab_index < 0 || tab_index >= static_cast(layout.tabs.size())) { return; } state->rename_tab_buffer = layout.tabs[static_cast(tab_index)].tab_name; state->rename_tab_index = tab_index; state->focus_rename_tab_input = true; request_tab_selection(state, tab_index); } void cancel_rename_tab(UiState *state) { state->rename_tab_index = -1; state->focus_rename_tab_input = false; } ImGuiID dockspace_id_for_tab(int tab_runtime_id) { return ImHashStr(util::string_format("jotpluggler_dockspace_%d", tab_runtime_id).c_str()); } bool curve_has_local_samples(const Curve &curve) { return curve.xs.size() > 1 && curve.xs.size() == curve.ys.size(); } void mark_all_docks_dirty(UiState *state) { for (TabUiState &tab_state : state->tabs) { tab_state.dock_needs_build = true; } } void mark_tab_dock_dirty(UiState *state, int tab_index) { if (tab_index >= 0 && tab_index < static_cast(state->tabs.size())) { state->tabs[static_cast(tab_index)].dock_needs_build = true; } } void normalize_split_node(WorkspaceNode *node) { if (node->is_pane) { return; } for (WorkspaceNode &child : node->children) { normalize_split_node(&child); } if (node->children.empty()) { return; } if (node->children.size() == 1) { *node = node->children.front(); return; } if (node->sizes.size() != node->children.size()) { node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); return; } float total = 0.0f; for (float &size : node->sizes) { size = std::max(size, 0.0f); total += size; } if (total <= 0.0f) { node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); return; } for (float &size : node->sizes) { size /= total; } } void decrement_pane_indices(WorkspaceNode *node, int removed_index) { if (node->is_pane) { if (node->pane_index > removed_index) { node->pane_index -= 1; } return; } for (WorkspaceNode &child : node->children) { decrement_pane_indices(&child, removed_index); } } bool remove_pane_node(WorkspaceNode *node, int pane_index) { if (node->is_pane) return node->pane_index == pane_index; for (size_t i = 0; i < node->children.size();) { if (remove_pane_node(&node->children[i], pane_index)) { node->children.erase(node->children.begin() + static_cast(i)); if (i < node->sizes.size()) { node->sizes.erase(node->sizes.begin() + static_cast(i)); } } else { ++i; } } normalize_split_node(node); return !node->is_pane && node->children.empty(); } bool split_pane_node(WorkspaceNode *node, int target_pane_index, SplitOrientation orientation, bool new_before, int new_pane_index) { if (node->is_pane) { if (node->pane_index != target_pane_index) return false; WorkspaceNode existing_pane; existing_pane.is_pane = true; existing_pane.pane_index = target_pane_index; WorkspaceNode new_pane; new_pane.is_pane = true; new_pane.pane_index = new_pane_index; node->is_pane = false; node->pane_index = -1; node->orientation = orientation; node->sizes = {0.5f, 0.5f}; node->children.clear(); if (new_before) { node->children.push_back(std::move(new_pane)); node->children.push_back(std::move(existing_pane)); } else { node->children.push_back(std::move(existing_pane)); node->children.push_back(std::move(new_pane)); } return true; } if (node->orientation == orientation) { for (size_t i = 0; i < node->children.size(); ++i) { WorkspaceNode &child = node->children[i]; if (!child.is_pane || child.pane_index != target_pane_index) { continue; } WorkspaceNode new_pane; new_pane.is_pane = true; new_pane.pane_index = new_pane_index; const auto insert_it = node->children.begin() + static_cast(new_before ? i : i + 1); node->children.insert(insert_it, std::move(new_pane)); node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); return true; } } for (WorkspaceNode &child : node->children) { if (split_pane_node(&child, target_pane_index, orientation, new_before, new_pane_index)) return true; } return false; } Pane make_empty_pane(const std::string &title = UNTITLED_PANE_TITLE) { Pane pane; pane.title = title; return pane; } WorkspaceTab make_empty_tab(const std::string &tab_name) { WorkspaceTab tab; tab.tab_name = tab_name; tab.panes.push_back(make_empty_pane()); tab.root.is_pane = true; tab.root.pane_index = 0; return tab; } SketchLayout make_empty_layout() { SketchLayout layout; layout.tabs.push_back(make_empty_tab("tab1")); layout.current_tab_index = 0; layout.roots.push_back("layout"); return layout; } bool tab_name_exists(const SketchLayout &layout, const std::string &name) { return std::any_of(layout.tabs.begin(), layout.tabs.end(), [&](const WorkspaceTab &tab) { return tab.tab_name == name; }); } std::string next_tab_name(const SketchLayout &layout, const std::string &base_name) { if (base_name == "tab" || base_name == "tab1") { int max_suffix = 0; for (const WorkspaceTab &tab : layout.tabs) { if (tab.tab_name.size() > 3 && util::starts_with(tab.tab_name, "tab")) { const std::string suffix = tab.tab_name.substr(3); if (!suffix.empty() && std::all_of(suffix.begin(), suffix.end(), ::isdigit)) { max_suffix = std::max(max_suffix, std::stoi(suffix)); } } } return "tab" + std::to_string(std::max(1, max_suffix + 1)); } std::string base = base_name.empty() ? "tab" : base_name; if (!tab_name_exists(layout, base)) return base; for (int i = 2; i < 1000; ++i) { const std::string candidate = base + " " + std::to_string(i); if (!tab_name_exists(layout, candidate)) return candidate; } return base + " copy"; } void clear_layout_autosave(const AppSession &session) { if (!session.autosave_path.empty() && fs::exists(session.autosave_path)) { fs::remove(session.autosave_path); } } bool autosave_layout(AppSession *session, UiState *state) { try { if (session->autosave_path.empty()) { session->autosave_path = autosave_path_for_layout(session->layout_path); } session->layout.current_tab_index = state->active_tab_index; save_layout_json(session->layout, session->autosave_path); state->layout_dirty = true; return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Failed to save layout draft"; return false; } } bool mark_layout_dirty(AppSession *session, UiState *state) { return autosave_layout(session, state); } bool active_tab_has_map_pane(const SketchLayout &layout) { if (layout.tabs.empty()) { return false; } const int tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1); const WorkspaceTab &tab = layout.tabs[static_cast(tab_index)]; return std::any_of(tab.panes.begin(), tab.panes.end(), [](const Pane &pane) { return pane_kind_is_special(pane.kind); }); } void draw_browser_special_item(const char *item_id, const char *label) { const ImGuiStyle &style = ImGui::GetStyle(); const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight()); ImGui::PushID(item_id); ImGui::InvisibleButton("##special_data_row", row_size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); ImDrawList *draw_list = ImGui::GetWindowDrawList(); if (hovered) { const ImU32 bg = ImGui::GetColorU32(held ? ImGuiCol_HeaderActive : ImGuiCol_HeaderHovered); draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f); } ImGui::RenderTextEllipsis(draw_list, ImVec2(rect.Min.x + style.FramePadding.x, rect.Min.y + style.FramePadding.y), ImVec2(rect.Max.x - style.FramePadding.x, rect.Max.y), rect.Max.x - style.FramePadding.x, label, nullptr, nullptr); if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { ImGui::SetDragDropPayload("JOTP_SPECIAL_ITEM", item_id, std::strlen(item_id) + 1); ImGui::TextUnformatted(label); ImGui::EndDragDropSource(); } ImGui::PopID(); } std::array app_next_curve_color(const Pane &pane) { static constexpr std::array, 10> PALETTE = {{ {35, 107, 180}, {220, 82, 52}, {67, 160, 71}, {243, 156, 18}, {123, 97, 255}, {0, 150, 136}, {214, 48, 49}, {52, 73, 94}, {197, 90, 17}, {96, 125, 139}, }}; return PALETTE[pane.curves.size() % PALETTE.size()]; } void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed) { ImGui::SetNextWindowPos(ImVec2(0.0f, ui.top_offset)); ImGui::SetNextWindowSize(ImVec2(ui.sidebar_width, std::max(1.0f, ui.height - ui.top_offset))); ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(238, 240, 244)); ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(190, 197, 205)); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings; if (ImGui::Begin("##sidebar", nullptr, flags)) { const RouteLoadSnapshot load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; const bool show_load_progress = session->route_loader && (load.active || load.total_segments > 0); const bool streaming = session->data_mode == SessionDataMode::Stream; CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); if (show_camera_feed && sidebar_camera != nullptr) { sidebar_camera->draw(ImGui::GetContentRegionAvail().x, load.active); } else if (streaming) { ImGui::SeparatorText("Camera"); ImGui::TextDisabled("Camera not available during live stream."); ImGui::Spacing(); } ImGui::SeparatorText(streaming ? "Stream" : "Route"); if (streaming) { const StreamPollSnapshot stream = session->stream_poller ? session->stream_poller->snapshot() : StreamPollSnapshot{}; const bool paused = stream.paused || session->stream_paused; const bool live = stream.connected && !paused; const ImVec4 status_color = live ? color_rgb(38, 135, 67) : (paused ? color_rgb(168, 119, 34) : color_rgb(155, 63, 63)); ImGui::TextColored(status_color, "%s %s", live ? "●" : "○", stream.source_label.c_str()); ImGui::TextDisabled("%s%s", stream_source_kind_label(stream.source_kind), paused ? " paused" : ""); const double span = session->route_data.has_time_range ? (session->route_data.x_max - session->route_data.x_min) : 0.0; const float fill = stream.buffer_seconds <= 0.0 ? 0.0f : std::clamp(static_cast(span / stream.buffer_seconds), 0.0f, 1.0f); ImGui::ProgressBar(fill, ImVec2(-FLT_MIN, 0.0f), nullptr); ImGui::TextDisabled("%.0fs buffer | %zu series", session->stream_buffer_seconds, session->route_data.series.size()); const char *button_label = paused ? "Resume" : "Pause"; if (ImGui::Button(button_label, ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), 0.0f))) { if (paused) { start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, true); } else { stop_stream_session(session, state); state->status_text = "Paused stream " + stream_source_target_label(session->stream_source); } } } else if (session->route_name.empty()) { ImGui::TextDisabled("No route loaded"); } if (!session->route_data.car_fingerprint.empty()) { ImGui::TextWrapped("Car: %s", session->route_data.car_fingerprint.c_str()); } const std::vector dbc_names = available_dbc_names(); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::BeginCombo("##dbc_combo", dbc_combo_label(*session).c_str())) { const bool auto_selected = session->dbc_override.empty(); if (ImGui::Selectable("Auto", auto_selected)) { apply_dbc_override_change(session, state, {}); } if (auto_selected) { ImGui::SetItemDefaultFocus(); } ImGui::Separator(); for (const std::string &dbc_name : dbc_names) { const bool selected = session->dbc_override == dbc_name; if (ImGui::Selectable(dbc_name.c_str(), selected) && !selected) { apply_dbc_override_change(session, state, dbc_name); } if (selected) { ImGui::SetItemDefaultFocus(); } } ImGui::EndCombo(); } ImGui::SeparatorText("Layout"); ImGui::SetNextItemWidth(-FLT_MIN); const std::string layout_combo_label = [&] { const std::string base = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string(); return state->layout_dirty ? base + " *" : base; }(); if (ImGui::BeginCombo("##layout_combo", layout_combo_label.c_str())) { if (ImGui::Selectable("New Layout")) { start_new_layout(session, state); } ImGui::Separator(); const std::vector layouts = available_layout_names(); const std::string current_layout = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string(); for (const std::string &layout_name : layouts) { const bool selected = layout_name == current_layout; if (ImGui::Selectable(layout_name.c_str(), selected) && !selected) { reload_layout(session, state, layout_name); } if (selected) { ImGui::SetItemDefaultFocus(); } } ImGui::EndCombo(); } const float layout_button_gap = ImGui::GetStyle().ItemSpacing.x; const float layout_row_width = std::max(1.0f, ImGui::GetContentRegionAvail().x); const float layout_button_width = std::max(1.0f, (layout_row_width - 2.0f * layout_button_gap) / 3.0f); if (ImGui::Button("New", ImVec2(layout_button_width, 0.0f))) { start_new_layout(session, state); } ImGui::SameLine(0.0f, layout_button_gap); if (ImGui::Button("Save", ImVec2(layout_button_width, 0.0f))) { state->request_save_layout = true; } ImGui::SameLine(0.0f, layout_button_gap); ImGui::BeginDisabled(!state->layout_dirty); if (ImGui::Button("Reset", ImVec2(layout_button_width, 0.0f))) { state->request_reset_layout = true; } ImGui::EndDisabled(); ImGui::Spacing(); ImGui::SeparatorText("Data Sources"); ImGui::SetNextItemWidth(-FLT_MIN); input_text_with_hint_string("##browser_filter", "Search...", &state->browser_filter); const float footer_height = ImGui::GetFrameHeightWithSpacing() + ImGui::GetTextLineHeightWithSpacing() + 16.0f + (show_load_progress ? (ImGui::GetFrameHeightWithSpacing() + 12.0f) : 0.0f); const float browser_height = std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 2.0f)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8.0f, 3.0f)); if (ImGui::BeginChild("##timeseries_browser", ImVec2(0.0f, browser_height), true)) { const std::string filter = lowercase_copy(state->browser_filter); std::vector visible_paths; for (const BrowserNode &node : session->browser_nodes) { collect_visible_leaf_paths(node, filter, &visible_paths); } for (const SpecialItemSpec &spec : kSpecialItemSpecs) { draw_browser_special_item(spec.id, spec.label); } ImGui::Dummy(ImVec2(0.0f, 2.0f)); ImGui::Separator(); ImGui::Dummy(ImVec2(0.0f, 2.0f)); for (const BrowserNode &node : session->browser_nodes) { draw_browser_node(session, node, state, filter, visible_paths); } } ImGui::EndChild(); ImGui::PopStyleVar(2); ImGui::SeparatorText("Custom Series"); if (ImGui::Button("Create...", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), 0.0f))) { open_custom_series_editor(state, state->selected_browser_path); } if (show_load_progress) { const float total = static_cast(std::max(1, load.total_segments)); const bool finalizing = load.active && load.total_segments > 0 && load.segments_downloaded >= load.total_segments && load.segments_parsed >= load.total_segments; const float progress = load.total_segments == 0 ? 0.0f : (finalizing ? 0.99f : std::clamp(static_cast(load.segments_downloaded + load.segments_parsed) / (2.0f * total), 0.0f, 0.99f)); ImGui::Dummy(ImVec2(0.0f, 8.0f)); ImGui::ProgressBar(progress, ImVec2(-FLT_MIN, 0.0f), finalizing ? "Finalizing..." : nullptr); } } ImGui::End(); ImGui::PopStyleColor(2); } std::string app_curve_display_name(const Curve &curve) { if (!curve.label.empty()) return curve.label; if (!curve.name.empty()) return curve.name; return "curve"; } Curve make_curve_for_path(const Pane &pane, const std::string &path) { Curve curve; curve.name = path; curve.label = path; curve.color = app_next_curve_color(pane); return curve; } bool add_curve_to_pane(WorkspaceTab *tab, int pane_index, Curve curve) { if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { return false; } Pane &pane = tab->panes[static_cast(pane_index)]; if (pane.kind != PaneKind::Plot) { pane.kind = PaneKind::Plot; if (is_default_special_title(pane.title)) { pane.title = UNTITLED_PANE_TITLE; } } for (Curve &existing : pane.curves) { const bool same_named_curve = !curve.name.empty() && existing.name == curve.name; const bool same_unnamed_curve = curve.name.empty() && existing.name.empty() && existing.label == curve.label; if (same_named_curve || same_unnamed_curve) { existing.visible = true; return false; } } pane.curves.push_back(std::move(curve)); return true; } bool add_path_curve_to_pane(AppSession *session, UiState *state, int pane_index, const std::string &path) { if (app_find_route_series(*session, path) == nullptr) { state->status_text = "Path not found in route"; return false; } WorkspaceTab *tab = app_active_tab(&session->layout, *state); if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { state->status_text = "No active pane"; return false; } const SketchLayout before_layout = session->layout; const bool inserted = add_curve_to_pane(tab, pane_index, make_curve_for_path(tab->panes[static_cast(pane_index)], path)); bool autosave_ok = true; if (inserted) { state->undo.push(before_layout); autosave_ok = mark_layout_dirty(session, state); } if (autosave_ok) { state->status_text = inserted ? "Added " + path : "Curve already present"; } return true; } int add_path_curves_to_pane(AppSession *session, UiState *state, int pane_index, const std::vector &paths) { WorkspaceTab *tab = app_active_tab(&session->layout, *state); if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { state->status_text = "No active pane"; return 0; } int inserted_count = 0; int duplicate_count = 0; const SketchLayout before_layout = session->layout; for (const std::string &path : paths) { if (app_find_route_series(*session, path) == nullptr) continue; if (add_curve_to_pane(tab, pane_index, make_curve_for_path(tab->panes[static_cast(pane_index)], path))) { ++inserted_count; } else { ++duplicate_count; } } if (inserted_count > 0) { state->undo.push(before_layout); if (mark_layout_dirty(session, state)) { state->status_text = inserted_count == 1 ? "Added " + paths.front() : "Added " + std::to_string(inserted_count) + " curves"; } return inserted_count; } if (duplicate_count > 0) { state->status_text = duplicate_count == 1 ? "Curve already present" : "Curves already present"; } else { state->status_text = "No matching series found"; } return 0; } bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path) { const TabUiState *tab_state = app_active_tab_state(state); if (tab_state == nullptr) { state->status_text = "No active pane"; return false; } return add_path_curve_to_pane(session, state, tab_state->active_pane_index, path); } bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pane_index, std::string_view item_id) { if (tab == nullptr || tab_state == nullptr) return false; if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) return false; const SpecialItemSpec *spec = special_item_spec(item_id); if (spec == nullptr) return false; Pane &pane = tab->panes[static_cast(pane_index)]; if (!((pane.kind == PaneKind::Plot && pane.curves.empty()) || pane_kind_is_special(pane.kind))) { return false; } if (pane.kind == spec->kind && (spec->kind != PaneKind::Camera || pane.camera_view == spec->camera_view)) { tab_state->active_pane_index = pane_index; return false; } const PaneKind previous_kind = pane.kind; pane.kind = spec->kind; pane.camera_view = spec->camera_view; if (spec->kind == PaneKind::Map) { if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { pane.title = spec->label; } } else { pane.title = spec->label; resize_tab_pane_state(tab_state, tab->panes.size()); tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; } tab_state->active_pane_index = pane_index; return true; } bool split_pane(WorkspaceTab *tab, int pane_index, PaneDropZone zone, std::optional curve = std::nullopt) { if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { return false; } if (zone == PaneDropZone::Center) return false; const int new_pane_index = static_cast(tab->panes.size()); Pane new_pane = make_empty_pane(); if (curve.has_value()) { new_pane.curves.push_back(*curve); } tab->panes.push_back(std::move(new_pane)); const bool vertical = zone == PaneDropZone::Top || zone == PaneDropZone::Bottom; const bool new_before = zone == PaneDropZone::Left || zone == PaneDropZone::Top; return split_pane_node(&tab->root, pane_index, vertical ? SplitOrientation::Vertical : SplitOrientation::Horizontal, new_before, new_pane_index); } bool close_pane(WorkspaceTab *tab, int pane_index) { if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { return false; } if (tab->panes.size() <= 1) { tab->panes[static_cast(pane_index)] = make_empty_pane(); return true; } if (remove_pane_node(&tab->root, pane_index)) return false; tab->panes.erase(tab->panes.begin() + static_cast(pane_index)); decrement_pane_indices(&tab->root, pane_index); normalize_split_node(&tab->root); return true; } void clear_pane(WorkspaceTab *tab, int pane_index) { if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { return; } Pane &pane = tab->panes[static_cast(pane_index)]; pane.curves.clear(); pane.title = UNTITLED_PANE_TITLE; } void create_runtime_tab(SketchLayout *layout, UiState *state) { const std::string tab_name = next_tab_name(*layout, "tab1"); layout->tabs.push_back(make_empty_tab(tab_name)); state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = 0, .runtime_id = state->next_tab_runtime_id++}); request_tab_selection(state, static_cast(layout->tabs.size()) - 1); state->status_text = "Created " + tab_name; } void duplicate_runtime_tab(SketchLayout *layout, UiState *state) { if (layout->tabs.empty()) { return; } const int source_index = std::clamp(state->active_tab_index, 0, static_cast(layout->tabs.size()) - 1); WorkspaceTab copy = layout->tabs[static_cast(source_index)]; copy.tab_name = next_tab_name(*layout, copy.tab_name + " copy"); layout->tabs.push_back(std::move(copy)); const int active_pane_index = source_index < static_cast(state->tabs.size()) ? state->tabs[static_cast(source_index)].active_pane_index : 0; state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = active_pane_index, .runtime_id = state->next_tab_runtime_id++}); request_tab_selection(state, static_cast(layout->tabs.size()) - 1); state->status_text = "Duplicated tab"; } void close_runtime_tab(SketchLayout *layout, UiState *state) { if (layout->tabs.empty()) { return; } const int tab_index = std::clamp(state->active_tab_index, 0, static_cast(layout->tabs.size()) - 1); if (layout->tabs.size() == 1) { layout->tabs[0] = make_empty_tab(layout->tabs[0].tab_name.empty() ? "tab1" : layout->tabs[0].tab_name); if (state->tabs.empty()) { state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = 0}); } else { state->tabs.resize(1); state->tabs[0] = TabUiState{ .dock_needs_build = true, .active_pane_index = 0, .runtime_id = state->tabs[0].runtime_id == 0 ? state->next_tab_runtime_id++ : state->tabs[0].runtime_id, }; } state->active_tab_index = 0; state->requested_tab_index = 0; layout->current_tab_index = 0; cancel_rename_tab(state); state->status_text = "Closed tab"; return; } layout->tabs.erase(layout->tabs.begin() + static_cast(tab_index)); if (tab_index < static_cast(state->tabs.size())) { state->tabs.erase(state->tabs.begin() + static_cast(tab_index)); } if (state->active_tab_index >= static_cast(layout->tabs.size())) { state->active_tab_index = static_cast(layout->tabs.size()) - 1; } sync_ui_state(state, *layout); state->requested_tab_index = state->active_tab_index; state->status_text = "Closed tab"; } void rename_runtime_tab(SketchLayout *layout, UiState *state) { if (state->rename_tab_index < 0 || state->rename_tab_index >= static_cast(layout->tabs.size())) { return; } layout->tabs[static_cast(state->rename_tab_index)].tab_name = state->rename_tab_buffer; state->status_text = "Renamed tab"; layout->current_tab_index = state->rename_tab_index; cancel_rename_tab(state); } void draw_inline_tab_editor(AppSession *session, UiState *state, const ImRect &tab_rect) { const int rename_tab_index = state->rename_tab_index; if (rename_tab_index < 0 || rename_tab_index >= static_cast(session->layout.tabs.size())) { return; } const float width = std::max(48.0f, tab_rect.Max.x - tab_rect.Min.x - 10.0f); const ImVec2 pos = ImVec2(tab_rect.Min.x + 5.0f, tab_rect.Min.y + 2.0f); ImGui::SetCursorScreenPos(pos); ImGui::PushItemWidth(width); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, 2.0f)); if (state->focus_rename_tab_input) { ImGui::SetKeyboardFocusHere(); state->focus_rename_tab_input = false; } const bool submitted = input_text_string("##rename_tab_inline", &state->rename_tab_buffer, ImGuiInputTextFlags_AutoSelectAll | ImGuiInputTextFlags_EnterReturnsTrue); const bool active = ImGui::IsItemActive(); const bool escape = active && ImGui::IsKeyPressed(ImGuiKey_Escape); const bool deactivated = ImGui::IsItemDeactivated(); ImGui::PopStyleVar(); ImGui::PopItemWidth(); if (escape) { cancel_rename_tab(state); } else if (submitted || deactivated) { const SketchLayout before_layout = session->layout; rename_runtime_tab(&session->layout, state); state->undo.push(before_layout); mark_layout_dirty(session, state); } } std::optional draw_pane_drop_target(int tab_index, int pane_index, const Pane &target_pane) { if (ImGui::GetDragDropPayload() == nullptr) return std::nullopt; const ImVec2 window_pos = ImGui::GetWindowPos(); const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); const ImVec2 content_max = ImGui::GetWindowContentRegionMax(); ImRect content_rect(ImVec2(window_pos.x + content_min.x, window_pos.y + content_min.y), ImVec2(window_pos.x + content_max.x, window_pos.y + content_max.y)); content_rect.Expand(ImVec2(-6.0f, -6.0f)); if (content_rect.GetWidth() < 60.0f || content_rect.GetHeight() < 60.0f) { return std::nullopt; } const float edge_w = std::min(90.0f, content_rect.GetWidth() * 0.24f); const float edge_h = std::min(72.0f, content_rect.GetHeight() * 0.24f); struct ZoneRect { PaneDropZone zone; ImRect rect; }; const std::array zones = {{ {PaneDropZone::Left, ImRect(content_rect.Min, ImVec2(content_rect.Min.x + edge_w, content_rect.Max.y))}, {PaneDropZone::Right, ImRect(ImVec2(content_rect.Max.x - edge_w, content_rect.Min.y), content_rect.Max)}, {PaneDropZone::Top, ImRect(content_rect.Min, ImVec2(content_rect.Max.x, content_rect.Min.y + edge_h))}, {PaneDropZone::Bottom, ImRect(ImVec2(content_rect.Min.x, content_rect.Max.y - edge_h), content_rect.Max)}, {PaneDropZone::Center, ImRect(ImVec2(content_rect.Min.x + edge_w, content_rect.Min.y + edge_h), ImVec2(content_rect.Max.x - edge_w, content_rect.Max.y - edge_h))}, }}; ImDrawList *draw_list = ImGui::GetWindowDrawList(); for (const ZoneRect &zone : zones) { if (zone.rect.GetWidth() <= 0.0f || zone.rect.GetHeight() <= 0.0f) { continue; } ImGui::PushID(static_cast(zone.zone) * 1000 + pane_index + tab_index * 100); ImGui::SetCursorScreenPos(zone.rect.Min); ImGui::InvisibleButton("##drop_zone", zone.rect.GetSize()); if (ImGui::BeginDragDropTarget()) { auto try_accept = [&](const char *type) -> const ImGuiPayload * { const ImGuiPayload *p = ImGui::AcceptDragDropPayload(type, ImGuiDragDropFlags_AcceptBeforeDelivery); if (p && p->Preview) { draw_list->AddRectFilled(zone.rect.Min, zone.rect.Max, IM_COL32(70, 130, 220, 55)); draw_list->AddRect(zone.rect.Min, zone.rect.Max, IM_COL32(45, 95, 175, 220), 0.0f, 0, 2.0f); } return p; }; auto deliver = [&](PaneDropAction action) -> std::optional { action.zone = zone.zone; action.target_pane_index = pane_index; ImGui::EndDragDropTarget(); ImGui::PopID(); return action; }; if (const ImGuiPayload *p = try_accept("JOTP_BROWSER_PATHS"); p && p->Delivery) { if (zone.zone != PaneDropZone::Center || target_pane.kind == PaneKind::Plot) { PaneDropAction action; action.from_browser = true; action.browser_paths = decode_browser_drag_payload(static_cast(p->Data)); return deliver(std::move(action)); } } if (zone.zone != PaneDropZone::Center || (target_pane.kind == PaneKind::Plot && target_pane.curves.empty()) || pane_kind_is_special(target_pane.kind)) { if (const ImGuiPayload *p = try_accept("JOTP_SPECIAL_ITEM"); p && p->Delivery) { PaneDropAction action; action.special_item_id = static_cast(p->Data); return deliver(std::move(action)); } } if (const ImGuiPayload *p = try_accept("JOTP_PANE_CURVE"); p && p->Delivery) { if (zone.zone != PaneDropZone::Center || target_pane.kind == PaneKind::Plot) { PaneDropAction action; action.curve_ref = *static_cast(p->Data); return deliver(std::move(action)); } } ImGui::EndDragDropTarget(); } ImGui::PopID(); } return std::nullopt; } bool commit_tab_layout_change(AppSession *session, UiState *state, WorkspaceTab *tab, TabUiState *tab_state, const SketchLayout &before_layout, std::string_view status_text, bool dock_changed) { if (dock_changed) { mark_tab_dock_dirty(state, state->active_tab_index); } resize_tab_pane_state(tab_state, tab->panes.size()); state->undo.push(before_layout); if (mark_layout_dirty(session, state)) { state->status_text = std::string(status_text); } return true; } bool apply_pane_menu_action(AppSession *session, UiState *state, int pane_index, const PaneMenuAction &action) { WorkspaceTab *tab = app_active_tab(&session->layout, *state); TabUiState *tab_state = app_active_tab_state(state); if (tab == nullptr || tab_state == nullptr) return false; const int original_pane_count = static_cast(tab->panes.size()); const SketchLayout before_layout = session->layout; bool dock_changed = false; bool layout_changed = false; std::string_view success_status = "Workspace updated"; switch (action.kind) { case PaneMenuActionKind::OpenAxisLimits: tab_state->active_pane_index = pane_index; open_axis_limits_editor(*session, state, pane_index); state->status_text = "Axis limits editor opened"; return true; case PaneMenuActionKind::OpenCustomSeries: tab_state->active_pane_index = pane_index; open_custom_series_editor(state, preferred_custom_series_source(tab->panes[static_cast(pane_index)])); state->status_text = "Custom series editor opened"; return true; case PaneMenuActionKind::SplitLeft: case PaneMenuActionKind::SplitRight: case PaneMenuActionKind::SplitTop: case PaneMenuActionKind::SplitBottom: { constexpr PaneDropZone kZones[] = {PaneDropZone::Left, PaneDropZone::Right, PaneDropZone::Top, PaneDropZone::Bottom}; const auto zone = kZones[static_cast(action.kind) - static_cast(PaneMenuActionKind::SplitLeft)]; if (split_pane(tab, pane_index, zone)) { tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; dock_changed = true; layout_changed = true; } break; } case PaneMenuActionKind::ResetView: reset_shared_range(state, *session); state->follow_latest = session->data_mode == SessionDataMode::Stream; state->suppress_range_side_effects = true; clamp_shared_range(state, *session); persist_shared_range_to_tab(tab, *state); clear_pane_vertical_limits(&tab->panes[static_cast(pane_index)]); layout_changed = true; success_status = "Plot view reset"; break; case PaneMenuActionKind::Clear: clear_pane(tab, pane_index); tab_state->active_pane_index = pane_index; layout_changed = true; break; case PaneMenuActionKind::Close: if (close_pane(tab, pane_index)) { tab_state->active_pane_index = std::clamp(pane_index, 0, static_cast(tab->panes.size()) - 1); layout_changed = true; dock_changed = static_cast(tab->panes.size()) != original_pane_count; } break; case PaneMenuActionKind::None: return false; } if (!layout_changed) { return false; } return commit_tab_layout_change(session, state, tab, tab_state, before_layout, success_status, dock_changed); } bool apply_pane_drop_action(AppSession *session, UiState *state, const PaneDropAction &action) { WorkspaceTab *tab = app_active_tab(&session->layout, *state); TabUiState *tab_state = app_active_tab_state(state); if (tab == nullptr || tab_state == nullptr) return false; if (!action.special_item_id.empty()) { const SpecialItemSpec *spec = special_item_spec(action.special_item_id); if (spec == nullptr) { return false; } if (action.zone == PaneDropZone::Center) { if (action.target_pane_index < 0 || action.target_pane_index >= static_cast(tab->panes.size())) { return false; } if (!((tab->panes[static_cast(action.target_pane_index)].kind == PaneKind::Plot && tab->panes[static_cast(action.target_pane_index)].curves.empty()) || pane_kind_is_special(tab->panes[static_cast(action.target_pane_index)].kind))) { state->status_text = std::string(special_item_label(action.special_item_id)) + " can only replace another special pane or use an empty pane"; return false; } const SketchLayout before_layout = session->layout; const bool changed = apply_special_item_to_pane(tab, tab_state, action.target_pane_index, spec->id); if (!changed) { state->status_text = std::string(special_item_label(action.special_item_id)) + " already shown in pane"; return false; } return commit_tab_layout_change(session, state, tab, tab_state, before_layout, std::string(special_item_label(action.special_item_id)) + " added to pane", false); } const SketchLayout before_layout = session->layout; if (split_pane(tab, action.target_pane_index, action.zone)) { tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; const bool changed = apply_special_item_to_pane(tab, tab_state, tab_state->active_pane_index, spec->id); if (!changed) { return false; } return commit_tab_layout_change(session, state, tab, tab_state, before_layout, "Split pane and added " + std::string(special_item_label(action.special_item_id)), true); } return false; } if (action.from_browser) { if (action.browser_paths.empty()) return false; if (action.zone == PaneDropZone::Center) { const int inserted_count = add_path_curves_to_pane(session, state, action.target_pane_index, action.browser_paths); if (inserted_count > 0) { tab_state->active_pane_index = action.target_pane_index; } return inserted_count > 0; } const SketchLayout before_layout = session->layout; if (split_pane(tab, action.target_pane_index, action.zone)) { tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; int inserted_count = 0; for (const std::string &path : action.browser_paths) { if (app_find_route_series(*session, path) == nullptr) continue; if (add_curve_to_pane(tab, tab_state->active_pane_index, make_curve_for_path(tab->panes[static_cast(tab_state->active_pane_index)], path))) { ++inserted_count; } } if (inserted_count > 0) { return commit_tab_layout_change(session, state, tab, tab_state, before_layout, inserted_count == 1 ? "Split pane and added " + action.browser_paths.front() : "Split pane and added " + std::to_string(inserted_count) + " curves", true); } return false; } return false; } if (action.curve_ref.tab_index < 0 || action.curve_ref.tab_index >= static_cast(session->layout.tabs.size())) { return false; } WorkspaceTab &source_tab = session->layout.tabs[static_cast(action.curve_ref.tab_index)]; if (action.curve_ref.pane_index < 0 || action.curve_ref.pane_index >= static_cast(source_tab.panes.size())) { return false; } const Pane &source_pane = source_tab.panes[static_cast(action.curve_ref.pane_index)]; if (action.curve_ref.curve_index < 0 || action.curve_ref.curve_index >= static_cast(source_pane.curves.size())) { return false; } const Curve curve = source_pane.curves[static_cast(action.curve_ref.curve_index)]; if (action.zone == PaneDropZone::Center) { const SketchLayout before_layout = session->layout; const bool inserted = add_curve_to_pane(tab, action.target_pane_index, curve); tab_state->active_pane_index = action.target_pane_index; if (inserted) { state->undo.push(before_layout); if (mark_layout_dirty(session, state)) { state->status_text = "Added " + app_curve_display_name(curve); } } else { state->status_text = "Curve already present"; } return true; } const SketchLayout before_layout = session->layout; if (split_pane(tab, action.target_pane_index, action.zone, curve)) { tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; return commit_tab_layout_change(session, state, tab, tab_state, before_layout, "Split pane and added " + app_curve_display_name(curve), true); } return false; } ImGuiDir dock_direction(SplitOrientation orientation) { return orientation == SplitOrientation::Horizontal ? ImGuiDir_Left : ImGuiDir_Up; } void build_dock_tree(const WorkspaceNode &node, const WorkspaceTab &tab, int tab_runtime_id, ImGuiID dock_id) { if (node.is_pane) { if (node.pane_index >= 0 && node.pane_index < static_cast(tab.panes.size())) { ImGui::DockBuilderDockWindow( pane_window_name(tab_runtime_id, node.pane_index, tab.panes[static_cast(node.pane_index)]).c_str(), dock_id); if (ImGuiDockNode *dock_node = ImGui::DockBuilderGetNode(dock_id); dock_node != nullptr) { dock_node->LocalFlags |= ImGuiDockNodeFlags_AutoHideTabBar | ImGuiDockNodeFlags_NoWindowMenuButton | ImGuiDockNodeFlags_NoCloseButton; } } return; } if (node.children.empty()) { return; } if (node.children.size() == 1) { build_dock_tree(node.children.front(), tab, tab_runtime_id, dock_id); return; } float remaining = 1.0f; ImGuiID current = dock_id; for (size_t i = 0; i + 1 < node.children.size(); ++i) { const float child_size = i < node.sizes.size() ? node.sizes[i] : 0.0f; const float ratio = remaining <= 0.0f ? 0.5f : std::clamp(child_size / remaining, 0.05f, 0.95f); ImGuiID child_id = 0; ImGuiID remainder_id = 0; ImGui::DockBuilderSplitNode(current, dock_direction(node.orientation), ratio, &child_id, &remainder_id); build_dock_tree(node.children[i], tab, tab_runtime_id, child_id); current = remainder_id; remaining = std::max(0.0f, remaining - child_size); } build_dock_tree(node.children.back(), tab, tab_runtime_id, current); } void ensure_dockspace(const WorkspaceTab &tab, TabUiState *tab_state, ImVec2 dockspace_size) { if (dockspace_size.x <= 0.0f || dockspace_size.y <= 0.0f || tab_state == nullptr) { return; } const bool size_changed = std::abs(tab_state->last_dockspace_size.x - dockspace_size.x) > 1.0f || std::abs(tab_state->last_dockspace_size.y - dockspace_size.y) > 1.0f; if (!tab_state->dock_needs_build && !size_changed) { return; } const ImGuiID dockspace_id = dockspace_id_for_tab(tab_state->runtime_id); ImGui::DockBuilderRemoveNode(dockspace_id); ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace | ImGuiDockNodeFlags_AutoHideTabBar); ImGui::DockBuilderSetNodeSize(dockspace_id, dockspace_size); build_dock_tree(tab.root, tab, tab_state->runtime_id, dockspace_id); ImGui::DockBuilderFinish(dockspace_id); tab_state->dock_needs_build = false; tab_state->last_dockspace_size = dockspace_size; } void draw_pane_windows(AppSession *session, UiState *state) { WorkspaceTab *tab = app_active_tab(&session->layout, *state); TabUiState *tab_state = app_active_tab_state(state); if (tab == nullptr || tab_state == nullptr) { return; } std::optional> pending_menu_action; std::optional pending_close_pane; std::optional pending_drop_action; for (size_t i = 0; i < tab->panes.size(); ++i) { Pane &pane = tab->panes[i]; std::optional menu_action; std::optional drop_action; bool close_pane_requested = false; ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(250, 250, 251)); ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(194, 198, 204)); ImGui::PushStyleColor(ImGuiCol_TitleBg, color_rgb(252, 252, 253)); ImGui::PushStyleColor(ImGuiCol_TitleBgActive, color_rgb(252, 252, 253)); ImGui::PushStyleColor(ImGuiCol_TitleBgCollapsed, color_rgb(252, 252, 253)); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; const std::string window_name = pane_window_name(tab_state->runtime_id, static_cast(i), pane); const bool opened = ImGui::Begin(window_name.c_str(), nullptr, flags); if (opened) { if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) || (ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) && ImGui::IsMouseClicked(0))) { tab_state->active_pane_index = static_cast(i); } if (pane.kind == PaneKind::Map) { draw_map_pane(session, state, &pane, static_cast(i)); } else if (pane.kind == PaneKind::Camera) { draw_camera_pane(session, state, tab_state, static_cast(i), pane); } else { draw_plot(*session, &pane, state); } draw_pane_frame_overlay(); close_pane_requested = draw_pane_close_button_overlay(); menu_action = draw_pane_context_menu(*tab, static_cast(i)); drop_action = draw_pane_drop_target(state->active_tab_index, static_cast(i), pane); } ImGui::End(); ImGui::PopStyleVar(); ImGui::PopStyleColor(5); if (!pending_menu_action.has_value() && menu_action.has_value()) { pending_menu_action = std::make_pair(static_cast(i), *menu_action); } if (!pending_menu_action.has_value() && !pending_close_pane.has_value() && close_pane_requested) { pending_close_pane = static_cast(i); } if (!pending_menu_action.has_value() && !pending_close_pane.has_value() && !pending_drop_action.has_value() && drop_action.has_value()) { pending_drop_action = *drop_action; } } if (pending_menu_action.has_value()) { apply_pane_menu_action(session, state, pending_menu_action->first, pending_menu_action->second); return; } if (pending_close_pane.has_value()) { PaneMenuAction action; action.kind = PaneMenuActionKind::Close; action.pane_index = *pending_close_pane; apply_pane_menu_action(session, state, *pending_close_pane, action); return; } if (pending_drop_action.has_value()) { apply_pane_drop_action(session, state, *pending_drop_action); } } void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { state->custom_series.selected = false; state->logs.selected = false; ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.content_y)); ImGui::SetNextWindowSize(ImVec2(ui.content_w, ui.content_h)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(244, 246, 248)); ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(186, 191, 198)); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; if (ImGui::Begin("##workspace_host", nullptr, flags)) { const int selection_request = state->requested_tab_index; std::optional rename_tab_rect; ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 6.0f)); ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(8.0f, 4.0f)); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); if (ImGui::BeginTabBar("##layout_tabs", ImGuiTabBarFlags_FittingPolicyScroll)) { enum class TabActionKind { None, New, Rename, Duplicate, Close, }; TabActionKind pending_action = TabActionKind::None; int pending_tab_index = -1; bool custom_series_tab_open = state->custom_series.open; bool suppress_aux_tabs_this_frame = state->request_close_tab && session->layout.tabs.size() == 1; for (size_t i = 0; i < session->layout.tabs.size(); ++i) { const WorkspaceTab &tab = session->layout.tabs[i]; const TabUiState &tab_ui = state->tabs[i]; ImGuiTabItemFlags tab_flags = ImGuiTabItemFlags_None; if (static_cast(i) == selection_request) { tab_flags |= ImGuiTabItemFlags_SetSelected; } bool tab_open = true; const bool opened = ImGui::BeginTabItem(tab_item_label(tab, tab_ui.runtime_id).c_str(), &tab_open, tab_flags); if (state->rename_tab_index == static_cast(i)) { rename_tab_rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); } if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { pending_action = TabActionKind::Rename; pending_tab_index = static_cast(i); } if (!tab_open) { pending_action = TabActionKind::Close; pending_tab_index = static_cast(i); if (session->layout.tabs.size() == 1) { suppress_aux_tabs_this_frame = true; } } if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem("New Tab")) { pending_action = TabActionKind::New; } if (ImGui::MenuItem("Rename Tab...")) { pending_action = TabActionKind::Rename; pending_tab_index = static_cast(i); } if (ImGui::MenuItem("Duplicate Tab")) { pending_action = TabActionKind::Duplicate; pending_tab_index = static_cast(i); } if (ImGui::MenuItem("Close Tab")) { pending_action = TabActionKind::Close; pending_tab_index = static_cast(i); } ImGui::EndPopup(); } if (opened) { state->active_tab_index = static_cast(i); session->layout.current_tab_index = state->active_tab_index; if (i < state->tabs.size()) { ensure_dockspace(tab, &state->tabs[i], ImGui::GetContentRegionAvail()); } ImGui::DockSpace(dockspace_id_for_tab(tab_ui.runtime_id), ImVec2(0.0f, 0.0f), ImGuiDockNodeFlags_AutoHideTabBar | ImGuiDockNodeFlags_NoWindowMenuButton | ImGuiDockNodeFlags_NoCloseButton); ImGui::EndTabItem(); } } if (!suppress_aux_tabs_this_frame) { ImGuiTabItemFlags logs_flags = ImGuiTabItemFlags_None; if (state->logs.request_select) { logs_flags |= ImGuiTabItemFlags_SetSelected; } if (ImGui::BeginTabItem("Logs##workspace_logs", nullptr, logs_flags)) { state->logs.request_select = false; state->logs.selected = true; draw_logs_tab(session, state); ImGui::EndTabItem(); } if (custom_series_tab_open) { ImGuiTabItemFlags custom_flags = ImGuiTabItemFlags_None; if (state->custom_series.request_select) { custom_flags |= ImGuiTabItemFlags_SetSelected; } if (ImGui::BeginTabItem("Custom Series##workspace_custom_series", &custom_series_tab_open, custom_flags)) { state->custom_series.request_select = false; state->custom_series.selected = true; draw_custom_series_editor(session, state); ImGui::EndTabItem(); } } } ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f, 5.0f)); ImGui::PushStyleColor(ImGuiCol_Tab, color_rgb(210, 217, 225)); ImGui::PushStyleColor(ImGuiCol_TabHovered, color_rgb(224, 230, 237)); ImGui::PushStyleColor(ImGuiCol_TabSelected, color_rgb(242, 245, 248)); if (ImGui::TabItemButton(" ##new_tab_button", ImGuiTabItemFlags_Trailing)) { pending_action = TabActionKind::New; } { const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); ImDrawList *draw_list = ImGui::GetWindowDrawList(); const ImU32 color = ImGui::GetColorU32(color_rgb(72, 79, 88)); const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); constexpr float half_extent = 6.25f; constexpr float thickness = 2.0f; draw_list->AddLine(ImVec2(center.x - half_extent, center.y), ImVec2(center.x + half_extent, center.y), color, thickness); draw_list->AddLine(ImVec2(center.x, center.y - half_extent), ImVec2(center.x, center.y + half_extent), color, thickness); } if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)); ImGui::BeginTooltip(); ImGui::TextUnformatted("New Tab"); ImGui::EndTooltip(); ImGui::PopStyleVar(); } ImGui::PopStyleColor(3); ImGui::PopStyleVar(); ImGui::EndTabBar(); if (!custom_series_tab_open) { state->custom_series.open = false; state->custom_series.request_select = false; } if (rename_tab_rect.has_value()) { draw_inline_tab_editor(session, state, *rename_tab_rect); } if (state->request_new_tab || pending_action == TabActionKind::New) { const SketchLayout before_layout = session->layout; create_runtime_tab(&session->layout, state); state->undo.push(before_layout); mark_layout_dirty(session, state); state->request_new_tab = false; } else if (pending_action == TabActionKind::Rename) { begin_rename_tab(session->layout, state, pending_tab_index); } else if (state->request_duplicate_tab || pending_action == TabActionKind::Duplicate) { if (pending_tab_index >= 0) { request_tab_selection(state, pending_tab_index); } const SketchLayout before_layout = session->layout; duplicate_runtime_tab(&session->layout, state); state->undo.push(before_layout); mark_layout_dirty(session, state); state->request_duplicate_tab = false; } else if (state->request_close_tab || pending_action == TabActionKind::Close) { if (pending_tab_index >= 0) { request_tab_selection(state, pending_tab_index); } const SketchLayout before_layout = session->layout; close_runtime_tab(&session->layout, state); state->undo.push(before_layout); mark_layout_dirty(session, state); state->request_close_tab = false; } if (state->requested_tab_index == selection_request) { state->requested_tab_index = -1; } } ImGui::PopStyleColor(3); ImGui::PopStyleVar(2); } ImGui::End(); ImGui::PopStyleVar(); ImGui::PopStyleColor(2); } int run(const Options &options) { try { const fs::path layout_path = options.layout.empty() ? fs::path() : resolve_layout_path(options.layout); AppSession session = { .layout_path = layout_path, .autosave_path = layout_path.empty() ? fs::path() : autosave_path_for_layout(layout_path), .route_name = options.route_name, .data_dir = options.data_dir, .dbc_override = {}, .stream_source = StreamSourceConfig{.kind = is_local_stream_address(options.stream_address) ? StreamSourceKind::CerealLocal : StreamSourceKind::CerealRemote, .address = options.stream_address}, .stream_buffer_seconds = options.stream_buffer_seconds, .data_mode = options.stream ? SessionDataMode::Stream : SessionDataMode::Route, .route_id = options.stream ? RouteIdentifier{} : parse_route_identifier(options.route_name), .layout = options.layout.empty() ? make_empty_layout() : load_sketch_layout(layout_path), }; UiState ui_state; if (!layout_path.empty() && !session.autosave_path.empty() && fs::exists(session.autosave_path)) { session.layout = load_sketch_layout(session.autosave_path); ui_state.layout_dirty = true; } ui_state.undo.reset(session.layout); sync_ui_state(&ui_state, session.layout); sync_route_buffers(&ui_state, session); sync_stream_buffers(&ui_state, session); sync_layout_buffers(&ui_state, session); session.async_route_loading = session.data_mode == SessionDataMode::Route && options.show && options.output_path.empty() && !options.sync_load; if (session.data_mode == SessionDataMode::Route && !session.async_route_loading) { TerminalRouteProgress route_progress(::isatty(STDERR_FILENO) != 0); rebuild_session_route_data(&session, &ui_state, [&](const RouteLoadProgress &update) { route_progress.update(update); }); route_progress.finish(); } GlfwRuntime glfw_runtime(options); ImGuiRuntime imgui_runtime(glfw_runtime.window()); configure_style(); session.map_data = std::make_unique(); for (std::unique_ptr &feed : session.pane_camera_feeds) { feed = std::make_unique(); } sync_camera_feeds(&session); if (session.async_route_loading) { session.route_loader = std::make_unique(::isatty(STDERR_FILENO) != 0); start_async_route_load(&session, &ui_state); } else if (session.data_mode == SessionDataMode::Stream) { session.stream_poller = std::make_unique(); start_stream_session(&session, &ui_state, session.stream_source, session.stream_buffer_seconds); } const bool should_capture = !options.output_path.empty(); const fs::path output_path = should_capture ? fs::path(options.output_path) : fs::path(); const bool capture_has_map = should_capture && active_tab_has_map_pane(session.layout); if (options.show) { bool captured = false; const auto capture_ready_at = std::chrono::steady_clock::now() + (capture_has_map ? std::chrono::milliseconds(1800) : std::chrono::milliseconds(0)); while (!glfwWindowShouldClose(glfw_runtime.window())) { const bool capture_ready = std::chrono::steady_clock::now() >= capture_ready_at; const fs::path *capture_path = (!captured && should_capture && capture_ready) ? &output_path : nullptr; render_frame(glfw_runtime.window(), &session, &ui_state, capture_path); captured = captured || capture_path != nullptr; } } else { render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); if (should_capture) { for (int i = 0; i < 3; ++i) { render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); } if (capture_has_map) { for (int i = 0; i < 18; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); } } render_frame(glfw_runtime.window(), &session, &ui_state, &output_path); } } if (session.stream_poller) { session.stream_poller->stop(); } session.map_data.reset(); for (std::unique_ptr &feed : session.pane_camera_feeds) { feed.reset(); } return 0; } catch (const std::exception &err) { std::cerr << err.what() << "\n"; return 1; } }