diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index f0a5eb0b9e..e669ac5fd5 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -1,6 +1,13 @@ #include "tools/cabana/ui/dialogs/routesdialog.h" +#include +#include #include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_downloader.h" +#include "tools/cabana/ui/theme.h" #include "imgui.h" #include "imgui_internal.h" @@ -14,12 +21,17 @@ const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; } // namespace void RoutesDialog::open(std::function on_done) { + if (alive_) return; on_done_ = std::move(on_done); - open_ = true; + open_ = false; popup_.reset(); s_ = State{}; alive_ = std::make_shared(true); + fetchDevices(); +} + +void RoutesDialog::fetchDevices() { routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { setDeviceList(devices, success, error_code); @@ -29,14 +41,19 @@ void RoutesDialog::open(std::function on_done) void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { if (success) { + s_.login = false; + open_ = true; s_.devices.clear(); for (const auto &device : devices) s_.devices.push_back(device.dongle_id); s_.devices_loaded = true; s_.device_index = 0; fetchRoutes(); + } else if (error_code == 401) { + s_.login = true; + open_ = true; } else { - // the box shows on top of the dialog, which is rejected once the box is dismissed - MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with openpilot/tools/lib/auth.py" : "Network error", "", + // Initial failures are shown on the calling window without opening the route browser. + MessageBox::warning("Error", "Network error", "", utils::guarded(alive_, [this]() { finish(false); })); } } @@ -72,6 +89,8 @@ void RoutesDialog::setRouteList(const std::vector &list, bool } void RoutesDialog::finish(bool accepted) { + if (auth_abort_) *auth_abort_ = true; + auth_abort_.reset(); alive_.reset(); open_ = false; auto on_done = std::move(on_done_); @@ -82,6 +101,16 @@ void RoutesDialog::draw() { if (!open_) return; if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; + if (s_.login) { + drawLogin(); + if (open_) { + MessageBox::draw(); + if (!open_) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); ImGui::SameLine(); @@ -122,3 +151,124 @@ void RoutesDialog::draw() { ImGui::EndPopup(); if (accepted || rejected) finish(accepted); } + +void RoutesDialog::signIn(const std::string &provider) { + s_.provider = provider == "google" ? "Google" : provider == "apple" ? "Apple" : "GitHub"; + s_.auth_error.clear(); + auth_abort_ = std::make_shared>(false); + std::thread([this, alive = std::weak_ptr(alive_), abort = auth_abort_, provider]() { + const std::string result = PyDownloader::authenticate(provider, abort.get()); + utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { + if (*abort) return; + auth_abort_.reset(); + std::string error; + auto status = json11::Json::parse(result, error); + if (status["success"].bool_value()) { + s_.login = false; + fetchDevices(); + } else { + s_.auth_error = status["error"].string_value(); + if (s_.auth_error.empty()) s_.auth_error = "Could not start sign-in. Please try again."; + } + })); + }).detach(); +} + +void RoutesDialog::drawLogin() { + const auto &p = palette(); + const char *providers[] = {"Google", "Apple", "GitHub"}; + const char *methods[] = {"google", "apple", "github"}; + const char *icons[] = {"\xef\x8f\xb0", "\xef\x99\x9b", "\xef\x8f\xad"}; + IconTextButtonOptions button_options{.height = 44.0f, .rounding = 8.0f, .icon_gap = 16.0f, .center_content = true}; + float button_width = iconTextButtonWidth("", "Choose another method", button_options); + for (int i = 0; i < 3; ++i) { + const std::string label = std::string("Sign in with ") + providers[i]; + button_width = std::max(button_width, iconTextButtonWidth(icons[i], label, button_options)); + button_options.label_width = std::max(button_options.label_width, ImGui::CalcTextSize(label.c_str()).x); + } + button_width += ImGui::GetStyle().FramePadding.x * 4; + // Keep the footer anchored while longer errors scroll inside the content area. + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("login_content", ImVec2(0, -footer)); + ImGui::Indent(16); + const float gap = ImGui::GetStyle().ItemSpacing.y; + const float wrap_x = ImGui::GetWindowWidth() - 28; + const float text_width = wrap_x - ImGui::GetCursorPosX(); + const char *chooser_intro = "Use your comma account to browse recorded drives and open a route for analysis."; + const auto waiting_intro = [](const std::string &provider) { + return "Sign in with " + provider + " in your browser, then return to Cabana to choose a device and route."; + }; + const std::string intro = auth_abort_ ? waiting_intro(s_.provider) : chooser_intro; + const char *hint = "Use the comma account paired with your device."; + // Reserve the same space in every state so the heading and controls never jump. + float intro_height = ImGui::CalcTextSize(chooser_intro, nullptr, false, text_width).y; + for (const char *provider : providers) { + intro_height = std::max(intro_height, ImGui::CalcTextSize(waiting_intro(provider).c_str(), nullptr, false, text_width).y); + } + const float controls_height = 3 * (button_options.height + 2 * gap) + + ImGui::CalcTextSize(hint, nullptr, false, text_width).y; + const float content_height = 28 + 2 * gap + intro_height + 2 * gap + 12 + controls_height; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.0f, (ImGui::GetContentRegionAvail().y - content_height) * 0.5f)); + ImGui::PushFont(boldFont(), 28.0f); + ImGui::TextUnformatted("Open your routes in Cabana"); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(wrap_x); + const float controls_y = ImGui::GetCursorPosY() + intro_height + 2 * gap + 12; + const float buttons_x = ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - 16 - button_width) * 0.5f; + if (auth_abort_) { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + ImGui::BeginChild("auth_status", ImVec2(-16, 76), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + const char *status = "Waiting for browser sign-in"; + const char *timeout = "This request expires after 3 minutes."; + const float spinner_size = ImGui::GetFontSize(); + const float status_width = spinner_size + 8 + ImGui::CalcTextSize(status).x; + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - status_width) * 0.5f, 16)); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float angle = std::fmod(ImGui::GetTime() * 4.0, 2.0 * IM_PI); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->PathArcTo(ImVec2(pos.x + spinner_size * 0.5f, pos.y + spinner_size * 0.5f), + spinner_size * 0.35f, angle, angle + IM_PI * 1.5f, 24); + draw_list->PathStroke(ImGui::GetColorU32(p.accent), 0, 2.0f); + ImGui::Dummy(ImVec2(spinner_size, spinner_size)); + ImGui::SameLine(0, 8); + ImGui::TextUnformatted(status); + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - ImGui::CalcTextSize(timeout).x) * 0.5f, 40)); + ImGui::TextUnformatted(timeout); + ImGui::EndChild(); + ImGui::SetCursorPosY(controls_y + 2 * (button_options.height + 2 * gap)); + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton("auth_retry", "", "Choose another method", button_width, button_options)) { + *auth_abort_ = true; + auth_abort_.reset(); + } + } else { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + const char *selected_method = nullptr; + for (int i = 0; i < 3; ++i) { + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton(methods[i], icons[i], std::string("Sign in with ") + providers[i], + button_width, button_options)) selected_method = methods[i]; + ImGui::Spacing(); + } + const char *message = s_.auth_error.empty() ? hint : s_.auth_error.c_str(); + const float width = ImGui::GetContentRegionAvail().x - 16; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (width - ImGui::CalcTextSize(message).x) * 0.5f)); + ImGui::TextWrapped("%s", message); + // Finish drawing this state before signIn clears its error text. + if (selected_method) signIn(selected_method); + } + ImGui::PopTextWrapPos(); + ImGui::Unindent(16); + ImGui::EndChild(); + ImGui::Separator(); + bool rejected = false; + dialogButtons("Cancel", &rejected, nullptr, true, nullptr); + if (rejected || dialogEscapePressed()) { + ImGui::CloseCurrentPopup(); + finish(false); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h index 8f84096973..c0f6db791c 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.h +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,10 +12,15 @@ // "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. class RoutesDialog { public: + ~RoutesDialog() { if (auth_abort_) *auth_abort_ = true; } void open(std::function on_done); void draw(); + bool isActive() const { return alive_ != nullptr; } private: + void fetchDevices(); + void signIn(const std::string &provider); + void drawLogin(); void setDeviceList(const std::vector &devices, bool success, int error_code); void setRouteList(const std::vector &list, bool success); void fetchRoutes(); @@ -26,6 +32,9 @@ private: }; struct State { + bool login = false; + std::string provider; + std::string auth_error; bool devices_loaded = false; std::vector devices; int device_index = 0; @@ -36,6 +45,7 @@ private: int fetch_id = 0; // the reply of an older request is dropped }; + std::shared_ptr> auth_abort_; bool open_ = false; PopupOwner popup_; State s_; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 17cae9635c..d320216109 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -22,11 +22,13 @@ void OpenReplayWidget::draw() { ImGui::GetStyle().ItemSpacing.x * 2)); inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); + ImGui::BeginDisabled(routes_dialog_.isActive()); if (ImGui::Button("Remote Route...")) { routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { if (accepted) route_ = route; })); } + ImGui::EndDisabled(); ImGui::SameLine(); if (ImGui::Button("Local Route...")) { FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 1b7a2a7837..b11ecf28fa 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -224,23 +224,33 @@ bool iconButton(const char *id, const char *icon, const char *tooltip) { return clicked; } -float iconTextButtonWidth(const char *icon, const std::string &text) { +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options) { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x + ImGui::CalcTextSize(text.c_str(), nullptr, true).x + style.FramePadding.x * 2; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + return ImGui::CalcTextSize(icon).x + gap + std::max(ImGui::CalcTextSize(text.c_str(), nullptr, true).x, options.label_width) + + style.FramePadding.x * 2; } -bool iconTextButton(const char *id, const char *icon, const std::string &text, float width) { +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width, const IconTextButtonOptions &options) { const ImGuiStyle &style = ImGui::GetStyle(); - if (width <= 0.0f) width = iconTextButtonWidth(icon, text); - const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, 0.0f)); + const float icon_width = ImGui::CalcTextSize(icon).x; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float label_width = std::max(text_width, options.label_width); + if (width <= 0.0f) width = icon_width + gap + label_width + style.FramePadding.x * 2; + if (options.rounding >= 0.0f) ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, options.rounding); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, options.height)); + if (options.rounding >= 0.0f) ImGui::PopStyleVar(); const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const float top = min.y + (max.y - min.y - ImGui::GetFontSize()) * 0.5f; + const float left = min.x + (options.center_content ? std::max(style.FramePadding.x, (max.x - min.x - icon_width - gap - label_width) * 0.5f) + : style.FramePadding.x); + const float text_left = left + icon_width + gap; + const float slack = options.center_content ? 0.0f : std::max(0.0f, (max.x - style.FramePadding.x - text_left - text_width) * 0.5f); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); auto *draw_list = ImGui::GetWindowDrawList(); - draw_list->AddText(ImVec2(min.x + style.FramePadding.x, min.y + style.FramePadding.y), color, icon); - // the text is centered between the icon and the right padding - const float left = min.x + style.FramePadding.x + ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x; - const float slack = max.x - style.FramePadding.x - left - ImGui::CalcTextSize(text.c_str(), nullptr, true).x; - draw_list->AddText(ImVec2(left + std::max(0.0f, slack * 0.5f), min.y + style.FramePadding.y), color, text.c_str()); + draw_list->AddText(ImVec2(left, top), color, icon); + draw_list->AddText(ImVec2(text_left + slack, top), color, text.c_str()); return clicked; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 3f3cabb71a..d8f11700a7 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -84,8 +84,16 @@ inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+ bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); -bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); -float iconTextButtonWidth(const char *icon, const std::string &text); +struct IconTextButtonOptions { + float height = 0.0f; + float rounding = -1.0f; // Negative uses the theme default. + float icon_gap = -1.0f; // Negative uses the theme default. + bool center_content = false; + float label_width = 0.0f; // Shared width aligns labels in a group of centered buttons. +}; +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f, + const IconTextButtonOptions &options = {}); +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options = {}); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text);