#include "tools/jotpluggler/internal.h" #include "system/hardware/hw.h" #include namespace fs = std::filesystem; namespace { enum class ModalAction { None, Primary, Secondary, }; struct FindSignalMatch { const std::string *path = nullptr; int score = 0; }; struct DbcEditorSource { fs::path path; DbcEditorState::SourceKind kind = DbcEditorState::SourceKind::None; }; StreamSourceConfig stream_source_config_from_ui(const UiState &state) { StreamSourceConfig source; source.kind = state.stream_source_kind; source.address = util::strip(state.stream_address_buffer); if (source.kind == StreamSourceKind::CerealLocal) { source.address = "127.0.0.1"; } else { source.address = normalize_stream_address(std::move(source.address)); } return source; } void open_queued_popup(bool &flag, const char *name) { if (flag) { ImGui::OpenPopup(name); flag = false; } } ModalAction draw_modal_action_row(const char *primary_label, const char *secondary_label = "Cancel", float width = 120.0f) { if (ImGui::Button(primary_label, ImVec2(width, 0.0f))) { return ModalAction::Primary; } ImGui::SameLine(); if (ImGui::Button(secondary_label, ImVec2(width, 0.0f))) { return ModalAction::Secondary; } return ModalAction::None; } std::vector find_signal_matches(const AppSession &session, std::string_view query) { std::vector matches; if (query.empty()) { return matches; } const std::string needle = lowercase_copy(query); for (const std::string &path : session.route_data.paths) { const std::string hay = lowercase_copy(path); const size_t pos = hay.find(needle); if (pos == std::string::npos) { continue; } const size_t slash = path.find_last_of('/'); const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1); int score = static_cast(pos * 8 + path.size()); if (lowercase_copy(label) == needle) score -= 60; if (util::starts_with(hay, needle)) score -= 30; matches.push_back({.path = &path, .score = score}); } std::sort(matches.begin(), matches.end(), [](const FindSignalMatch &a, const FindSignalMatch &b) { return std::tie(a.score, *a.path) < std::tie(b.score, *b.path); }); if (matches.size() > 200) { matches.resize(200); } return matches; } bool open_find_signal_result(UiState *state, const std::string &path) { state->selected_browser_paths = {path}; state->selected_browser_path = path; state->browser_selection_anchor = path; state->status_text = "Selected signal " + path; return true; } void draw_open_route_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Open Route")) { return; } ImGui::TextUnformatted("Load a route into the current layout."); ImGui::Separator(); input_text_string("Route", &state->route_buffer); input_text_string("Data Dir", &state->data_dir_buffer); ImGui::Spacing(); switch (draw_modal_action_row("Load")) { case ModalAction::Primary: reload_session(session, state, state->route_buffer, state->data_dir_buffer); ImGui::CloseCurrentPopup(); break; case ModalAction::Secondary: sync_route_buffers(state, *session); ImGui::CloseCurrentPopup(); break; case ModalAction::None: break; } ImGui::EndPopup(); } void draw_stream_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Live Stream")) { return; } ImGui::TextUnformatted("Connect to a live source."); ImGui::Separator(); if (ImGui::RadioButton("Local (MSGQ)", state->stream_source_kind == StreamSourceKind::CerealLocal)) { state->stream_source_kind = StreamSourceKind::CerealLocal; } if (ImGui::RadioButton("Remote (ZMQ)", state->stream_source_kind == StreamSourceKind::CerealRemote)) { state->stream_source_kind = StreamSourceKind::CerealRemote; } if (state->stream_source_kind == StreamSourceKind::CerealRemote) { input_text_string("Address", &state->stream_address_buffer); } ImGui::InputDouble("Buffer (seconds)", &state->stream_buffer_seconds, 0.0, 0.0, "%.0f"); ImGui::Spacing(); switch (draw_modal_action_row("Connect")) { case ModalAction::Primary: { const StreamSourceConfig source = stream_source_config_from_ui(*state); if (start_stream_session(session, state, source, state->stream_buffer_seconds, false)) { ImGui::CloseCurrentPopup(); } break; } case ModalAction::Secondary: sync_stream_buffers(state, *session); ImGui::CloseCurrentPopup(); break; case ModalAction::None: break; } ImGui::EndPopup(); } void draw_load_layout_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Load Layout")) { return; } ImGui::TextUnformatted("Load a JotPlugger JSON layout."); ImGui::Separator(); input_text_string("Layout", &state->load_layout_buffer); ImGui::Spacing(); switch (draw_modal_action_row("Load")) { case ModalAction::Primary: if (reload_layout(session, state, state->load_layout_buffer)) { ImGui::CloseCurrentPopup(); } break; case ModalAction::Secondary: sync_layout_buffers(state, *session); ImGui::CloseCurrentPopup(); break; case ModalAction::None: break; } ImGui::EndPopup(); } void draw_save_layout_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Save Layout")) { return; } ImGui::TextUnformatted("Save the current workspace as a JotPlugger JSON layout."); ImGui::Separator(); input_text_string("Layout", &state->save_layout_buffer); ImGui::Spacing(); switch (draw_modal_action_row("Save")) { case ModalAction::Primary: if (save_layout(session, state, state->save_layout_buffer)) { ImGui::CloseCurrentPopup(); } break; case ModalAction::Secondary: sync_layout_buffers(state, *session); ImGui::CloseCurrentPopup(); break; case ModalAction::None: break; } ImGui::EndPopup(); } void draw_preferences_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Preferences")) { return; } if (session->map_data) { const MapCacheStats map_cache = session->map_data->cacheStats(); const MapCacheStats download_cache = directory_cache_stats(Path::download_cache_root()); ImGui::TextUnformatted("Map"); ImGui::Separator(); ImGui::Text("Map cache: %s in %zu file%s", format_cache_bytes(map_cache.bytes).c_str(), map_cache.files, map_cache.files == 1 ? "" : "s"); if (ImGui::Button("Clear Map Cache", ImVec2(120.0f, 0.0f))) { session->map_data->clearCache(); state->status_text = "Cleared map cache"; } ImGui::Spacing(); ImGui::TextUnformatted("comma Download Cache"); ImGui::Separator(); ImGui::Text("Download cache: %s in %zu file%s", format_cache_bytes(download_cache.bytes).c_str(), download_cache.files, download_cache.files == 1 ? "" : "s"); ImGui::TextDisabled("%s", Path::download_cache_root().c_str()); ImGui::Spacing(); } if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } void draw_find_signal_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Find Signal")) { return; } ImGui::TextUnformatted("Search decoded signals across the loaded route."); ImGui::Separator(); ImGui::SetNextItemWidth(560.0f); input_text_with_hint_string("##find_signal_query", "Search signal path or name...", &state->find_signal_buffer); if (ImGui::IsWindowAppearing()) { ImGui::SetKeyboardFocusHere(-1); } const std::vector matches = find_signal_matches(*session, state->find_signal_buffer); ImGui::Spacing(); ImGui::TextDisabled("%zu match%s", matches.size(), matches.size() == 1 ? "" : "es"); if (ImGui::BeginChild("##find_signal_results", ImVec2(760.0f, 360.0f), true)) { for (const FindSignalMatch &match : matches) { const std::string &path = *match.path; const size_t slash = path.find_last_of('/'); const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1); if (ImGui::Selectable((std::string(label) + "##" + path).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { if (open_find_signal_result(state, path)) { ImGui::CloseCurrentPopup(); } } ImGui::SameLine(280.0f); ImGui::TextDisabled("%s", path.c_str()); } } ImGui::EndChild(); ImGui::Spacing(); if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } std::string default_dbc_template() { return "VERSION \"\"\n\nNS_ :\nBS_:\nBU_: XXX\n"; } DbcEditorSource resolve_dbc_editor_source(const std::string &dbc_name) { const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; const std::array candidates = {{ {.path = repo_root() / "iqdbc" / "dbc" / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Opendbc}, {.path = generated_dbc_dir / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Generated}, }}; for (const DbcEditorSource &candidate : candidates) { if (fs::exists(candidate.path)) { return candidate; } } return {}; } void load_dbc_editor_state(const AppSession &session, UiState *state) { DbcEditorState &editor = state->dbc_editor; const std::string dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name; editor.source_name = dbc_name.empty() ? "untitled" : dbc_name; editor.source_path.clear(); editor.source_kind = DbcEditorState::SourceKind::None; if (dbc_name.empty()) { editor.save_name = "custom_can"; editor.text = default_dbc_template(); } else { const DbcEditorSource source = resolve_dbc_editor_source(dbc_name); editor.source_kind = source.kind; editor.source_path = source.path; editor.text = source.path.empty() ? default_dbc_template() : read_file_or_throw(source.path); editor.save_name = source.kind == DbcEditorState::SourceKind::Generated ? dbc_name : dbc_name + "_edited"; } editor.loaded = true; } bool ensure_dbc_editor_loaded(const AppSession &session, UiState *state) { if (!state->dbc_editor.loaded) { try { load_dbc_editor_state(session, state); } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; return false; } } return true; } bool save_dbc_editor_contents(AppSession *session, UiState *state) { DbcEditorState &editor = state->dbc_editor; editor.save_name = util::strip(editor.save_name); if (editor.save_name.empty()) { state->error_text = "DBC name cannot be empty"; state->open_error_popup = true; return false; } if (editor.source_kind == DbcEditorState::SourceKind::Opendbc && editor.save_name == editor.source_name) { state->error_text = "Save edited iqdbc files under a new name"; state->open_error_popup = true; return false; } try { dbc::Database::fromContent(editor.text, editor.save_name + ".dbc"); const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; fs::create_directories(generated_dbc_dir); const fs::path output = generated_dbc_dir / (editor.save_name + ".dbc"); write_file_or_throw(output, editor.text); apply_dbc_override_change(session, state, editor.save_name); editor.source_name = editor.save_name; editor.source_path = output; editor.source_kind = DbcEditorState::SourceKind::Generated; editor.loaded = false; state->status_text = "Saved DBC " + editor.save_name; return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; return false; } } void draw_dbc_editor_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("DBC Editor")) { return; } DbcEditorState &editor = state->dbc_editor; if (!ensure_dbc_editor_loaded(*session, state)) { ImGui::CloseCurrentPopup(); ImGui::EndPopup(); return; } ImGui::TextUnformatted("Edit DBC text and save it into generated_dbcs."); ImGui::Separator(); ImGui::SetNextItemWidth(260.0f); input_text_string("DBC Name", &editor.save_name, ImGuiInputTextFlags_AutoSelectAll); if (!editor.source_path.empty()) { ImGui::TextDisabled("%s", editor.source_path.string().c_str()); } else { ImGui::TextDisabled("New in-memory DBC"); } ImGui::Spacing(); input_text_multiline_string("##dbc_editor_text", &editor.text, ImVec2(920.0f, 520.0f), ImGuiInputTextFlags_AllowTabInput); ImGui::Spacing(); if (ImGui::Button("Apply + Save", ImVec2(140.0f, 0.0f))) { if (save_dbc_editor_contents(session, state)) { ImGui::CloseCurrentPopup(); } } ImGui::SameLine(); if (ImGui::Button("Reload Source", ImVec2(140.0f, 0.0f))) { editor.loaded = false; } ImGui::SameLine(); if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { editor.loaded = false; ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } void draw_axis_limits_popup(AppSession *session, UiState *state) { if (!app_begin_popup_modal("Edit Axis Limits")) { return; } const WorkspaceTab *tab = app_active_tab(session->layout, *state); const bool valid_pane = tab != nullptr && state->axis_limits.pane_index >= 0 && state->axis_limits.pane_index < static_cast(tab->panes.size()); if (!valid_pane) { ImGui::TextWrapped("The selected pane is no longer available."); ImGui::Spacing(); if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { state->axis_limits.pane_index = -1; ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); return; } ImGui::TextUnformatted("X range applies to the active tab. Y limits apply to the selected pane."); ImGui::Separator(); ImGui::TextUnformatted("Horizontal"); ImGui::SetNextItemWidth(180.0f); ImGui::InputDouble("X Min", &state->axis_limits.x_min, 0.0, 0.0, "%.3f"); ImGui::SetNextItemWidth(180.0f); ImGui::InputDouble("X Max", &state->axis_limits.x_max, 0.0, 0.0, "%.3f"); ImGui::Spacing(); ImGui::TextUnformatted("Vertical"); ImGui::Checkbox("Use Y Min", &state->axis_limits.y_min_enabled); ImGui::BeginDisabled(!state->axis_limits.y_min_enabled); ImGui::SetNextItemWidth(180.0f); ImGui::InputDouble("Y Min", &state->axis_limits.y_min, 0.0, 0.0, "%.6g"); ImGui::EndDisabled(); ImGui::Checkbox("Use Y Max", &state->axis_limits.y_max_enabled); ImGui::BeginDisabled(!state->axis_limits.y_max_enabled); ImGui::SetNextItemWidth(180.0f); ImGui::InputDouble("Y Max", &state->axis_limits.y_max, 0.0, 0.0, "%.6g"); ImGui::EndDisabled(); ImGui::Spacing(); switch (draw_modal_action_row("Apply")) { case ModalAction::Primary: if (apply_axis_limits_editor(session, state)) { state->axis_limits.pane_index = -1; ImGui::CloseCurrentPopup(); } break; case ModalAction::Secondary: state->axis_limits.pane_index = -1; ImGui::CloseCurrentPopup(); break; case ModalAction::None: break; } ImGui::EndPopup(); } void draw_error_popup(UiState *state) { if (state->open_error_popup) { ImGui::OpenPopup("Error"); state->open_error_popup = false; } if (!app_begin_popup_modal("Error")) { return; } ImGui::TextWrapped("%s", state->error_text.c_str()); ImGui::Spacing(); if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } } // namespace bool reset_layout(AppSession *session, UiState *state) { try { if (session->layout_path.empty()) { start_new_layout(session, state, "Reset layout"); return true; } clear_layout_autosave(*session); session->layout = load_sketch_layout(session->layout_path); state->layout_dirty = false; session->autosave_path = autosave_path_for_layout(session->layout_path); state->undo.reset(session->layout); refresh_replaced_layout_ui(session, state, false); reset_shared_range(state, *session); state->status_text = "Reset layout"; return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Failed to reset layout"; return false; } } bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg) { try { const bool preserve_shared_range = session->route_data.has_time_range && state->has_shared_range; const double preserved_x_min = state->x_view_min; const double preserved_x_max = state->x_view_max; const fs::path layout_path = resolve_layout_path(layout_arg); session->autosave_path = autosave_path_for_layout(layout_path); const bool load_draft = fs::exists(session->autosave_path); session->layout = load_sketch_layout(load_draft ? session->autosave_path : layout_path); session->layout_path = layout_path; state->layout_dirty = load_draft; state->undo.reset(session->layout); refresh_replaced_layout_ui(session, state, true); if (preserve_shared_range) { state->has_shared_range = true; state->x_view_min = preserved_x_min; state->x_view_max = preserved_x_max; clamp_shared_range(state, *session); } else { reset_shared_range(state, *session); } state->status_text = std::string(load_draft ? "Loaded layout draft " : "Loaded layout ") + layout_path.filename().string(); return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Failed to load layout"; return false; } } bool save_layout(AppSession *session, UiState *state, const std::string &layout_path) { try { if (layout_path.empty()) throw std::runtime_error("Layout path is empty"); session->layout.current_tab_index = state->active_tab_index; const fs::path previous_autosave = session->autosave_path; const fs::path output = fs::absolute(fs::path(layout_path)); save_layout_json(session->layout, output); session->layout_path = output; session->autosave_path = autosave_path_for_layout(output); if (!previous_autosave.empty() && previous_autosave != session->autosave_path && fs::exists(previous_autosave)) { fs::remove(previous_autosave); } clear_layout_autosave(*session); state->layout_dirty = false; sync_layout_buffers(state, *session); state->status_text = "Saved layout " + output.filename().string(); return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Failed to save layout"; return false; } } void rebuild_session_route_data(AppSession *session, UiState *state, const RouteLoadProgressCallback &progress) { apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, progress)); } void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) { if (preserve_data && session->stream_poller && session->data_mode == SessionDataMode::Stream) { session->stream_poller->setPaused(true); } else if (session->stream_poller) { session->stream_poller->stop(); } session->stream_paused = preserve_data && session->data_mode == SessionDataMode::Stream; if (!preserve_data) { session->stream_time_offset.reset(); apply_route_data(session, state, RouteData{}); } sync_stream_buffers(state, *session); } bool start_stream_session(AppSession *session, UiState *state, const StreamSourceConfig &source, double buffer_seconds, bool preserve_existing_data) { try { if (session->route_loader) { session->route_loader.reset(); } session->data_mode = SessionDataMode::Stream; session->route_id = {}; session->route_name.clear(); session->data_dir.clear(); session->stream_source = source; if (session->stream_source.kind == StreamSourceKind::CerealLocal) { session->stream_source.address = "127.0.0.1"; } session->stream_buffer_seconds = std::max(1.0, buffer_seconds); session->next_stream_custom_refresh_time = 0.0; session->stream_paused = false; if (preserve_existing_data && session->stream_poller) { StreamPollSnapshot snapshot = session->stream_poller->snapshot(); if (snapshot.active) { session->stream_poller->setPaused(false); sync_route_buffers(state, *session); sync_stream_buffers(state, *session); state->follow_latest = true; state->playback_playing = false; state->status_text = "Resumed stream " + stream_source_target_label(session->stream_source); return true; } } if (!preserve_existing_data) { session->stream_time_offset.reset(); apply_route_data(session, state, RouteData{}); } if (!session->stream_poller) { session->stream_poller = std::make_unique(); } session->stream_poller->start(session->stream_source, session->stream_buffer_seconds, session->dbc_override, session->stream_time_offset); sync_route_buffers(state, *session); sync_stream_buffers(state, *session); state->follow_latest = true; state->playback_playing = false; state->status_text = preserve_existing_data ? "Resumed stream " + stream_source_target_label(session->stream_source) : "Streaming from " + stream_source_target_label(session->stream_source); return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Failed to start stream"; return false; } } void start_async_route_load(AppSession *session, UiState *state) { if (!session->route_loader) { return; } apply_route_data(session, state, RouteData{}); session->route_loader->start(session->route_name, session->data_dir, session->dbc_override); state->status_text = session->route_name.empty() ? "Ready" : "Loading route " + session->route_name; } void poll_async_route_load(AppSession *session, UiState *state) { if (!session->route_loader) { return; } RouteData loaded_route; std::string error_text; if (!session->route_loader->consume(&loaded_route, &error_text)) { return; } if (!error_text.empty()) { state->error_text = error_text; state->open_error_popup = true; state->status_text = "Failed to load route"; return; } apply_route_data(session, state, std::move(loaded_route)); state->status_text = session->route_name.empty() ? "Ready" : "Loaded route " + session->route_name; } bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir) { try { stop_stream_session(session, state, false); session->data_mode = SessionDataMode::Route; session->route_name = route_name; session->route_id = parse_route_identifier(route_name); session->data_dir = data_dir; if (session->async_route_loading) { if (!session->route_loader) { session->route_loader = std::make_unique(::isatty(STDERR_FILENO) != 0); } start_async_route_load(session, state); } else { rebuild_session_route_data(session, state); state->status_text = "Loaded route " + route_name; } sync_route_buffers(state, *session); return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Failed to load route"; return false; } } void draw_popups(AppSession *session, UiState *state) { open_queued_popup(state->open_open_route, "Open Route"); if (state->open_stream) { sync_stream_buffers(state, *session); } open_queued_popup(state->open_stream, "Live Stream"); if (state->open_load_layout || state->open_save_layout) { sync_layout_buffers(state, *session); } open_queued_popup(state->open_load_layout, "Load Layout"); open_queued_popup(state->open_save_layout, "Save Layout"); open_queued_popup(state->open_preferences, "Preferences"); open_queued_popup(state->dbc_editor.open, "DBC Editor"); open_queued_popup(state->open_find_signal, "Find Signal"); open_queued_popup(state->axis_limits.open, "Edit Axis Limits"); draw_open_route_popup(session, state); draw_stream_popup(session, state); draw_load_layout_popup(session, state); draw_save_layout_popup(session, state); draw_preferences_popup(session, state); draw_dbc_editor_popup(session, state); draw_find_signal_popup(session, state); draw_axis_limits_popup(session, state); draw_error_popup(state); }