Files
StarPilot/tools/jotpluggler/app.cc
T
2026-03-25 19:49:38 -07:00

1915 lines
77 KiB
C++

#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 <GLFW/glfw3.h>
#include <chrono>
#include <cmath>
#include <cfloat>
#include <cstring>
#include <condition_variable>
#include <iomanip>
#include <iostream>
#include <limits>
#include <mutex>
#include <stdexcept>
#include <thread>
#include <unordered_set>
#include <unistd.h>
#include "third_party/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<unsigned char>(raw);
if (std::isalnum(c) != 0) {
out.push_back(static_cast<char>(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<std::string> available_layout_names() {
std::vector<std::string> 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<std::string, EnumInfo> can_enum_info;
std::vector<RouteSeries> can_series = decode_can_messages(session->route_data.can_messages, active_dbc_name, &can_enum_info);
std::vector<RouteSeries> 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<std::string, EnumInfo> 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, 0.4f);
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<int>(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<int>(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<int>(layout.tabs[i].panes.size());
state->tabs[i].map_panes.resize(static_cast<size_t>(std::max(0, pane_count)));
state->tabs[i].camera_panes.resize(static_cast<size_t>(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<int>(layout.tabs.size()) - 1);
return &layout.tabs[static_cast<size_t>(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<int>(layout->tabs.size()) - 1);
return &layout->tabs[static_cast<size_t>(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<int>(state->tabs.size()) - 1);
return &state->tabs[static_cast<size_t>(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<int>(layout.tabs.size())) {
return;
}
state->rename_tab_buffer = layout.tabs[static_cast<size_t>(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<int>(state->tabs.size())) {
state->tabs[static_cast<size_t>(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<float>(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<float>(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<std::ptrdiff_t>(i));
if (i < node->sizes.size()) {
node->sizes.erase(node->sizes.begin() + static_cast<std::ptrdiff_t>(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<std::ptrdiff_t>(new_before ? i : i + 1);
node->children.insert(insert_it, std::move(new_pane));
node->sizes.assign(node->children.size(), 1.0f / static_cast<float>(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<int>(layout.tabs.size()) - 1);
const WorkspaceTab &tab = layout.tabs[static_cast<size_t>(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<uint8_t, 3> app_next_curve_color(const Pane &pane) {
static constexpr std::array<std::array<uint8_t, 3>, 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<size_t>(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<float>(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<std::string> 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<std::string> 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<std::string> 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<float>(std::max<size_t>(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<float>(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<int>(tab->panes.size())) {
return false;
}
Pane &pane = tab->panes[static_cast<size_t>(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<int>(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<size_t>(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<std::string> &paths) {
WorkspaceTab *tab = app_active_tab(&session->layout, *state);
if (tab == nullptr || pane_index < 0 || pane_index >= static_cast<int>(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<size_t>(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<int>(tab->panes.size())) return false;
const SpecialItemSpec *spec = special_item_spec(item_id);
if (spec == nullptr) return false;
Pane &pane = tab->panes[static_cast<size_t>(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<size_t>(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> curve = std::nullopt) {
if (pane_index < 0 || pane_index >= static_cast<int>(tab->panes.size())) {
return false;
}
if (zone == PaneDropZone::Center) return false;
const int new_pane_index = static_cast<int>(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<int>(tab->panes.size())) {
return false;
}
if (tab->panes.size() <= 1) {
tab->panes[static_cast<size_t>(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<std::ptrdiff_t>(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<int>(tab->panes.size())) {
return;
}
Pane &pane = tab->panes[static_cast<size_t>(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<int>(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<int>(layout->tabs.size()) - 1);
WorkspaceTab copy = layout->tabs[static_cast<size_t>(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<int>(state->tabs.size()) ? state->tabs[static_cast<size_t>(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<int>(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<int>(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<std::ptrdiff_t>(tab_index));
if (tab_index < static_cast<int>(state->tabs.size())) {
state->tabs.erase(state->tabs.begin() + static_cast<std::ptrdiff_t>(tab_index));
}
if (state->active_tab_index >= static_cast<int>(layout->tabs.size())) {
state->active_tab_index = static_cast<int>(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<int>(layout->tabs.size())) {
return;
}
layout->tabs[static_cast<size_t>(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<int>(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<PaneDropAction> 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<ZoneRect, 5> 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<int>(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<PaneDropAction> {
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<const char *>(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<const char *>(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<const PaneCurveDragPayload *>(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<int>(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<size_t>(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<int>(action.kind) - static_cast<int>(PaneMenuActionKind::SplitLeft)];
if (split_pane(tab, pane_index, zone)) {
tab_state->active_pane_index = static_cast<int>(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<size_t>(pane_index)]);
layout_changed = true;
success_status = "Plot view reset";
break;
case PaneMenuActionKind::ResetHorizontal:
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);
layout_changed = true;
success_status = "Horizontal zoom reset";
break;
case PaneMenuActionKind::ResetVertical:
clear_pane_vertical_limits(&tab->panes[static_cast<size_t>(pane_index)]);
layout_changed = true;
success_status = "Vertical zoom 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<int>(tab->panes.size()) - 1);
layout_changed = true;
dock_changed = static_cast<int>(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<int>(tab->panes.size())) {
return false;
}
if (!((tab->panes[static_cast<size_t>(action.target_pane_index)].kind == PaneKind::Plot
&& tab->panes[static_cast<size_t>(action.target_pane_index)].curves.empty())
|| pane_kind_is_special(tab->panes[static_cast<size_t>(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<int>(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<int>(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<size_t>(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<int>(session->layout.tabs.size())) {
return false;
}
WorkspaceTab &source_tab = session->layout.tabs[static_cast<size_t>(action.curve_ref.tab_index)];
if (action.curve_ref.pane_index < 0
|| action.curve_ref.pane_index >= static_cast<int>(source_tab.panes.size())) {
return false;
}
const Pane &source_pane = source_tab.panes[static_cast<size_t>(action.curve_ref.pane_index)];
if (action.curve_ref.curve_index < 0
|| action.curve_ref.curve_index >= static_cast<int>(source_pane.curves.size())) {
return false;
}
const Curve curve = source_pane.curves[static_cast<size_t>(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<int>(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<int>(tab.panes.size())) {
ImGui::DockBuilderDockWindow(
pane_window_name(tab_runtime_id, node.pane_index, tab.panes[static_cast<size_t>(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<std::pair<int, PaneMenuAction>> pending_menu_action;
std::optional<int> pending_close_pane;
std::optional<PaneDropAction> pending_drop_action;
for (size_t i = 0; i < tab->panes.size(); ++i) {
Pane &pane = tab->panes[i];
std::optional<PaneMenuAction> menu_action;
std::optional<PaneDropAction> 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<int>(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<int>(i);
}
if (pane.kind == PaneKind::Map) {
draw_map_pane(session, state, &pane, static_cast<int>(i));
} else if (pane.kind == PaneKind::Camera) {
draw_camera_pane(session, state, tab_state, static_cast<int>(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<int>(i));
drop_action = draw_pane_drop_target(state->active_tab_index, static_cast<int>(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<int>(i), *menu_action);
}
if (!pending_menu_action.has_value() && !pending_close_pane.has_value() && close_pane_requested) {
pending_close_pane = static_cast<int>(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<ImRect> 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<int>(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<int>(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<int>(i);
}
if (!tab_open) {
pending_action = TabActionKind::Close;
pending_tab_index = static_cast<int>(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<int>(i);
}
if (ImGui::MenuItem("Duplicate Tab")) {
pending_action = TabActionKind::Duplicate;
pending_tab_index = static_cast<int>(i);
}
if (ImGui::MenuItem("Close Tab")) {
pending_action = TabActionKind::Close;
pending_tab_index = static_cast<int>(i);
}
ImGui::EndPopup();
}
if (opened) {
state->active_tab_index = static_cast<int>(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<MapDataManager>();
for (std::unique_ptr<CameraFeedView> &feed : session.pane_camera_feeds) {
feed = std::make_unique<CameraFeedView>();
}
sync_camera_feeds(&session);
if (session.async_route_loading) {
session.route_loader = std::make_unique<AsyncRouteLoader>(::isatty(STDERR_FILENO) != 0);
start_async_route_load(&session, &ui_state);
} else if (session.data_mode == SessionDataMode::Stream) {
session.stream_poller = std::make_unique<StreamPoller>();
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<CameraFeedView> &feed : session.pane_camera_feeds) {
feed.reset();
}
return 0;
} catch (const std::exception &err) {
std::cerr << err.what() << "\n";
return 1;
}
}