diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 198fe00..87d495d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: run: | brew update brew install llvm + python3 scripts/install_qt.py env: HOMEBREW_NO_AUTO_UPDATE: 1 @@ -49,6 +50,12 @@ jobs: mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja + mingw-w64-ucrt-x86_64-python + + - name: Install Qt (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: python3 scripts/install_qt.py - name: Cache build directory uses: actions/cache@v4 diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 2e60a76..b0d5e0c 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -32,6 +32,7 @@ jobs: brew update brew install llvm@21 echo "/opt/homebrew/opt/llvm@21/bin" >> $GITHUB_PATH + python3 scripts/install_qt.py env: HOMEBREW_NO_AUTO_UPDATE: 1 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5f53411..b540fee 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -25,6 +25,7 @@ jobs: run: | brew update brew install llvm + python3 scripts/install_qt.py env: HOMEBREW_NO_AUTO_UPDATE: 1 @@ -38,8 +39,13 @@ jobs: mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja + mingw-w64-ucrt-x86_64-python zip tar + - name: Install Qt (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: python3 scripts/install_qt.py - name: Build and pack macOS if: runner.os == 'macOS' @@ -78,3 +84,17 @@ jobs: path: | *.tar.gz *.zip + + release: + needs: package + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + files: artifacts/* diff --git a/CMakeLists.txt b/CMakeLists.txt index f99da68..dabf130 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,7 +32,7 @@ file(GLOB_RECURSE ALL_SOURCES "${SRC_DIR}/*.cpp") set(INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include") file(GLOB_RECURSE HEADERS "${INCLUDE_DIR}/*.hpp" "${INCLUDE_DIR}/*.h") -set(LIB_SOURCES ${ALL_SOURCES}) +set(LIB_SOURCES ${ALL_SOURCES} ${HEADERS}) list(FILTER LIB_SOURCES EXCLUDE REGEX "main\\.cpp$") set(TEST_DIR "${CMAKE_SOURCE_DIR}/tests") diff --git a/MBR_data.json b/MBR_data.json index 219ba76..d7b4b99 100644 --- a/MBR_data.json +++ b/MBR_data.json @@ -3,9 +3,9 @@ { "key": "T", "name": "Timestamp", "required": true, "plot": false }, { "key": "E", "name": "Engine RPM", "required": false, "plot": true, "unit": "RPM", "group": "rpm" }, { "key": "W", "name": "Wheel RPM", "required": false, "plot": true, "unit": "RPM", "group": "rpm" }, - { "key": "fr", "name": "Front Right", "required": false, "plot": true, "unit": "mm", "group": "shock" }, - { "key": "fl", "name": "Front Left", "required": false, "plot": true, "unit": "mm", "group": "shock" }, - { "key": "br", "name": "Back Right", "required": false, "plot": true, "unit": "mm", "group": "shock" }, - { "key": "bl", "name": "Back Left", "required": false, "plot": true, "unit": "mm", "group": "shock" } + { "key": "FR", "name": "Front Right", "required": false, "plot": true, "unit": "mm", "group": "shock" }, + { "key": "FL", "name": "Front Left", "required": false, "plot": true, "unit": "mm", "group": "shock" }, + { "key": "RR", "name": "Back Right", "required": false, "plot": true, "unit": "mm", "group": "shock" }, + { "key": "RL", "name": "Back Left", "required": false, "plot": true, "unit": "mm", "group": "shock" } ] } diff --git a/cmake/CompilerFlags.cmake b/cmake/CompilerFlags.cmake index 334d74b..a50c3ab 100644 --- a/cmake/CompilerFlags.cmake +++ b/cmake/CompilerFlags.cmake @@ -8,7 +8,7 @@ elseif(WIN32) endif() if(MINGW) - add_link_options("-static-libgcc" "-static-libstdc++" "-static") + add_link_options("-static-libgcc" "-static-libstdc++") endif() set(CMAKE_CXX_STANDARD 23) diff --git a/cmake/MbrDaqSuite.cmake b/cmake/MbrDaqSuite.cmake index 171f74a..6c18dde 100644 --- a/cmake/MbrDaqSuite.cmake +++ b/cmake/MbrDaqSuite.cmake @@ -1,14 +1,13 @@ function(create_mbrdaq_suite TARGET_NAME EXTRA_FLAGS OUT_DIR) add_library(mbrdaq_core_${TARGET_NAME} STATIC ${LIB_SOURCES}) + set_target_properties(mbrdaq_core_${TARGET_NAME} PROPERTIES + AUTOMOC ON + AUTOUIC OFF + AUTORCC OFF + ) + target_link_libraries(mbrdaq_core_${TARGET_NAME} PUBLIC - imgui_setup - implot_setup tinyfiledialogs - opencv_core - opencv_imgproc - opencv_imgcodecs - opencv_videoio - opencv_highgui spdlog::spdlog ixwebsocket stb_image_setup @@ -20,6 +19,11 @@ function(create_mbrdaq_suite TARGET_NAME EXTRA_FLAGS OUT_DIR) magic_enum::magic_enum unordered_dense::unordered_dense stdx + Qt6::Core + Qt6::Widgets + Qt6::Charts + Qt6::PrintSupport + QCustomPlot ) target_include_directories(mbrdaq_core_${TARGET_NAME} PUBLIC include) @@ -35,11 +39,13 @@ function(create_mbrdaq_suite TARGET_NAME EXTRA_FLAGS OUT_DIR) target_compile_options(mbrdaq_core_${TARGET_NAME} PRIVATE ${BASE_FLAGS}) target_compile_options(mbrdaq_core_${TARGET_NAME} PRIVATE ${EXTRA_FLAGS}) + if(APPLE) - target_link_options(mbrdaq_core_${TARGET_NAME} PRIVATE ${EXTRA_FLAGS} "-static-libstdc++") + target_link_options(mbrdaq_core_${TARGET_NAME} PRIVATE ${EXTRA_FLAGS}) elseif(WIN32) target_link_options(mbrdaq_core_${TARGET_NAME} PRIVATE ${EXTRA_FLAGS}) endif() + set_target_properties(mbrdaq_core_${TARGET_NAME} PROPERTIES ARCHIVE_OUTPUT_DIRECTORY ${OUT_DIR}) @@ -53,6 +59,7 @@ function(create_mbrdaq_suite TARGET_NAME EXTRA_FLAGS OUT_DIR) set_target_properties(catch2_${TARGET_NAME} PROPERTIES ARCHIVE_OUTPUT_DIRECTORY ${OUT_DIR}) + # Test Executable add_executable(mbrdaq_tests_${TARGET_NAME} ${TEST_SOURCES}) target_link_libraries(mbrdaq_tests_${TARGET_NAME} PRIVATE catch2_${TARGET_NAME} mbrdaq_core_${TARGET_NAME}) @@ -64,6 +71,7 @@ function(create_mbrdaq_suite TARGET_NAME EXTRA_FLAGS OUT_DIR) target_compile_options(mbrdaq_tests_${TARGET_NAME} PRIVATE ${EXTRA_FLAGS}) target_link_options(mbrdaq_tests_${TARGET_NAME} PRIVATE ${EXTRA_FLAGS}) + set_target_properties(mbrdaq_tests_${TARGET_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${OUT_DIR}) endfunction() @@ -78,11 +86,20 @@ function(create_standard_target EXTRA_FLAGS TARGET_NAME) if (NOT MINGW) target_link_options(mbrdaq_${TARGET_NAME} PRIVATE "LINKER:/ENTRY:mainCRTStartup") endif() + elseif(APPLE) + add_executable(mbrdaq_${TARGET_NAME} MACOSX_BUNDLE src/main.cpp) + set_target_properties(mbrdaq_${TARGET_NAME} PROPERTIES + MACOSX_BUNDLE_BUNDLE_NAME ${PROJECT_NAME} + MACOSX_BUNDLE_GUI_IDENTIFIER "com.mbr.daq.${TARGET_NAME}" + MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} + ) else() add_executable(mbrdaq_${TARGET_NAME} src/main.cpp) endif() target_link_libraries(mbrdaq_${TARGET_NAME} PRIVATE mbrdaq_core_${TARGET_NAME}) + add_custom_target(${TARGET_NAME} DEPENDS mbrdaq_tests_${TARGET_NAME} mbrdaq_${TARGET_NAME}) @@ -90,13 +107,32 @@ function(create_standard_target EXTRA_FLAGS TARGET_NAME) RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${TARGET_NAME} OUTPUT_NAME ${PROJECT_NAME}) - # Windows still depends on a custom ffmpeg dll provided by opencv if(WIN32) + # Ensure destination directory exists add_custom_command(TARGET mbrdaq_${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/bin" COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_BINARY_DIR}/bin" "$" - COMMENT "Copy FFMPEG to output directory" + COMMENT "Copy FFMPEG binaries to output directory" ) + + if(WINDEPLOYQT_EXE) + add_custom_command(TARGET mbrdaq_${TARGET_NAME} POST_BUILD + COMMAND ${WINDEPLOYQT_EXE} + --dir "$" + --no-translations + "$" + COMMENT "Deploy Qt runtime libraries" + ) + endif() + elseif(APPLE) + if(MACDEPLOYQT_EXE) + add_custom_command(TARGET mbrdaq_${TARGET_NAME} POST_BUILD + COMMAND ${MACDEPLOYQT_EXE} + "$" + COMMENT "Deploy Qt frameworks into app bundle" + ) + endif() endif() endfunction() diff --git a/cmake/VendorSetup.cmake b/cmake/VendorSetup.cmake index 58a669b..2d147ef 100644 --- a/cmake/VendorSetup.cmake +++ b/cmake/VendorSetup.cmake @@ -45,85 +45,10 @@ add_vendor_fetch_content(gsl vendor/gsl) add_vendor_fetch_content(fmt vendor/fmt) # Sokol -add_library(sokol_setup STATIC src/vendor/sokol_impl.cpp) -target_include_directories(sokol_setup SYSTEM PUBLIC - vendor/sokol - vendor/sokol/util - vendor/imgui - vendor/imgui/backends) -target_compile_options(sokol_setup PRIVATE ${WARNING_IGNORE}) -if(APPLE) - target_compile_definitions(sokol_setup PUBLIC SOKOL_METAL) - set_source_files_properties(src/vendor/sokol_impl.cpp PROPERTIES LANGUAGE OBJCXX) - target_link_libraries(sokol_setup INTERFACE - "-framework Cocoa" - "-framework IOKit" - "-framework Metal" - "-framework MetalKit" - "-framework QuartzCore" - "-framework AVFoundation" - "-framework CoreMedia" - "-framework CoreVideo" - ) -elseif(WIN32) - target_compile_definitions(sokol_setup PUBLIC SOKOL_D3D11) - target_link_libraries(sokol_setup INTERFACE d3d11 dxgi dwmapi) -endif() - -# Dear ImGUI -add_library(imgui_setup STATIC - vendor/imgui/imgui.cpp - vendor/imgui/imgui_draw.cpp - vendor/imgui/imgui_widgets.cpp - vendor/imgui/imgui_tables.cpp - vendor/imgui/imgui_demo.cpp -) -target_include_directories(imgui_setup SYSTEM PUBLIC - vendor/imgui vendor/imgui/backends) -target_link_libraries(imgui_setup PUBLIC sokol_setup) - -# ImPlot -add_library(implot_setup STATIC - vendor/implot/implot.cpp - vendor/implot/implot_items.cpp - vendor/implot/implot_demo.cpp -) -target_include_directories(implot_setup SYSTEM PUBLIC vendor/implot) -target_link_libraries(implot_setup PUBLIC imgui_setup) -target_compile_options(implot_setup PRIVATE ${WARNING_IGNORE}) # OpenCV -set(BUILD_LIST "core,imgproc,imgcodecs,videoio,highgui" CACHE STRING "" FORCE) -set(BUILD_SHARED_LIBS OFF CACHE BOOL "Static linking" FORCE) -set(BUILD_opencv_apps OFF CACHE BOOL "Classifier training not needed" FORCE) -set(BUILD_opencv_python2 OFF CACHE BOOL "Disable python support" FORCE) -set(BUILD_opencv_python3 OFF CACHE BOOL "Disable python support" FORCE) -set(BUILD_EXAMPLES OFF CACHE BOOL "Examples not needed" FORCE) -set(BUILD_DOCS OFF CACHE BOOL "OpenCV docs are not needed" FORCE) -set(BUILD_TESTS OFF CACHE BOOL "Testing not needed for vendoring" FORCE) -set(BUILD_PERF_TESTS OFF CACHE BOOL "Testing not needed for vendoring" FORCE) -set(BUILD_CUDA_STUBS OFF CACHE BOOL "Cuda is never needed" FORCE) -set(WITH_CUDA OFF CACHE BOOL "" FORCE) -set(BUILD_JAVA OFF CACHE BOOL "" FORCE) -set(WITH_IPP OFF CACHE BOOL "Intel IPP support not needed" FORCE) -set(WITH_ITT OFF CACHE BOOL "Intel ITT support not needed" FORCE) -set(WITH_OPENCL OFF CACHE BOOL "" FORCE) -set(WITH_WEBP OFF CACHE BOOL "" FORCE) -set(WITH_OPENEXR OFF CACHE BOOL "" FORCE) - -set(OPENCV_WARNINGS_ARE_ERRORS OFF CACHE BOOL "" FORCE) -set(CMAKE_SUPPRESS_DEVELOPER_WARNINGS ON CACHE BOOL "" FORCE) -if(APPLE) - set(WITH_FFMPEG OFF CACHE BOOL "System dependencies instead" FORCE) - set(HAVE_FFMPEG OFF CACHE BOOL "System dependencies instead" FORCE) - set(WITH_AVFOUNDATION ON CACHE BOOL "Apple framework is statically linked" FORCE) - unset(FFMPEG_LIBRARIES CACHE) - unset(FFMPEG_INCLUDE_DIRS CACHE) -endif() - -add_vendor_subdirectory(vendor/opencv) # TinyFileDialogs add_library(tinyfiledialogs STATIC vendor/libtinyfiledialogs/tinyfiledialogs.c) @@ -230,3 +155,63 @@ option(ENABLE_STDX_PROFILE "Enable profiling/tracing in stdx" OFF) set(STDX_PROFILE ${ENABLE_STDX_PROFILE} CACHE BOOL "" FORCE) add_vendor_subdirectory(vendor/stdx) + +# Qt +if(WIN32) + list(APPEND CMAKE_PREFIX_PATH "C:/msys64/ucrt64") +elseif(APPLE) + # Homebrew Qt6 install location differs by CPU architecture + execute_process( + COMMAND brew --prefix qt + OUTPUT_VARIABLE HOMEBREW_QT_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + if(HOMEBREW_QT_PREFIX) + list(APPEND CMAKE_PREFIX_PATH "${HOMEBREW_QT_PREFIX}") + else() + # Fallback if `brew` isn't on PATH (e.g. CI) - covers both arches + list(APPEND CMAKE_PREFIX_PATH "/opt/homebrew/opt/qt" "/usr/local/opt/qt") + endif() +endif() + +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Charts PrintSupport) + +if(WIN32) + find_program(WINDEPLOYQT_EXE windeployqt + HINTS "${Qt6_DIR}/../../../bin" "${Qt6_DIR}/../../../share/qt6/bin" + ) + message(STATUS "windeployqt found: ${WINDEPLOYQT_EXE}") +elseif(APPLE) + find_program(MACDEPLOYQT_EXE macdeployqt + HINTS "${Qt6_DIR}/../../../bin" + ) + message(STATUS "macdeployqt found: ${MACDEPLOYQT_EXE}") +endif() + +# QCustomPlot +include(FetchContent) + +FetchContent_Declare( + qcustomplot + URL https://www.qcustomplot.com/release/2.1.1/QCustomPlot-source.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR "no-cmake" +) + +FetchContent_MakeAvailable(qcustomplot) + +add_library(QCustomPlot STATIC + ${qcustomplot_SOURCE_DIR}/qcustomplot.cpp + ${qcustomplot_SOURCE_DIR}/qcustomplot.h +) + +set_target_properties(QCustomPlot PROPERTIES + AUTOMOC ON + AUTOUIC OFF + AUTORCC OFF +) + +target_include_directories(QCustomPlot PUBLIC ${qcustomplot_SOURCE_DIR}) +target_compile_options(QCustomPlot PRIVATE ${WARNING_IGNORE}) +target_link_libraries(QCustomPlot PUBLIC Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::PrintSupport) diff --git a/include/app/app.hpp b/include/app/app.hpp index e6c24aa..5bc2562 100644 --- a/include/app/app.hpp +++ b/include/app/app.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -12,15 +13,16 @@ class gui_t; class app_t { public: - explicit app_t(i32 arc, char** argv); + explicit app_t(i32 argc, char** argv); ~app_t(); MAKE_PINNED(app_t); void run(); private: - std::unique_ptr gui_; - std::shared_ptr context_; + std::unique_ptr qt_app_; + std::unique_ptr gui_; + std::shared_ptr context_; }; } // namespace mbr diff --git a/include/app/assets/style.hpp b/include/app/assets/style.hpp new file mode 100644 index 0000000..6a10fc1 --- /dev/null +++ b/include/app/assets/style.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace mbr::ui::style { + +namespace color { + +// Dark Mode Theme Standards +inline constexpr QColor bg_dark(0x1e, 0x1e, 0x1e); +inline constexpr QColor bg_hover(0x2a, 0x2a, 0x2a); +inline constexpr QColor border(0x2d, 0x2d, 0x2d); +inline constexpr QColor text_main(0xd4, 0xd4, 0xd4); +inline constexpr QColor text_muted(0xa0, 0xa0, 0xa0); +inline constexpr QColor accent(0x00, 0xad, 0xb5); + +// Status Indicator Colors +inline constexpr QColor status_error(0xf4, 0x43, 0x36); +inline constexpr QColor status_warn(0xff, 0xc1, 07); +inline constexpr QColor status_ok(0x00, 0xe6, 0x76); + +// Graph Line Palette +inline constexpr std::array graph_palette = {QColor(0x00, 0xad, 0xb5), + QColor(0xff, 0x57, 0x22), + QColor(0xe9, 0x1e, 0x63), + QColor(0x9c, 0x27, 0xb0), + QColor(0x4c, 0xaf, 0x50), + QColor(0xff, 0xeb, 0x3b)}; +QColor get_graph_color(std::size_t index); + +} // namespace color + +// QSS Generators +struct button_style_options { + int font_size = 10; + int padding_v = 6; + int padding_h = 12; + int border_radius = 4; + QString align = "center"; +}; + +QString make_button_style(const button_style_options& opts = {}); +QString make_tree_style(); +QString make_menubar_style(int font_size_px = 20); +QString make_menu_style(int font_size_px = 20); +QString make_status_dot_style(const QColor& color, int diameter_px = 20); + +} // namespace mbr::ui::style diff --git a/include/app/assets/texture.hpp b/include/app/assets/texture.hpp deleted file mode 100644 index 978d929..0000000 --- a/include/app/assets/texture.hpp +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace mbr::assets { - -class button_texture { - public: - button_texture(gsl::span data); - ~button_texture(); - MAKE_MOVE_ONLY(button_texture); - - [[nodiscard]] ImTextureID get_id() const { return im_tex_id_; } - [[nodiscard]] bool is_valid() const { return im_tex_id_ != 0; } - - private: - sg_image image_{SG_INVALID_ID}; - sg_view view_{SG_INVALID_ID}; - ImTextureID im_tex_id_{0}; -}; - -template struct icon_texture { - icon_texture(gsl::span data) - : pixels{stbi_load_from_memory( - data.data(), static_cast(data.size()), &width, &height, &comp, 4)} { - size = static_cast(width * height) * comp; - } - ~icon_texture() { release(); }; - MAKE_MOVE_ONLY(icon_texture); - - // This is a noop if the texture is managed - void release() { - if constexpr (!Managed) { - stbi_image_free(pixels); - pixels = nullptr; - } - } - - i32 width; - i32 height; - i32 comp; - unsigned char* pixels; - usize size; -}; - -} // namespace mbr::assets diff --git a/include/app/backend_bridge.hpp b/include/app/backend_bridge.hpp new file mode 100644 index 0000000..da06671 --- /dev/null +++ b/include/app/backend_bridge.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include +#include + +namespace mbr::ui::bridge { + +class backend_bridge : public QObject { + Q_OBJECT + public: + explicit backend_bridge(telemetry_backend& backend, QObject* parent = nullptr) + : QObject(parent) { + backend.set_on_data( + [this](const ankerl::unordered_dense::map>& d) { + emit data_updated(d); + }); + backend.set_on_connection_changed([this](bool c) { emit connection_changed(c); }); + backend.set_on_receiving_changed([this](bool r) { emit receiving_changed(r); }); + backend.set_on_fields_received( + [this](const std::vector& keys) { emit fields_received(keys); }); + } + signals: + void data_updated(const ankerl::unordered_dense::map>&); + void connection_changed(bool connected); + void receiving_changed(bool receiving); + void fields_received(const std::vector& keys); +}; + +} // namespace mbr::ui::bridge diff --git a/include/app/context.hpp b/include/app/context.hpp index 2128e16..4d150bb 100644 --- a/include/app/context.hpp +++ b/include/app/context.hpp @@ -1,12 +1,13 @@ #pragma once +#include #include #include #include #include -#include "app/style.hpp" +#include "app/backend_bridge.hpp" #include "core/log.hpp" #include "esp32/backend.hpp" @@ -14,20 +15,20 @@ namespace mbr { enum class page_type_t : u8 { HOME, - RPM, - SHOCK, - VIEW, - SERIAL, + PLOT, + ANALYSIS, + SERIAL_MONITOR, + SETTINGS, }; [[nodiscard]] constexpr const char* page_type_str(page_type_t page_type) { switch (page_type) { - case page_type_t::HOME: return "Home"; - case page_type_t::RPM: return "RPM"; - case page_type_t::SHOCK: return "Shock"; - case page_type_t::VIEW: return "View"; - case page_type_t::SERIAL: return "Serial Monitor"; - default: return "Unknown"; + case page_type_t::HOME: return "Home"; + case page_type_t::PLOT: return "Plot"; + case page_type_t::ANALYSIS: return "Analysis"; + case page_type_t::SERIAL_MONITOR: return "Serial Monitor"; + case page_type_t::SETTINGS: return "Settings"; + default: return "Unknown"; } } @@ -35,15 +36,15 @@ class telemetry_backend; struct app_context { log_t logger; - app_style style; page_type_t current_page_type; std::atomic should_exit{false}; bool is_cmd_input_focused{false}; - std::unique_ptr backend; - std::string username; - std::string password; - log_fn_t log; + std::unique_ptr backend; + std::unique_ptr bridge; + std::string username; + std::string password; + log_fn_t log; }; } // namespace mbr diff --git a/include/app/gui.hpp b/include/app/gui.hpp index d62110a..26c8824 100644 --- a/include/app/gui.hpp +++ b/include/app/gui.hpp @@ -1,35 +1,45 @@ #pragma once #include -#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include #include "app/context.hpp" #include "app/pages/page.hpp" -#include "assets/texture.hpp" - -struct sapp_event; -struct sapp_desc; namespace mbr { -class gui_t { +class gui_t : public QMainWindow { + Q_OBJECT public: - explicit gui_t(const std::shared_ptr& ctx) : context_{ctx} {}; - - sapp_desc get_sokol_desc(); - void on_init(); - void on_frame(); - void on_event(const sapp_event* event); - void on_cleanup(); + explicit gui_t(const std::shared_ptr& ctx); + ~gui_t() = default; private: void change_page(page_type_t type); - void draw_main_menu_bar(); + void build_menu_bar(); + void update_status_dot(); + [[nodiscard]] ui::pages::page* + create_page(page_type_t type, const std::shared_ptr& ctx, QWidget* parent); private: - std::unique_ptr current_page_; - std::shared_ptr context_; - std::string command_buf_; + QStackedWidget* pages_; + std::shared_ptr context_; + stdx::fixed::enum_map page_lookup_; + QLabel* connection_status_; + QLabel* status_dot_ = nullptr; + bool is_connected_ = false; + bool is_receiving_ = false; }; } // namespace mbr diff --git a/include/app/pages/analysis.hpp b/include/app/pages/analysis.hpp new file mode 100644 index 0000000..9d29cc8 --- /dev/null +++ b/include/app/pages/analysis.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "app/context.hpp" +#include "app/pages/page.hpp" +#include "core/time.hpp" + +namespace mbr::ui::pages { + +class analysis_page : public page { + public: + explicit analysis_page(const std::shared_ptr& ctx, QWidget* parent = nullptr) + : page(ctx, parent) {}; + ~analysis_page() override = default; + + void on_enter() override; + void on_exit() override; + void build_page() override; + + private: +}; + +} // namespace mbr::ui::pages diff --git a/include/app/pages/home.hpp b/include/app/pages/home.hpp index 4ee55b8..b2802b7 100644 --- a/include/app/pages/home.hpp +++ b/include/app/pages/home.hpp @@ -3,37 +3,41 @@ #include #include +#include +#include + #include "app/context.hpp" #include "app/pages/page.hpp" #include "core/ip.hpp" -namespace mbr::pages { +namespace mbr::ui::pages { class home_page : public page { public: - explicit home_page(const std::shared_ptr& ctx) : page{ctx} {} + explicit home_page(const std::shared_ptr& ctx, QWidget* parent = nullptr) + : page{ctx, parent} { + build_page(); + } ~home_page() override = default; void on_enter() override; void on_exit() override; - void update() override; + void build_page() override; private: - void draw_top_lhs(); - void draw_top_rhs(); - - void draw_bottom_lhs(); - void draw_bottom_rhs(); - void draw_ip_controls(); - void draw_credential_controls(); + QWidget* build_rhs(); + QWidget* build_lhs(); + void connect_signals(); private: - ipv4_t previous_ip_; - ipv4_t ip_buf_; - std::string username_buf_; - std::string password_buf_; - std::string sd_name_; - std::string set_name_; + ipv4_t previous_ip_; + ipv4_t ip_buf_; + std::string username_buf_; + std::string password_buf_; + std::string sd_name_; + std::string set_name_; + ankerl::unordered_dense::map labels_; + ankerl::unordered_dense::map last_updated_; }; -} // namespace mbr::pages +} // namespace mbr::ui::pages diff --git a/include/app/pages/page.hpp b/include/app/pages/page.hpp index 1b47ab8..7916c26 100644 --- a/include/app/pages/page.hpp +++ b/include/app/pages/page.hpp @@ -1,23 +1,27 @@ #pragma once +#include #include +#include #include "app/context.hpp" -namespace mbr::pages { +namespace mbr::ui::pages { -class page { +class page : public QWidget { + Q_OBJECT public: page() = default; virtual ~page() = default; - virtual void on_enter() = 0; - virtual void on_exit() = 0; - virtual void update() = 0; + virtual void on_enter() = 0; + virtual void on_exit() = 0; + virtual void build_page() = 0; protected: - explicit page(const std::shared_ptr& ctx) : context_{ctx} {} + explicit page(const std::shared_ptr& ctx, QWidget* parent = nullptr) + : QWidget(parent), context_{ctx} {} std::shared_ptr context_; }; -} // namespace mbr::pages +} // namespace mbr::ui::pages diff --git a/include/app/pages/plot.hpp b/include/app/pages/plot.hpp new file mode 100644 index 0000000..7fea55a --- /dev/null +++ b/include/app/pages/plot.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/pages/page.hpp" +#include "esp32/data.hpp" + +namespace mbr::ui::pages { + +class plot_page : public page { + public: + explicit plot_page(const std::shared_ptr& ctx, QWidget* parent = nullptr) + : page{ctx, parent} { + build_page(); + } + ~plot_page() override = default; + + void on_enter() override; + void on_exit() override; + void build_page() override; + + private: + QWidget* build_rhs(); + QWidget* build_lhs(); + QToolButton* create_tree_dropdown(const std::vector& data, + QWidget* parent); + void plot_signal(const std::string& key); + void plot_timer(); + void update_plot(); + void update_stream(); + + private: + QPlainTextEdit* text_log_; + QCustomPlot* plot_; + ankerl::unordered_dense::map active_graphs_; + + ankerl::unordered_dense::map plotted_counts_; + QTimer* redraw_timer_ = nullptr; + + double time_offset_ = 0.0; + bool pending_update_ = false; + bool has_offset_ = false; + + private slots: + void on_data_updated(); +}; + +} // namespace mbr::ui::pages diff --git a/include/app/pages/rpm.hpp b/include/app/pages/rpm.hpp deleted file mode 100644 index 867061a..0000000 --- a/include/app/pages/rpm.hpp +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -#include "app/pages/page.hpp" -#include "app/pages/utils.hpp" - -namespace mbr::pages { - -class rpm_page : public page { - public: - explicit rpm_page(const std::shared_ptr& ctx) : page{ctx}, text_drawer_{ctx} {} - ~rpm_page() override = default; - - void on_enter() override; - void on_exit() override; - void update() override; - - private: - void DrawLHS(const std::vector& raw_lines); - void DrawRHS(gsl::span time, - gsl::span wheel, - gsl::span engine); - - private: - utils::text_drawers text_drawer_; - std::string download_fd_text_; -}; - -} // namespace mbr::pages diff --git a/include/app/pages/serialmon.hpp b/include/app/pages/serialmon.hpp index 3195928..49aaf76 100644 --- a/include/app/pages/serialmon.hpp +++ b/include/app/pages/serialmon.hpp @@ -3,30 +3,26 @@ #include #include +#include #include #include "app/pages/page.hpp" -#include "app/pages/utils.hpp" -namespace mbr::pages { +namespace mbr::ui::pages { class serial_page : public page { public: - explicit serial_page(const std::shared_ptr& ctx) : page{ctx}, text_drawer_{ctx} {} + explicit serial_page(const std::shared_ptr& ctx, QWidget* parent = nullptr) + : page{ctx, parent} {} ~serial_page() override = default; void on_enter() override; void on_exit() override; - void update() override; + void build_page() override; private: - void draw_top_lhs(); - void draw_top_rhs(); - void draw_bottom(); - private: - utils::text_drawers text_drawer_; - std::string serial_buffer_; + std::string serial_buffer_; }; -} // namespace mbr::pages +} // namespace mbr::ui::pages diff --git a/include/app/pages/settings.hpp b/include/app/pages/settings.hpp new file mode 100644 index 0000000..9acb5ee --- /dev/null +++ b/include/app/pages/settings.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include + +#include "app/context.hpp" +#include "app/pages/page.hpp" +#include "core/ip.hpp" + +namespace mbr::ui::pages { + +class settings_page : public page { + public: + explicit settings_page(const std::shared_ptr& ctx, QWidget* parent = nullptr) + : page{ctx, parent} {} + ~settings_page() override = default; + + void on_enter() override; + void on_exit() override; + void build_page() override; + + private: + private: +}; + +} // namespace mbr::ui::pages diff --git a/include/app/pages/shock.hpp b/include/app/pages/shock.hpp deleted file mode 100644 index f31c933..0000000 --- a/include/app/pages/shock.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -#include "app/pages/page.hpp" -#include "app/pages/utils.hpp" - -namespace mbr::pages { - -class shock_page : public page { - public: - explicit shock_page(const std::shared_ptr& ctx) : page{ctx}, text_utils_{ctx} {} - ~shock_page() override = default; - - void on_enter() override; - void on_exit() override; - void update() override; - - void draw_lhs(const std::vector& raw_lines); - void draw_rhs(gsl::span time, - gsl::span fr, - gsl::span fl, - gsl::span br, - gsl::span bl); - - private: - utils::text_drawers text_utils_; - std::string download_fd_text_; -}; - -} // namespace mbr::pages diff --git a/include/app/pages/utils.hpp b/include/app/pages/utils.hpp deleted file mode 100644 index 457af4b..0000000 --- a/include/app/pages/utils.hpp +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace mbr { - -struct app_context; - -namespace pages::utils { - -template -void plot_if_non_empty(const char* label, - gsl::span x, - gsl::span y, - bool extra_condition = true, - usize data_clip_position = 0) { - if (!x.empty() && !y.empty() && extra_condition) { - const usize n = std::min(x.size(), y.size()); - const usize count = n - std::min(n, data_clip_position); - ImPlot::PlotLine(label, x.data(), y.data(), count); - } -} - -class text_drawers { - public: - explicit text_drawers(const std::shared_ptr& ctx) : context_{ctx} {} - - void start_logging_button(); - bool start_serial_button(); - void send_data_button(); - - // Appends the buf data to the filepath, clearing the buf on success. - void data_download_button(const std::vector& raw_lines, std::string& buf); - - private: - std::shared_ptr context_; -}; - -bool draw_input_box(const char* label, - std::string& buf, - stdx::option hint = stdx::none, - float width_scale = 200.0F, - ImGuiInputTextFlags flags = 0); -void draw_data_log(gsl::span raw_lines); - -} // namespace pages::utils - -} // namespace mbr diff --git a/include/app/pages/view.hpp b/include/app/pages/view.hpp deleted file mode 100644 index 52edbd9..0000000 --- a/include/app/pages/view.hpp +++ /dev/null @@ -1,134 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "app/assets/texture.hpp" -#include "app/context.hpp" -#include "app/pages/page.hpp" -#include "core/time.hpp" - -namespace mbr::pages { - -class view_page : public page { - public: - using selected_video_t = stdx::option>>; - using selected_txt_file_t = stdx::option; - - enum class data_view_t : u8 { - ALL, - RPMDATA, - SHOCKDATA, - }; - - [[nodiscard]] static const char* data_type_string(data_view_t type); - - public: - explicit view_page(const std::shared_ptr& ctx); - ~view_page() override; - - void on_enter() override; - void on_exit() override; - void update() override; - - private: - void cleanup(); - - void draw_lhs(); - void draw_lhs_controls(); - void draw_open_video(); - - void draw_rhs(); - void draw_open_text(); - void draw_sync_video_buttons(); - - selected_video_t open_video_file(const std::string& previous_file); - void request_seek(i32 frame_index); - - selected_txt_file_t open_text_file(const std::string& previous_file); - void load_data(); - stdx::option sync_data_video(const std::vector& micros_times); - void delete_extra(usize erase_pos); - void dynamic_plot_start(); - void dynamic_plot_loop(); - - void start_decoding_thread(); - void stop_decoding_thread(); - void update_texture(bool is_timer_tick); - - void try_cleanup_sokol_resources(); - - private: - std::shared_ptr is_alive_; - - std::string video_path_; - stdx::option video_creation_ts_; - std::string creation_metadata_text_buf_; - std::atomic video_dialog_running_{false}; - std::mutex video_path_mutex_; - selected_video_t selected_video_; - std::string input_time_; - bool video_loaded_{false}; - bool video_hovered_{false}; - - bool dynamic_plotting_{false}; - usize plot_percent_; - double points_per_{0.0}; - double data_count_{0.0}; - double data_from_end_{0.0}; - - std::string txt_path_; - std::atomic txt_dialog_running_{false}; - std::mutex txt_path_mutex_; - selected_txt_file_t selected_txt_; - bool txt_loaded_{false}; - bool data_and_time_sync_{false}; - - i32 total_frames_{0}; - double video_fps_{0.0}; - double video_length_minutes_{0.0}; - std::string video_length_formatted_; - double frame_duration_{0.0}; - double time_accumulator_{0.0}; - i32 current_frame_ui_{0}; - - std::atomic is_playing_{false}; - std::atomic is_looping_{false}; - std::atomic seek_target_{-1}; - std::atomic force_update_frame_{false}; - data_view_t data_show_{data_view_t::ALL}; - - std::thread decode_thread_; - std::atomic thread_running_{false}; - std::mutex frame_mutex_; - std::deque> frame_queue_; - std::condition_variable queue_cv_; - - ImVec2 button_size_{24, 24}; - assets::button_texture play_button_; - assets::button_texture pause_button_; - assets::button_texture step_button_; - - sg_image video_texture_{SG_INVALID_ID}; - sg_view video_view_{SG_INVALID_ID}; - ImTextureID video_texture_id_{0}; - i32 texture_width_{0}; - i32 texture_height_{0}; - - bool timestamp_input_focused_{false}; -}; - -} // namespace mbr::pages diff --git a/include/app/style.hpp b/include/app/style.hpp deleted file mode 100644 index fcafb8b..0000000 --- a/include/app/style.hpp +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -struct ImFont; - -#include "core/log.hpp" - -namespace mbr { - -struct app_fonts { - ImFont* regular; - ImFont* bold; - ImFont* italic; - ImFont* bold_italic; -}; - -app_fonts load_fonts(); - -struct app_style { - void set_dark_theme(const log_fn_t& log = nullptr); - void set_light_theme(const log_fn_t& log = nullptr); - - app_fonts default_fonts; - bool dark_mode{true}; -}; - -constexpr float DEFAULT_FONT_SIZE = 22.0F; -constexpr float HEADER_FONT_SIZE = 26.0F; -constexpr float MAIN_MENU_BAR_FONT_SIZE = 30.0F; -constexpr float MAIN_MENU_ITEM_FONT_SIZE = 28.0F; -constexpr float MENU_BAR_FONT_SIZE = 26.0F; -constexpr float MENU_ITEM_FONT_SIZE = 24.0F; - -#define WITH_FONT(font, size, code) \ - do { \ - ImGui::PushFont(font, size); \ - code; \ - ImGui::PopFont(); \ - } while (false) - -#define BOLD_DEFAULT(B) WITH_FONT(context_->style.default_fonts.bold, DEFAULT_FONT_SIZE, B) -#define HEADER(B) WITH_FONT(context_->style.default_fonts.regular, HEADER_FONT_SIZE, B) -#define BOLD_HEADER(B) WITH_FONT(context_->style.default_fonts.bold, HEADER_FONT_SIZE, B) -#define MAIN_MENU_BAR(B) \ - WITH_FONT(context_->style.default_fonts.regular, MAIN_MENU_BAR_FONT_SIZE, B) -#define MAIN_MENU_BAR_ITEM(B) \ - WITH_FONT(context_->style.default_fonts.regular, MAIN_MENU_ITEM_FONT_SIZE, B) - -} // namespace mbr diff --git a/include/esp32/backend.hpp b/include/esp32/backend.hpp index 35c9716..67a195d 100644 --- a/include/esp32/backend.hpp +++ b/include/esp32/backend.hpp @@ -34,6 +34,42 @@ enum class response_type_t : u8 { }; class telemetry_backend { + + public: + using data_cb = + std::function>&)>; + using status_cb = std::function; + using receiving_cb = std::function; + using cmd_cb = std::function; + using fields_cb = std::function&)>; + + void set_on_data(data_cb cb) { on_data_ = std::move(cb); } + void set_on_connection_changed(status_cb cb) { on_connection_changed_ = std::move(cb); } + void set_on_receiving_changed(receiving_cb cb) { on_receiving_changed_ = std::move(cb); } + void set_on_cmd(cmd_cb cb) { on_cmd_ = std::move(cb); } + void set_on_fields_received(fields_cb cb) { on_fields_ = std::move(cb); } + + private: + void notify_data(const ankerl::unordered_dense::map>& s) { + if (on_data_) { on_data_(s); } + } + void notify_connection_changed(bool c) { + if (on_connection_changed_) { on_connection_changed_(c); } + } + void notify_receiving_changed(bool r) { + if (on_receiving_changed_) { on_receiving_changed_(r); } + } + + void notify_fields_received(const std::vector& keys) { + if (on_fields_) { on_fields_(keys); } + } + + data_cb on_data_; + status_cb on_connection_changed_; + receiving_cb on_receiving_changed_; + cmd_cb on_cmd_; + fields_cb on_fields_; + public: explicit telemetry_backend(const std::filesystem::path& json_path, log_fn_t log = nullptr); ~telemetry_backend(); @@ -48,8 +84,6 @@ class telemetry_backend { [[nodiscard]] const telemetry_data& get_data() const noexcept { return data_; } [[nodiscard]] serial_manager_t& get_serial_manager() noexcept { return serial_manager_; } - [[nodiscard]] std::atomic& get_try_connection() noexcept { return try_connection_; } - [[nodiscard]] bool is_connected() const noexcept { return is_connected_.load(std::memory_order_relaxed); } @@ -67,7 +101,8 @@ class telemetry_backend { void set_logging(bool value) noexcept { is_logging_.store(value, std::memory_order_relaxed); } stdx::option>> - validate_packet(std::string_view str) const; + validate_packet(std::string_view str) const; + void restart(); private: void worker_loop(); @@ -81,7 +116,6 @@ class telemetry_backend { rw_latch data_latch_; telemetry_data data_; serial_manager_t serial_manager_; - std::atomic try_connection_{false}; std::atomic is_connected_{false}; std::atomic is_logging_{false}; std::atomic is_receiving_{false}; diff --git a/include/esp32/data.hpp b/include/esp32/data.hpp index 2ed100f..dbc5554 100644 --- a/include/esp32/data.hpp +++ b/include/esp32/data.hpp @@ -46,18 +46,20 @@ class telemetry_data { [[nodiscard]] const std::string& get_current_line() { return current_line_; } template - [[nodiscard]] auto& get_series(this Self&& self, const std::string& key) noexcept { - auto it = self.series.find(key); - if (it != self.series.end()) { return it->second; } + [[nodiscard]] auto get_series(this Self&& self, const std::string& key) noexcept { + using ret_t = stdx::option>&>; - if (!self.logged_missing_keys_.contains(key)) { - self.logged_missing_keys_.insert(key); + auto it = self.series_.find(key); + if (it != self.series_.end()) { return ret_t{it->second}; } + + if (self.logged_missing_keys_.insert(key).second) { log_error(self.log_, "Key '{}' not found in telemetry configuration!", key); } - static stdx::const_dispatch_t> dummy; - return dummy; + return ret_t{}; } + [[nodiscard]] const auto& get_series() noexcept { return series_; } + void set_sync_lt(local_time lt) noexcept { sync_lt_ = lt; } void write_data(const std::string& identifier, const std::string& value); void write_raw_line(const std::string& message); @@ -76,7 +78,7 @@ class telemetry_data { std::vector raw_lines_; double sync_start_; stdx::option sync_lt_; - ankerl::unordered_dense::map> series; + ankerl::unordered_dense::map> series_; mutable ankerl::unordered_dense::set logged_missing_keys_; friend class pages::view_page; diff --git a/scripts/install_qt.py b/scripts/install_qt.py new file mode 100644 index 0000000..28658f3 --- /dev/null +++ b/scripts/install_qt.py @@ -0,0 +1,18 @@ +"""scripts/install_qt.py — installs Qt6 for local dev.""" +import platform +import subprocess +import os +import sys + +if platform.system() == "Darwin": + subprocess.run(["brew", "install", "qt@6"], check=True) +elif "MSYSTEM" in os.environ: + subprocess.run( + ["pacman", "-S", "--noconfirm", "--needed", + "mingw-w64-ucrt-x86_64-qt6-base", + "mingw-w64-ucrt-x86_64-qt6-tools", + "mingw-w64-ucrt-x86_64-qt6-charts"], + check=True, + ) +else: + sys.exit("Run on macOS, or on Windows from an MSYS2 UCRT64 shell.") diff --git a/src/app/app.cpp b/src/app/app.cpp index 1ead367..4d5856e 100644 --- a/src/app/app.cpp +++ b/src/app/app.cpp @@ -3,13 +3,14 @@ #include #include +#include +#include #include -#include -#include -#include -#include +#include +#include #include +#include "app/backend_bridge.hpp" #include "app/context.hpp" #include "app/gui.hpp" #include "core/log.hpp" @@ -20,18 +21,22 @@ namespace mbr { namespace { const std::filesystem::path DEFAULT_JSON_PATH{"MBR_data.json"}; } // namespace -app_t::app_t(i32, char**) : context_{std::make_shared()} /*, m_Manager{m_Context}*/ { +app_t::app_t(i32 argc, char** argv) + : qt_app_{std::make_unique(argc, argv)}, + context_{std::make_shared()} { context_->log = context_->logger.get_log_fn(); ix::initNetSystem(); - gui_ = std::make_unique(context_); context_->backend = std::make_unique(DEFAULT_JSON_PATH, context_->log); + context_->bridge = std::make_unique(*context_->backend); + gui_ = std::make_unique(context_); + context_->backend->start(); } app_t::~app_t() { ix::uninitNetSystem(); } void app_t::run() { - auto app_desc = gui_->get_sokol_desc(); - sapp_run(&app_desc); + gui_->showMaximized(); + qt_app_->exec(); } } // namespace mbr diff --git a/src/app/assets/style.cpp b/src/app/assets/style.cpp new file mode 100644 index 0000000..d4d9f65 --- /dev/null +++ b/src/app/assets/style.cpp @@ -0,0 +1,97 @@ +#include + +#include +#include + +#include +#include + +#include "app/assets/style.hpp" + +namespace mbr::ui::style { + +QColor color::get_graph_color(usize index) { + return QColor(graph_palette[index % graph_palette.size()]); +} + +QString make_button_style(const button_style_options& opts) { + return QString::fromStdString( + fmt::format("QToolButton {{" + " background-color: {0}; color: {1}; border: 1px solid {2};" + " border-radius: {3}px; padding: {4}px {5}px; font: {6}pt; text-align: {7};" + "}}" + "QToolButton:hover {{ background-color: {8}; }}", + color::bg_dark.name().toStdString(), + color::text_main.name().toStdString(), + color::border.name().toStdString(), + opts.border_radius, + opts.padding_v, + opts.padding_h, + opts.font_size, + opts.align.toStdString(), + color::bg_hover.name().toStdString())); +} + +QString make_tree_style() { + return QString::fromStdString(fmt::format("QTreeWidget {{" + " background-color: {0};" + " color: {1};" + " border: 1px solid {2};" + " font: 14px;" + "}}", + color::bg_dark.name().toStdString(), + color::text_main.name().toStdString(), + color::border.name().toStdString(), + color::accent.name().toStdString(), + color::text_main.name().toStdString())); +} + +QString make_menubar_style(int font_size_px) { + return QString::fromStdString( + fmt::format("QMenuBar {{" + " background-color: {0}; color: {1}; font: {2}px; spacing: 2px;" + " border-bottom: 1px solid {3};" + "}}" + "QMenuBar::item {{" + " background-color: transparent; padding: 4px 8px; border-radius: 4px; " + "border: 1px solid transparent;" + "}}" + "QMenuBar::item:selected {{" + " background-color: {4}; border: 1px solid {3};" + "}}", + color::bg_dark.name().toStdString(), + color::text_main.name().toStdString(), + font_size_px, + color::border.name().toStdString(), + color::bg_hover.name().toStdString())); +} + +QString make_menu_style(int font_size_px) { + return QString::fromStdString( + fmt::format("QMenu {{" + " background-color: {0}; color: {1}; font-size: {2}px; border: 1px solid " + "{3}; padding: 4px;" + "}}" + "QMenu::item {{" + " background-color: transparent; padding: 4px 24px 4px 12px; border-radius: " + "4px; border: 1px solid transparent;" + "}}" + "QMenu::item:selected {{" + " background-color: {4}; border: 1px solid {3};" + "}}" + "QMenu::item:disabled {{ color: {5}; }}" + "QMenu::separator {{ height: 1px; background-color: {3}; margin: 4px 6px; }}", + color::bg_dark.name().toStdString(), + color::text_main.name().toStdString(), + font_size_px, + color::border.name().toStdString(), + color::bg_hover.name().toStdString(), + color::text_muted.name().toStdString())); +} + +QString make_status_dot_style(const QColor& color, int diameter_px) { + return QString::fromStdString(fmt::format( + "background-color: {}; border-radius: {}px;", color.name().toStdString(), diameter_px / 2)); +} + +} // namespace mbr::ui::style diff --git a/src/app/assets/texture.cpp b/src/app/assets/texture.cpp deleted file mode 100644 index 1b28b7e..0000000 --- a/src/app/assets/texture.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "app/assets/texture.hpp" - -#include -#include -#include -#include - -namespace mbr::assets { - -button_texture::button_texture(gsl::span data) { - i32 width, height, comp; - auto* pixels = stbi_load_from_memory( - data.data(), static_cast(data.size()), &width, &height, &comp, 4); - ASSERT(pixels, "Image load from memory failed"); - - sg_image_desc img_desc = {}; - img_desc.width = width; - img_desc.height = height; - img_desc.pixel_format = SG_PIXELFORMAT_RGBA8; - img_desc.num_mipmaps = 1; - img_desc.data.mip_levels[0].ptr = pixels; - img_desc.data.mip_levels[0].size = static_cast(width * height) * comp; - - image_ = sg_make_image(&img_desc); - stbi_image_free(pixels); - ASSERT(image_.id != SG_INVALID_ID, "Image allocation failed"); - - sg_view_desc view_desc = {}; - view_desc.texture.image = image_; - - view_ = sg_make_view(&view_desc); - ASSERT(view_.id != SG_INVALID_ID, "View allocation failed"); - im_tex_id_ = simgui_imtextureid(view_); -} - -button_texture::~button_texture() { - if (view_.id != SG_INVALID_ID) { - sg_destroy_view(view_); - view_.id = SG_INVALID_ID; - } - - if (image_.id != SG_INVALID_ID) { - sg_destroy_image(image_); - image_.id = SG_INVALID_ID; - im_tex_id_ = 0; - } -} - -} // namespace mbr::assets diff --git a/src/app/backend_bridge.cpp b/src/app/backend_bridge.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/app/gui.cpp b/src/app/gui.cpp index 71ff090..6f3c247 100644 --- a/src/app/gui.cpp +++ b/src/app/gui.cpp @@ -1,267 +1,145 @@ #include "app/gui.hpp" #include +#include +#include +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include #include #include +#include +#include +#include +#include +#include +#include +#include + #include "app/assets/images/app_icon.hpp" -#include "app/assets/texture.hpp" +#include "app/assets/style.hpp" +#include "app/backend_bridge.hpp" #include "app/context.hpp" +#include "app/pages/analysis.hpp" #include "app/pages/home.hpp" -#include "app/pages/rpm.hpp" +#include "app/pages/page.hpp" +#include "app/pages/plot.hpp" #include "app/pages/serialmon.hpp" -#include "app/pages/shock.hpp" -#include "app/pages/utils.hpp" -#include "app/pages/view.hpp" -#include "app/style.hpp" +#include "app/pages/settings.hpp" #include "core/time.hpp" +#include "esp32/backend.hpp" +#include "stdx/enum.hh" using namespace std::chrono; namespace mbr { -namespace { - -void sokol_init_cb(void* data) { - gsl::not_null gui = static_cast(data); - gui->on_init(); -} - -void sokol_cleanup_cb(void* data) { - gsl::not_null gui = static_cast(data); - gui->on_cleanup(); -} - -void sokol_frame_cb(void* data) { - gsl::not_null gui = static_cast(data); - gui->on_frame(); -} - -void sokol_event_cb(const sapp_event* e, void* data) { - gsl::not_null gui = static_cast(data); - gui->on_event(e); -} - -void sokol_start_frame() { - simgui_frame_desc_t frame_desc = {}; - frame_desc.width = sapp_width(); - frame_desc.height = sapp_height(); - frame_desc.delta_time = sapp_frame_duration(); - frame_desc.dpi_scale = sapp_dpi_scale(); - simgui_new_frame(&frame_desc); -} - -void sokol_end_frame() { - sg_pass_action pass_action = {}; - pass_action.colors[0].load_action = SG_LOADACTION_CLEAR; - pass_action.colors[0].clear_value = {.r = 0.1F, .g = 0.1F, .b = 0.1F, .a = 1.0F}; - - sg_pass pass = {}; - pass.action = pass_action; - pass.swapchain = sglue_swapchain(); - - sg_begin_pass(&pass); - simgui_render(); - sg_end_pass(); - sg_commit(); -} - -} // namespace - -sapp_desc gui_t::get_sokol_desc() { - sapp_desc desc = {}; - desc.init_userdata_cb = sokol_init_cb; - desc.frame_userdata_cb = sokol_frame_cb; - desc.cleanup_userdata_cb = sokol_cleanup_cb; - desc.event_userdata_cb = sokol_event_cb; - desc.width = 1'920; - desc.height = 1'080; - desc.window_title = "Michigan Baja Racing - Data Suite"; - desc.icon.sokol_default = false; - desc.user_data = this; - - // The app description takes ownership of the icon here - assets::icon_texture app_icon{assets::BAJA_LOGO_PNG}; - desc.icon.images[0].width = app_icon.width; - desc.icon.images[0].height = app_icon.height; - desc.icon.images[0].pixels = {.ptr = app_icon.pixels, .size = app_icon.size}; - - return desc; -} - -void gui_t::on_init() { - sg_desc sg_description = {}; - sg_description.environment = sglue_environment(); - sg_description.logger.func = slog_func; - sg_setup(&sg_description); - - simgui_desc_t si_desc = {}; - si_desc.no_default_font = true; - simgui_setup(&si_desc); - - auto& io = ImGui::GetIO(); - context_->style.default_fonts = load_fonts(); - io.FontDefault = context_->style.default_fonts.regular; - - ImPlot::CreateContext(); - context_->style.set_dark_theme(); - - change_page(page_type_t::HOME); -} - -void gui_t::on_frame() { - PROFILE_FUNCTION(); - sokol_start_frame(); - const auto cleanup_frame{gsl::finally(sokol_end_frame)}; - MAIN_MENU_BAR(draw_main_menu_bar()); - - if (current_page_) { - const auto* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->WorkPos); - ImGui::SetNextWindowSize(viewport->WorkSize); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0F); - const auto sv1{gsl::finally([] { ImGui::PopStyleVar(1); })}; - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0F); - const auto sv2{gsl::finally([] { ImGui::PopStyleVar(1); })}; - - static constexpr ImGuiWindowFlags window_flags = - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | - ImGuiWindowFlags_NoNavFocus; - - if (ImGui::Begin("##currpage", nullptr, window_flags)) { - const auto cleanup_page{gsl::finally(ImGui::End)}; - current_page_->update(); - } +namespace style = ui::style; +namespace colors = ui::style::color; +namespace page = ui::pages; + +gui_t::gui_t(const std::shared_ptr& ctx) : context_(ctx) { + pages_ = new QStackedWidget(this); + setCentralWidget(pages_); + for (auto type : stdx::enum_range()) { + auto* p = create_page(type, context_, pages_); + pages_->addWidget(p); + page_lookup_[type] = p; } - - if (ImGui::IsKeyPressed(ImGuiKey_F11, false)) { sapp_toggle_fullscreen(); } - if (context_->backend->get_try_connection().exchange(false)) { context_->backend->start(); } -} - -void gui_t::on_event(const sapp_event* event) { // NOLINT - PROFILE_FUNCTION(); - if (event->type == SAPP_EVENTTYPE_QUIT_REQUESTED) { sapp_quit(); } - simgui_handle_event(event); -} - -void gui_t::on_cleanup() { - context_->should_exit = true; - if (current_page_) { current_page_->on_exit(); } - current_page_.reset(); - - simgui_shutdown(); - sg_shutdown(); - ImPlot::DestroyContext(); + change_page(page_type_t::HOME); + build_menu_bar(); } -void gui_t::change_page(page_type_t type) { - if (current_page_) { current_page_->on_exit(); } - +[[nodiscard]] page::page* +gui_t::create_page(page_type_t type, const std::shared_ptr& ctx, QWidget* parent) { switch (type) { - case page_type_t::HOME: current_page_ = std::make_unique(context_); break; - case page_type_t::RPM: current_page_ = std::make_unique(context_); break; - case page_type_t::SHOCK: current_page_ = std::make_unique(context_); break; - case page_type_t::VIEW: current_page_ = std::make_unique(context_); break; - case page_type_t::SERIAL: current_page_ = std::make_unique(context_); break; + case page_type_t::HOME: return new page::home_page(ctx, parent); + case page_type_t::PLOT: return new page::plot_page(ctx, parent); + case page_type_t::ANALYSIS: return new page::analysis_page(ctx, parent); + case page_type_t::SERIAL_MONITOR: return new page::serial_page(ctx, parent); + case page_type_t::SETTINGS: return new page::settings_page(ctx, parent); + default: return nullptr; } - - if (current_page_) { current_page_->on_enter(); } - context_->current_page_type = type; } -void gui_t::draw_main_menu_bar() { - PROFILE_FUNCTION(); - // DO NOT MOVE THIS BEGIN CALL IT WILL BREAK - if (ImGui::BeginMainMenuBar()) { - const auto cleanup_mm{gsl::finally(ImGui::EndMainMenuBar)}; - if (ImGui::BeginMenu("Menu")) { - const auto cleanup_m{gsl::finally(ImGui::EndMenu)}; - MAIN_MENU_BAR_ITEM({ - if (ImGui::MenuItem("Home")) { change_page(page_type_t::HOME); } - if (ImGui::MenuItem("RPM")) { change_page(page_type_t::RPM); } - if (ImGui::MenuItem("Shock")) { change_page(page_type_t::SHOCK); } - if (ImGui::MenuItem("View")) { change_page(page_type_t::VIEW); } - if (ImGui::MenuItem("Serial Monitor")) { change_page(page_type_t::SERIAL); } - if (ImGui::MenuItem("Toggle Dark Mode")) { - if (context_->style.dark_mode) { - context_->style.set_light_theme(); - } else { - context_->style.set_dark_theme(); - } - } - if (ImGui::MenuItem("Exit")) { sapp_request_quit(); } +void gui_t::change_page(page_type_t type) { pages_->setCurrentWidget(page_lookup_[type]); } + +void gui_t::build_menu_bar() { + + menuBar()->setStyleSheet(style::make_menubar_style()); + + auto* file = menuBar()->addMenu("File"); + file->setStyleSheet(style::make_menu_style()); + file->addAction("Settings"); + file->addAction("Export Log"); + file->addAction("Exit", this, [] { QApplication::quit(); }); + + auto* nav = menuBar()->addMenu("Navigation"); + nav->setStyleSheet(style::make_menu_style()); + nav->addAction("Home", this, [this] { change_page(page_type_t::HOME); }); + nav->addAction("Plot", this, [this] { change_page(page_type_t::PLOT); }); + nav->addAction("Serial", this, [this] { change_page(page_type_t::SERIAL_MONITOR); }); + nav->addAction("Analysis", this, [this] { change_page(page_type_t::ANALYSIS); }); + + menuBar()->addAction("Sync Time", this, [this] { + local_time lt; + const auto sync_time = lt.micros_since_midnight(); + context_->backend->send_cmd(std::format("CMD SYNC {}", sync_time)); + }); + + menuBar()->addAction("Restart Connection", this, [this] { context_->backend->restart(); }); + + menuBar()->addAction("Clear Data", this, [this] { context_->backend->get_data().clear(); }); + + auto* rightWidget = new QWidget(menuBar()); + auto* rightLayout = new QHBoxLayout(rightWidget); + rightLayout->setContentsMargins(0, 4, 8, 4); + rightLayout->setSpacing(8); + + connection_status_ = new QLabel("Disconnected", rightWidget); + connection_status_->setStyleSheet("font-size: 20px;"); + connection_status_->setMinimumWidth(120); + connection_status_->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + connect(context_->bridge.get(), + &ui::bridge::backend_bridge::connection_changed, + this, + [this](bool connected) { + is_connected_ = connected; + if (!connected) { is_receiving_ = false; } + connection_status_->setText(connected ? "Connected" : "Disconnected"); + update_status_dot(); }); - } - - ImGui::Separator(); - ImGui::TextUnformatted(page_type_str(context_->current_page_type)); - ImGui::Separator(); - // ImGui::TextUnformatted(page_type_str(m_Context->CurrentPageType)); - // ImGui::Separator(); - - const local_time lt; - const auto sync_time = lt.micros_since_midnight(); - - const auto command = fmt::format("CMD SYNC {}", sync_time); - if (ImGui::Button("Sync Time")) { context_->backend->send_cmd(command); } - - ImGui::Separator(); - if (ImGui::Button("Restart Connection")) { context_->backend->get_try_connection() = true; } - ImGui::Separator(); - if (ImGui::Button("Clear Data")) { context_->backend->get_data().clear(); } - ImGui::Separator(); - - // Connection indicator - const bool connected = context_->backend->is_connected(); - const bool receiving = context_->backend->is_receiving(); - - const f32 radius = 10.0F; - const ImVec2 pos = ImGui::GetCursorScreenPos(); - const ImVec2 center = ImVec2(pos.x + radius, pos.y + (ImGui::GetFrameHeight() * 0.5F)); - ImU32 color; - if (connected) { - color = receiving ? IM_COL32(0, 200, 0, 255) : IM_COL32(255, 255, 0, 255); - } else { - color = IM_COL32(200, 0, 0, 255); - } - - ImDrawList* draw = ImGui::GetWindowDrawList(); - draw->AddCircleFilled(center, radius, color); - - ImGui::Dummy({radius * 2.5F, radius * 2.0F}); - ImGui::SameLine(); - ImGui::TextUnformatted(connected ? "Connected" : "Not Connected"); - - ImGui::Separator(); - pages::utils::draw_input_box("##command", command_buf_); - context_->is_cmd_input_focused = ImGui::IsItemFocused(); - ImGui::Separator(); + connect(context_->bridge.get(), + &ui::bridge::backend_bridge::receiving_changed, + this, + [this](bool receiving) { + is_receiving_ = receiving; + update_status_dot(); + }); + connection_status_->setStyleSheet("font-size: 20px;"); - if (ImGui::Button("Send CMD")) { - context_->backend->send_cmd(command_buf_); - command_buf_ = {}; - } + status_dot_ = new QLabel(rightWidget); + status_dot_->setFixedSize(20, 20); + status_dot_->setStyleSheet(QString::fromStdString(fmt::format( + "background-color: {0}; border-radius: 10px;", colors::status_error.name().toStdString()))); + rightLayout->addWidget(connection_status_, 0, Qt::AlignVCenter); + rightLayout->addWidget(status_dot_, 0, Qt::AlignVCenter); - ImGui::Separator(); + menuBar()->setCornerWidget(rightWidget, Qt::TopRightCorner); +} - const std::string time_formatted = lt.to_string(); - ImGui::TextUnformatted(time_formatted.c_str()); - } +void gui_t::update_status_dot() { + QColor color = !is_connected_ ? colors::status_error.name() + : !is_receiving_ ? colors::status_warn.name() + : colors::status_ok.name(); + status_dot_->setStyleSheet( + QString("background-color: %1; border-radius: 10px;").arg(color.name().toStdString())); } } // namespace mbr diff --git a/src/app/pages/analysis.cpp b/src/app/pages/analysis.cpp new file mode 100644 index 0000000..99cc14e --- /dev/null +++ b/src/app/pages/analysis.cpp @@ -0,0 +1,39 @@ +#include "app/pages/analysis.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "app/assets/images/image_buttons.hpp" +#include "app/pages/page.hpp" +#include "core/log.hpp" +#include "core/time.hpp" + +using namespace std::chrono_literals; + +namespace mbr::ui::pages { + +void analysis_page::on_enter() { log_info(context_->log, "Entered AnalysisPage"); } +void analysis_page::on_exit() { log_info(context_->log, "Exited AnalysisPage"); } + +void analysis_page::build_page() { PROFILE_FUNCTION(); } + +} // namespace mbr::ui::pages diff --git a/src/app/pages/home.cpp b/src/app/pages/home.cpp index 9097a70..93a7d0d 100644 --- a/src/app/pages/home.cpp +++ b/src/app/pages/home.cpp @@ -1,224 +1,131 @@ #include "app/pages/home.hpp" +#include +#include #include #include +#include +#include +#include +#include +#include +#include + #include #include -#include -#include #include #include -#include "app/pages/utils.hpp" -#include "app/style.hpp" +#include "app/assets/style.hpp" #include "core/log.hpp" #include "core/time.hpp" -namespace mbr::pages { +namespace mbr::ui::pages { + +namespace style = ui::style; +namespace colors = ui::style::color; + +using namespace std::chrono_literals; void home_page::on_enter() { log_info(context_->log, "Entered HomePage"); } void home_page::on_exit() { log_info(context_->log, "Exited HomePage"); } -void home_page::update() { - PROFILE_FUNCTION(); - const f32 full_height = ImGui::GetContentRegionAvail().y; - const f32 top_height = full_height * 0.66F; - const f32 bottom_height = full_height - top_height; - - if (ImGui::BeginChild("##topsec", {0, top_height})) { - const auto cleanup_top{gsl::finally(ImGui::EndChild)}; - if (ImGui::BeginTable("##topsplt", 2, ImGuiTableFlags_BordersInnerV)) { - const auto cleanup_split{gsl::finally(ImGui::EndTable)}; - ImGui::TableNextColumn(); - draw_top_lhs(); - ImGui::TableNextColumn(); - draw_top_rhs(); - } - } - ImGui::Separator(); - - if (ImGui::BeginChild("##botsec", {0, bottom_height})) { - const auto cleanup_bot{gsl::finally(ImGui::EndChild)}; - if (ImGui::BeginTable("##botsplt", 2, ImGuiTableFlags_Resizable)) { - const auto cleanup_split{gsl::finally(ImGui::EndTable)}; - ImGui::TableSetupColumn("##errors", ImGuiTableColumnFlags_WidthStretch, 0.33F); - ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthStretch, 0.66F); - - ImGui::TableNextColumn(); - draw_bottom_lhs(); - ImGui::TableNextColumn(); - draw_bottom_rhs(); - } - } -} +void home_page::build_page() { + auto* layout = new QHBoxLayout(this); + // layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(1); -void home_page::draw_top_lhs() { - BOLD_HEADER(ImGui::Text("SD Card Control")); - ImGui::Separator(); - - if (ImGui::BeginChild("##sd_control", {0, 0}, false, ImGuiWindowFlags_HorizontalScrollbar)) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - local_time t; - HEADER(utils::draw_input_box( - "##sd_name", set_name_, fmt::format("Name ({})", t.to_string(false)).c_str())); - ImGui::SameLine(); - if (ImGui::Button("Create File")) { - if (set_name_.empty()) { - sd_name_ = t.to_string(false); - } else { - sd_name_ = set_name_; - } - for (char& c : sd_name_) { - if (c == ':' || c == ' ') { c = '-'; } - } - const auto command = fmt::format("SD_START /{}.txt", sd_name_); - context_->backend->send_cmd(command); - set_name_.clear(); - } - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Button, - context_->backend->is_writing() ? ImVec4(0, 0.7F, 0, 1) - : ImVec4(0.7F, 0, 0, 1)); - if (ImGui::Button(context_->backend->is_writing() ? "Write ON" : "Write OFF")) { - if (context_->backend->is_writing()) { - context_->backend->send_cmd("SD_WRITE 0"); - } else { - context_->backend->send_cmd("SD_WRITE 1"); - } - // m_SDWrite = !m_SDWrite; - } - ImGui::PopStyleColor(); - ImGui::SameLine(); - if (!sd_name_.empty()) { - if (ImGui::Button(context_->backend->is_open() ? "Close SD" : "Open SD")) { - if (context_->backend->is_open()) { - context_->backend->send_cmd("SD_CLOSE"); - } else { - const auto command = fmt::format("SD_START /{}.txt", sd_name_); - context_->backend->send_cmd(command); - } - } - } - ImGui::SameLine(); - ImGui::TextDisabled("%s", !sd_name_.empty() ? sd_name_.c_str() : "No file created"); - /* - ImGui::BulletText("Connect to Wifi on laptop"); - ImGui::BulletText( - "Make sure you see 'Connected' and a green light, that means you are receiving data"); - ImGui::BulletText("If not, restart everything: app, esp32, wifi"); - ImGui::BulletText("Once connection is established, press 'Sync Time', this will allow the " - "data to be displayed"); - ImGui::BulletText("No data will be collected unless time is synced"); - ImGui::BulletText("You must sync time everytime the esp32 is restarted"); - ImGui::BulletText("Under the 'Menu' dropdown there are various pages"); - ImGui::BulletText("Go to the page you want data from"); - ImGui::BulletText("Press 'Start Logging'"); - ImGui::BulletText("Once you've gathered the data, you can download it by just pressing " - "download, or giving it a title"); - ImGui::BulletText("No title just gives the time stamp"); - ImGui::BulletText( - "In the 'View' page, you can upload data and video to watch the data be plotted live"); - ImGui::BulletText("First upload both the video and the data file"); - ImGui::BulletText( - "Then type in the timestamp (down to the second) of when the video was created"); - ImGui::BulletText("Press 'Sync Data/Video'"); - ImGui::BulletText("Turn on dynamic plotting and watch the data be plotted"); - ImGui::BulletText( - "You can also hide data you don't want to see by pressing on them in the legend"); - ImGui::BulletText("Send CMD currently works, but there aren't any commands supported"); - */ - } -} + auto* lhs = build_lhs(); + auto* rhs = build_rhs(); -void home_page::draw_top_rhs() { - BOLD_HEADER(ImGui::Text("Control Panel")); - ImGui::Separator(); - - BOLD_DEFAULT(ImGui::SeparatorText("Metrics")); - ImGui::BulletText("Backend Status: %s", - context_->backend->is_connected() ? "Online" : "Offline"); - ImGui::BulletText("Last IP Update: %s", previous_ip_.to_string().c_str()); - ImGui::BulletText("Application FPS: %.2f", ImGui::GetIO().Framerate); - - BOLD_DEFAULT(ImGui::SeparatorText("UI Settings")); - bool dark_mode = context_->style.dark_mode; - if (ImGui::Checkbox("Dark Mode", &dark_mode)) { - if (dark_mode) { - context_->style.set_dark_theme(); - } else { - context_->style.set_light_theme(); - } - } + layout->addWidget(lhs, 1); + layout->addWidget(rhs, 1); - bool fullscreen = sapp_is_fullscreen(); - if (ImGui::Checkbox("Fullscreen", &fullscreen)) { sapp_toggle_fullscreen(); } + connect_signals(); } -void home_page::draw_bottom_lhs() { - BOLD_HEADER(ImGui::Text("Error Log")); - ImGui::Separator(); - - if (ImGui::BeginChild("##errscroll", {0, 0}, false, ImGuiWindowFlags_HorizontalScrollbar)) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - const std::string all_errors = context_->logger.get_streamed_logs(); - ImGui::TextUnformatted(all_errors.c_str()); - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { ImGui::SetScrollHereY(1.0F); } +QWidget* home_page::build_rhs() { + auto* container = new QWidget; + auto* v_layout = new QVBoxLayout(container); + auto* jason_grid = new QGridLayout(); + + auto* title = new QLabel("Data Status"); + title->setStyleSheet(QString::fromStdString( + fmt::format("color: {}; font: 12pt", colors::text_main.name().toStdString()))); + + auto* line = new QFrame; + line->setFrameShape(QFrame::HLine); + line->setFrameShadow(QFrame::Sunken); + + usize row = 0; + for (auto& d : context_->backend->get_data().data_values) { + auto* label = new QLabel(QString::fromStdString(d.name)); + label->setStyleSheet(QString::fromStdString( + fmt::format("color: {}; font: 12pt", colors::text_main.name().toStdString()))); + jason_grid->addWidget(label, row, 0); + + auto* status = new QLabel(); + status->setFixedSize(20, 20); + status->setStyleSheet( + QString::fromStdString(fmt::format("background-color: {}; border-radius: 10px;", + colors::status_error.name().toStdString()))); + jason_grid->addWidget(status, row, 1); + labels_.try_emplace(QString::fromStdString(d.key), status); + row++; } -} + jason_grid->setColumnStretch(2, 1); -void home_page::draw_bottom_rhs() { - BOLD_HEADER(ImGui::Text("Command Center")); - ImGui::Separator(); + v_layout->addWidget(title); + v_layout->addWidget(line); + v_layout->addLayout(jason_grid); + v_layout->addStretch(); - if (ImGui::BeginChild("##commandcenter", {0, 0}, false, ImGuiWindowFlags_HorizontalScrollbar)) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - draw_ip_controls(); - draw_credential_controls(); - } + return container; } -void home_page::draw_ip_controls() { - if (ImGui::Button("Update IP") && !ip_buf_.any_empty()) { - context_->backend->set_ip(ip_buf_); - previous_ip_ = std::exchange(ip_buf_, {}); - } - - ImGui::SameLine(); - utils::draw_input_box("##ip_FIRST", ip_buf_.first, previous_ip_.first.c_str(), 75.0F); - ImGui::SameLine(); - ImGui::TextUnformatted("."); +QWidget* home_page::build_lhs() { + auto* container = new QWidget; + auto* v_layout = new QVBoxLayout(container); + // auto* jason_grid = new QGridLayout(); - ImGui::SameLine(); - utils::draw_input_box("##ip_SECOND", ip_buf_.second, previous_ip_.second.c_str(), 75.0F); - ImGui::SameLine(); - ImGui::TextUnformatted("."); + auto* title = new QLabel("hello world"); - ImGui::SameLine(); - utils::draw_input_box("##ip_THIRD", ip_buf_.third, previous_ip_.third.c_str(), 75.0F); - ImGui::SameLine(); - ImGui::TextUnformatted("."); - - ImGui::SameLine(); - utils::draw_input_box("##ip_FOURTH", ip_buf_.fourth, previous_ip_.fourth.c_str(), 75.0F); - ImGui::SameLine(); - ImGui::TextUnformatted(":"); - - ImGui::SameLine(); - utils::draw_input_box("##ip_PORT", ip_buf_.port, previous_ip_.port.c_str(), 50.0F); + v_layout->addWidget(title); + return container; } -void home_page::draw_credential_controls() { - // TODO(blake): Do something with user/password - if (ImGui::Button("Upload Credentials") && (!username_buf_.empty() && !password_buf_.empty())) { - username_buf_ = {}; - password_buf_ = {}; - } - - ImGui::SameLine(); - utils::draw_input_box("##username", username_buf_, "Username", 150.0F); - ImGui::SameLine(); - utils::draw_input_box("##password", password_buf_, "Password", 150.0F); +void home_page::connect_signals() { + connect(context_->bridge.get(), + &ui::bridge::backend_bridge::fields_received, + this, + [this](const std::vector& keys) { + const auto now = std::chrono::steady_clock::now(); + for (const auto& key : keys) { + auto qkey = QString::fromStdString(key); + last_updated_[qkey] = now; + if (auto it = labels_.find(qkey); it != labels_.end()) { + it->second->setStyleSheet(QString::fromStdString( + fmt::format("background-color: {}; border-radius: 10px;", + QColor(colors::status_ok).name().toStdString()))); + } + } + }); + + auto* timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, [this]() { + const auto now = std::chrono::steady_clock::now(); + for (const auto& [key, time] : last_updated_) { + if (now - time > 250ms) { + if (auto it = labels_.find(key); it != labels_.end()) { + it->second->setStyleSheet(QString::fromStdString( + fmt::format("background-color: {}; border-radius: 10px;", + QColor(colors::status_error).name().toStdString()))); + } + } + } + }); + timer->start(100); } -} // namespace mbr::pages +} // namespace mbr::ui::pages diff --git a/src/app/pages/plot.cpp b/src/app/pages/plot.cpp new file mode 100644 index 0000000..e654b12 --- /dev/null +++ b/src/app/pages/plot.cpp @@ -0,0 +1,390 @@ +#include "app/pages/plot.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "app/assets/style.hpp" +#include "app/backend_bridge.hpp" +#include "core/log.hpp" +#include "esp32/backend.hpp" +#include "esp32/data.hpp" + +#include + +namespace mbr::ui::pages { + +namespace style = ui::style; +namespace colors = ui::style::color; + +void plot_page::on_enter() { log_info(context_->log, "Entered PlotPage"); } +void plot_page::on_exit() { log_info(context_->log, "Exited PlotPage"); } + +void plot_page::build_page() { + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(8); + + auto* lhs = build_lhs(); + auto* rhs = build_rhs(); + + plot_timer(); + + layout->addWidget(lhs, 1); + layout->addWidget(rhs, 3); +} + +QWidget* plot_page::build_lhs() { + style::button_style_options button_style; + button_style.font_size = 10; + + auto* container = new QWidget(this); + auto* v_layout = new QVBoxLayout(container); + auto* h_layout = new QHBoxLayout(); + + auto* log_button = new QToolButton(); + log_button->setText(context_->backend->is_logging() ? "Stop Logging" : "Start Logging"); + connect(log_button, &QToolButton::clicked, this, [this, log_button] { + context_->backend->set_logging(!context_->backend->is_logging()); + log_button->setText(context_->backend->is_logging() ? "Stop Logging" : "Start Logging"); + }); + log_button->setMinimumHeight(32); + log_button->setStyleSheet(style::make_button_style(button_style)); + h_layout->addWidget(log_button); + + auto* download_name_input = new QLineEdit(); + download_name_input->setPlaceholderText("current_time.txt"); + download_name_input->setMaximumWidth(160); + download_name_input->setMinimumHeight(32); + h_layout->addWidget(download_name_input); + + auto* download_button = new QToolButton(); + download_button->setText("Download Data"); + connect(download_button, &QToolButton::clicked, this, [this, download_name_input] { + std::string name = download_name_input->text().toStdString(); + + local_time lt; + std::string filepath; + if (!name.empty()) { + filepath = fmt::format("{}_{}.txt", lt.to_string(false), name); + } else { + filepath = fmt::format("{}.txt", lt.to_string(false)); + } + + auto raw_lines = context_->backend->get_data().get_raw_lines(); + if (raw_lines.empty()) { + log_warn(context_->log, "Cannot download data as the data buffer is empty!"); + return; + } + + std::ofstream out{filepath}; + if (!out.is_open()) { + log_error(context_->log, + fmt::format("Failed to open output file: {}", std::strerror(errno))); + return; + } + + for (const auto& line : raw_lines) { fmt::println(out, "{}", line); } + + download_name_input->clear(); + }); + + download_button->setMinimumHeight(32); + download_button->setStyleSheet(style::make_button_style(button_style)); + h_layout->addWidget(download_button); + + text_log_ = new QPlainTextEdit(container); + text_log_->setReadOnly(true); + v_layout->addLayout(h_layout); + v_layout->addWidget(text_log_); + return container; +} + +QWidget* plot_page::build_rhs() { + + auto* container = new QWidget(this); + auto* layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + QToolButton* channel_selector = + create_tree_dropdown(context_->backend->get_data().data_values, container); + + layout->addWidget(channel_selector); + + plot_ = new QCustomPlot(container); + + plot_->setBackground(QBrush(colors::bg_dark)); + plot_->axisRect()->setBackground(QBrush(colors::bg_dark)); + + auto configure_axis = [&](QCPAxis* axis, const QString& label) { + axis->setLabel(label); + axis->setBasePen(QPen(QColor(colors::text_muted), 1.0)); + axis->setTickPen(QPen(QColor(colors::text_muted), 1.0)); + axis->setSubTickPen(QPen(QColor(colors::text_muted), 0.5)); + axis->setTickLabelColor(colors::text_muted); + axis->setLabelColor(colors::text_muted); + + axis->grid()->setPen(QPen(QColor(colors::border), 1.0, Qt::SolidLine)); + axis->grid()->setSubGridPen(QPen(QColor(colors::border), 0.5, Qt::DotLine)); + axis->grid()->setSubGridVisible(true); + }; + + configure_axis(plot_->xAxis, "Time (s)"); + configure_axis(plot_->yAxis, "Value"); + + plot_->axisRect()->setAutoMargins(QCP::msNone); + plot_->axisRect()->setMargins(QMargins(50, 20, 20, 40)); + plot_->setNoAntialiasingOnDrag(true); + plot_->rescaleAxes(); + plot_->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom); + plot_->replot(); + plot_->legend->setVisible(true); + plot_->legend->setBrush(QBrush(colors::bg_dark)); + plot_->legend->setTextColor(colors::text_muted); + + layout->addWidget(plot_, 1); + + QTreeWidget* tree = channel_selector->findChild(); + + if (tree) { + connect(tree, &QTreeWidget::itemSelectionChanged, this, [this, tree, channel_selector]() { + plot_->clearGraphs(); + plotted_counts_.clear(); + usize count = tree->selectedItems().count(); + channel_selector->setText(count == 0 ? "Select Data" + : QString("%1 selected").arg(count)); + active_graphs_.clear(); + for (QTreeWidgetItem* item : tree->selectedItems()) { + QString key = item->data(0, Qt::UserRole).toString(); + if (!key.isEmpty()) { plot_signal(key.toStdString()); } + + if (item->childCount() > 0) { + for (i32 i = 0; i < item->childCount(); ++i) { + QTreeWidgetItem* child = item->child(i); + std::string child_key = + child->data(0, Qt::UserRole).toString().toStdString(); + if (!child_key.empty()) { plot_signal(child_key); } + } + } + } + }); + } + + return container; +} + +void plot_page::plot_timer() { + + connect(context_->bridge.get(), + &ui::bridge::backend_bridge::data_updated, + this, + [this](const auto&) { pending_update_ = true; }); + + redraw_timer_ = new QTimer(this); + connect(redraw_timer_, &QTimer::timeout, this, [this]() { + if (pending_update_) { + on_data_updated(); + pending_update_ = false; + } + }); + redraw_timer_->start(17); +} + +QToolButton* plot_page::create_tree_dropdown(const std::vector& data, + QWidget* parent) { + auto* select = new QToolButton(parent); + + select->setText("Select Data"); + select->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + select->setPopupMode(QToolButton::InstantPopup); + select->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + select->setStyleSheet(style::make_button_style()); + + auto* tree = new QTreeWidget(select); + tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + tree->setHeaderHidden(true); + tree->setColumnCount(1); + tree->setStyleSheet(style::make_tree_style()); + + QString current_group; + for (auto& it : data) { + bool found = false; + QTreeWidgetItem* target_group = nullptr; + current_group = QString::fromStdString(it.group); + for (i32 i = 0; i < tree->topLevelItemCount(); ++i) { + if (current_group == tree->topLevelItem(i)->text(0)) { + found = true; + target_group = tree->topLevelItem(i); + break; + } + } + if (!found && it.plot) { + target_group = new QTreeWidgetItem; + QString group_name = QString::fromStdString(it.group); + target_group->setText(0, group_name); + tree->addTopLevelItem(target_group); + } + if (!target_group) { continue; } + auto* child_item = new QTreeWidgetItem(target_group); + child_item->setText(0, QString::fromStdString(it.name)); + child_item->setData(0, Qt::UserRole, QString::fromStdString(it.key)); + } + tree->expandAll(); + + auto* menu = new QMenu(select); + auto* widget_action = new QWidgetAction(menu); + + tree->setMinimumSize(300, 0); + widget_action->setDefaultWidget(tree); + menu->addAction(widget_action); + + select->setMenu(menu); + + return select; +} + +void plot_page::plot_signal(const std::string& key) { + auto [it, inserted] = active_graphs_.try_emplace(key); + if (!inserted) { return; } + auto series = context_->backend->get_data().get_series(key); + if (!series) { return; } + auto time = context_->backend->get_data().get_time(); + + if (!has_offset_ && !time.empty()) { + time_offset_ = time.front(); + has_offset_ = true; + } + + QVector x_times; + x_times.reserve(time.size()); + for (auto t : time) { x_times.append(t - time_offset_); } + QVector y_values(series->begin(), series->end()); + + QCPGraph* graph = plot_->addGraph(); + graph->setAdaptiveSampling(true); + + static const std::array palette = colors::graph_palette; + QColor color = palette[active_graphs_.size() % palette.size()]; + + graph->setPen(QPen(color, 2.0)); + graph->setData(x_times, y_values); + for (auto& d : context_->backend->get_data().data_values) { + if (d.key == key) { + graph->setName(QString::fromStdString(d.name)); + break; + } + } + graph->addToLegend(); + + it->second = graph; + plotted_counts_[key] = series->size(); + + plot_->rescaleAxes(); + plot_->replot(); +} + +void plot_page::on_data_updated() { + update_plot(); + update_stream(); +} + +void plot_page::update_plot() { + auto sync_opt = context_->backend->get_data().get_sync_lt(); + plot_->xAxis->setLabel( + sync_opt + ? QString("Data Synced at: %1").arg(QString::fromStdString(sync_opt->to_string(false))) + : "No Time Synced"); + + if (active_graphs_.empty()) { return; } + + auto time = context_->backend->get_data().get_time(); + + for (auto& [key, graph] : active_graphs_) { + auto series = context_->backend->get_data().get_series(key); + if (!series) { return; } + + usize& already_plotted = plotted_counts_[key]; + usize total = series->size(); + + if (total < already_plotted) { + graph->data()->clear(); + already_plotted = 0; + } + + if (total == already_plotted) { continue; } + + QVector new_x; + new_x.reserve(total - already_plotted); + for (auto it = time.begin() + already_plotted; it != time.end(); ++it) { + new_x.append(*it - time_offset_); + } + QVector new_y(series->begin() + already_plotted, series->end()); + graph->addData(new_x, new_y); + + already_plotted = total; + } + + plot_->xAxis->rescale(); + plot_->replot(); +} + +void plot_page::update_stream() { + + if (!context_->backend->is_logging()) { return; } + + auto series = context_->backend->get_data().get_raw_lines(); + + usize displayed = 0; + if (text_log_->document()->isEmpty()) { + displayed = 0; + } else { + displayed = static_cast(text_log_->document()->blockCount()); + } + + usize total = series.size(); + + if (total < displayed) { + text_log_->clear(); + displayed = 0; + } + + if (total <= displayed) { return; } + + for (usize i = displayed; i < total; ++i) { + text_log_->appendPlainText(QString::fromStdString(series[i])); + } +} + +} // namespace mbr::ui::pages diff --git a/src/app/pages/rpm.cpp b/src/app/pages/rpm.cpp deleted file mode 100644 index 47dc5cd..0000000 --- a/src/app/pages/rpm.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "app/pages/rpm.hpp" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "app/pages/utils.hpp" -#include "app/style.hpp" -#include "core/log.hpp" -#include "core/time.hpp" - -namespace mbr::pages { - -void rpm_page::on_enter() { log_info(context_->log, "Entered RPMPage"); } -void rpm_page::on_exit() { log_info(context_->log, "Exited RPMPage"); } - -void rpm_page::update() { - PROFILE_FUNCTION(); - if (ImGui::BeginTable( - "##viewsplit", 2, ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_Resizable)) { - const auto cleanup_split{gsl::finally(ImGui::EndTable)}; - const std::shared_lock lock{context_->backend->get_data_latch()}; - const auto& data = context_->backend->get_data(); - - ImGui::TableNextColumn(); - DrawLHS(data.get_raw_lines()); - ImGui::TableNextColumn(); - DrawRHS(data.get_time(), data.get_series("W"), data.get_series("E")); - } -} - -void rpm_page::DrawLHS(const std::vector& raw_lines) { - if (ImGui::BeginChild("##datalog")) { - const auto cleanup_data{gsl::finally(ImGui::EndChild)}; - BOLD_HEADER(ImGui::Text("Data Log")); - - ImGui::Separator(); - text_drawer_.start_logging_button(); - ImGui::SameLine(); - text_drawer_.data_download_button(raw_lines, download_fd_text_); - ImGui::SameLine(); - - HEADER(utils::draw_input_box("##extra_rpm", download_fd_text_, "File descriptor")); - ImGui::Separator(); - utils::draw_data_log(raw_lines); - } -} - -void rpm_page::DrawRHS(gsl::span time, - gsl::span wheel, - gsl::span engine) { - if (ImGui::BeginChild("##graph")) { - const auto cleanup_graph{gsl::finally(ImGui::EndChild)}; - const auto sync_lt = context_->backend->get_data().get_sync_lt(); - const auto plot_title = sync_lt - ? fmt::format("RPM Data from {}", sync_lt.value().to_string()) - : "No Synced Time"; - - if (ImPlot::BeginPlot(plot_title.c_str(), {-1, -1})) { - const auto cleanup_plot{gsl::finally(ImPlot::EndPlot)}; - utils::plot_if_non_empty("Wheel Speed", time, wheel); - utils::plot_if_non_empty("Engine Speed", time, engine); - } - } -} - -} // namespace mbr::pages diff --git a/src/app/pages/serialmon.cpp b/src/app/pages/serialmon.cpp index 82d4297..b6cbefd 100644 --- a/src/app/pages/serialmon.cpp +++ b/src/app/pages/serialmon.cpp @@ -3,143 +3,18 @@ #include #include -#include #include #include #include -#include "app/pages/utils.hpp" -#include "app/style.hpp" #include "core/log.hpp" #include "esp32/serial.hpp" -namespace mbr::pages { +namespace mbr::ui::pages { void serial_page::on_enter() { log_info(context_->log, "Entered SerialPage"); } void serial_page::on_exit() { log_info(context_->log, "Exited SerialPage"); } -void serial_page::update() { - PROFILE_FUNCTION(); - const f32 full_height = ImGui::GetContentRegionAvail().y; - const f32 top_height = full_height * 0.66F; - const f32 bottom_height = full_height - top_height; +void serial_page::build_page() { PROFILE_FUNCTION(); } - if (ImGui::BeginChild("##topsec", {0, top_height})) { - const auto cleanup_top{gsl::finally(ImGui::EndChild)}; - if (ImGui::BeginTable("##topsplt", 2, ImGuiTableFlags_BordersInnerV)) { - const auto cleanup_split{gsl::finally(ImGui::EndTable)}; - ImGui::TableNextColumn(); - draw_top_lhs(); - ImGui::TableNextColumn(); - draw_top_rhs(); - } - } - ImGui::Separator(); - - if (ImGui::BeginChild("##botsec", {0, bottom_height})) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - draw_bottom(); - } -} - -void serial_page::draw_top_lhs() { - if (ImGui::BeginChild("##datalog")) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - BOLD_HEADER(ImGui::Text("Serial Monitor Settings")); - - ImGui::Separator(); - if (text_drawer_.start_serial_button()) { - if (!context_->backend->get_serial_manager().is_running()) { - context_->backend->get_serial_manager().start(); - } else { - context_->backend->get_serial_manager().stop(); - } - } - ImGui::SameLine(); - text_drawer_.send_data_button(); - if (context_->backend->get_serial_manager().should_send_data()) { - context_->backend->get_serial_manager().send_data( - context_->backend->get_data().get_current_line()); - } - ImGui::Separator(); - - auto all_ports = context_->backend->get_serial_manager().get_all_ports(); - ImGui::SetNextItemWidth(250.0F); - if (ImGui::BeginCombo("##port_dropdown", "Select Ports to Send Data")) { - const auto cleanup{gsl::finally(ImGui::EndCombo)}; - for (const auto& port : all_ports) { - const bool is_selected = - context_->backend->get_serial_manager().is_port_selected(port); - if (ImGui::Selectable( - port.c_str(), is_selected, ImGuiSelectableFlags_NoAutoClosePopups)) { - if (!is_selected) { - context_->backend->get_serial_manager().add_port(port); - log_info(context_->log, "Added port {}", port); - } else { - context_->backend->get_serial_manager().remove_port(port); - log_info(context_->log, "Removed port {}", port); - } - } - } - } - - ImGui::SameLine(); - ImGui::TextUnformatted("Chosen Ports: "); - ImGui::SameLine(); - for (const auto& port : context_->backend->get_serial_manager().get_chosen_ports()) { - ImGui::TextUnformatted(port.c_str()); - ImGui::SameLine(); - } - ImGui::Separator(); - ImGui::TextUnformatted("Baud Rate"); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(100.0F); - if (ImGui::BeginCombo( - "##baud_dropdown", - baud_rate_string(context_->backend->get_serial_manager().get_baud_rate()))) { - const auto cleanup_combo{gsl::finally(ImGui::EndCombo)}; - for (const auto rate : stdx::enum_range()) { - const bool is_selected = - (context_->backend->get_serial_manager().get_baud_rate() == rate); - if (ImGui::Selectable(baud_rate_string(rate), is_selected)) { - context_->backend->get_serial_manager().change_baud_rate(rate); - } - if (is_selected) { ImGui::SetItemDefaultFocus(); } - } - } - } -} - -void serial_page::draw_top_rhs() { - BOLD_HEADER(ImGui::Text("LOG Info")); - - ImGui::Separator(); - - if (ImGui::BeginChild("##errscroll", {0, 0}, false, ImGuiWindowFlags_HorizontalScrollbar)) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - const std::string all_errors = context_->logger.get_streamed_logs(); - ImGui::TextUnformatted(all_errors.c_str()); - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { ImGui::SetScrollHereY(1.0F); } - } -} - -void serial_page::draw_bottom() { - if (utils::draw_input_box("##command", - serial_buffer_, - "Send Serial Data Here", - 1900.0F, - ImGuiInputTextFlags_EnterReturnsTrue)) { - context_->backend->get_serial_manager().send_data(serial_buffer_ + "\n"); - serial_buffer_ = {}; - ImGui::SetKeyboardFocusHere(-1); - } - if (ImGui::BeginChild("##datalog")) { - const auto cleanup{gsl::finally(ImGui::EndChild)}; - ImGui::Separator(); - const std::scoped_lock lock{context_->backend->get_serial_manager().get_mutex()}; - utils::draw_data_log(context_->backend->get_serial_manager().get_data_stream()); - } -} - -} // namespace mbr::pages +} // namespace mbr::ui::pages diff --git a/src/app/pages/settings.cpp b/src/app/pages/settings.cpp new file mode 100644 index 0000000..5d1e84a --- /dev/null +++ b/src/app/pages/settings.cpp @@ -0,0 +1,22 @@ +#include "app/pages/settings.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/log.hpp" + +namespace mbr::ui::pages { + +void settings_page::on_enter() { log_info(context_->log, "Entered SettingsPage"); } +void settings_page::on_exit() { log_info(context_->log, "Exited SettingsPage"); } + +void settings_page::build_page() {} + +} // namespace mbr::ui::pages diff --git a/src/app/pages/shock.cpp b/src/app/pages/shock.cpp deleted file mode 100644 index 99600d1..0000000 --- a/src/app/pages/shock.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include "app/pages/shock.hpp" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "app/pages/utils.hpp" -#include "app/style.hpp" -#include "core/log.hpp" - -namespace mbr::pages { - -void shock_page::on_enter() { log_info(context_->log, "Entered ShockPage"); } -void shock_page::on_exit() { log_info(context_->log, "Exited ShockPage"); } - -void shock_page::update() { - PROFILE_FUNCTION(); - if (ImGui::BeginTable( - "##viewsplit", 2, ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_Resizable)) { - const auto cleanup_split{gsl::finally(ImGui::EndTable)}; - const std::shared_lock lock{context_->backend->get_data_latch()}; - const auto& data = context_->backend->get_data(); - - ImGui::TableNextColumn(); - draw_lhs(data.get_raw_lines()); - ImGui::TableNextColumn(); - draw_rhs(data.get_time(), - data.get_series("FR"), - data.get_series("FL"), - data.get_series("RR"), - data.get_series("RL")); - } -} - -void shock_page::draw_lhs(const std::vector& raw_lines) { - if (ImGui::BeginChild("##datalog")) { - const auto cleanup_data{gsl::finally(ImGui::EndChild)}; - BOLD_HEADER(ImGui::Text("Data Log")); - - ImGui::Separator(); - text_utils_.start_logging_button(); - ImGui::SameLine(); - text_utils_.data_download_button(raw_lines, download_fd_text_); - ImGui::SameLine(); - - HEADER(utils::draw_input_box("##extra_shock", download_fd_text_, "File descriptor")); - ImGui::Separator(); - utils::draw_data_log(raw_lines); - } -} - -void shock_page::draw_rhs(gsl::span time, - gsl::span fr, - gsl::span fl, - gsl::span br, - gsl::span bl) { - if (ImGui::BeginChild("##graph")) { - const auto cleanup_graph{gsl::finally(ImGui::EndChild)}; - const auto sync_lt = context_->backend->get_data().get_sync_lt(); - const auto plot_title = - sync_lt ? fmt::format("Shock Travel Data from {}", sync_lt.value().to_string()) - : "No Synced Time"; - - if (ImPlot::BeginPlot(plot_title.c_str(), {-1, -1})) { - const auto cleanup_plot{gsl::finally(ImPlot::EndPlot)}; - utils::plot_if_non_empty("Front Right Shock Travel", time, fr); - utils::plot_if_non_empty("Front Left Shock Travel", time, fl); - utils::plot_if_non_empty("Rear Right Shock Travel", time, br); - utils::plot_if_non_empty("Rear Left Shock Travel", time, bl); - } - } -} - -} // namespace mbr::pages diff --git a/src/app/pages/utils.cpp b/src/app/pages/utils.cpp deleted file mode 100644 index 47c182e..0000000 --- a/src/app/pages/utils.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include "app/pages/utils.hpp" - -#include -#include -#include -#include -#include - -// clang-format off -#include -#include -#include // NOLINT -// clang-format on - -#include -#include -#include -#include - -#include "app/context.hpp" -#include "app/style.hpp" -#include "core/log.hpp" -#include "core/time.hpp" - -namespace mbr::pages::utils { - -void text_drawers::start_logging_button() { - HEADER({ - if (ImGui::Button(context_->backend->is_logging() ? "Stop Logging" : "Start Logging")) { - context_->backend->set_logging(!context_->backend->is_logging()); - } - }); -} - -bool text_drawers::start_serial_button() { - bool clicked = false; - HEADER({ - if (ImGui::Button(context_->backend->get_serial_manager().is_serial_write() - ? "Stop Serial" - : "Start Serial")) { - context_->backend->get_serial_manager().set_serial_write( - !context_->backend->get_serial_manager().is_serial_write()); - clicked = true; - } - }); - return clicked; -} - -void text_drawers::send_data_button() { - HEADER({ - if (ImGui::Button(context_->backend->get_serial_manager().should_send_data() - ? "Stop Data Send" - : "Send Data")) { - context_->backend->get_serial_manager().set_send_data( - !context_->backend->get_serial_manager().should_send_data()); - } - }); -} - -void text_drawers::data_download_button(const std::vector& raw_lines, - std::string& buf) { - HEADER({ - if (ImGui::Button("Download Data")) { - const date_time dt; - std::string filepath; - if (!buf.empty()) { - filepath = fmt::format("{}_{}.txt", dt.to_string(date_time::fmt_t::TEXT_FILE), buf); - } else { - filepath = fmt::format("{}.txt", dt.to_string(date_time::fmt_t::TEXT_FILE)); - } - - if (raw_lines.empty()) { - log_warn(context_->log, "Cannot download data as the data buffer is empty!"); - } else { - std::ofstream out{filepath}; - if (!out.is_open()) { - log_error( - context_->log, "Failed to open output file: {}", std::strerror(errno)); - } else { - for (const auto& line : raw_lines) { out << line << "\n"; } - buf = {}; - } - } - } - }); -} - -bool draw_input_box(const char* label, - std::string& buf, - stdx::option hint, - f32 width_scale, - ImGuiInputTextFlags flags) { - ImGui::SetNextItemWidth(width_scale); - if (hint) { return ImGui::InputTextWithHint(label, *hint, &buf, flags); } - return ImGui::InputText(label, &buf, flags); -} - -void draw_data_log(gsl::span raw_lines) { - for (const auto& msg : raw_lines) { ImGui::TextUnformatted(msg.c_str()); } - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { ImGui::SetScrollHereY(1.0F); } -} - -} // namespace mbr::pages::utils diff --git a/src/app/pages/view.cpp b/src/app/pages/view.cpp deleted file mode 100644 index 3fcfeb5..0000000 --- a/src/app/pages/view.cpp +++ /dev/null @@ -1,739 +0,0 @@ -#include "app/pages/view.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "app/assets/images/image_buttons.hpp" -#include "app/pages/page.hpp" -#include "app/pages/utils.hpp" -#include "app/style.hpp" -#include "core/log.hpp" -#include "core/time.hpp" - -using namespace std::chrono_literals; - -namespace mbr::pages { - -static constexpr usize MAX_QUEUE_SIZE = 10; - -const char* view_page::data_type_string(data_view_t type) { - switch (type) { - case data_view_t::ALL: return "All Data Shown"; - case data_view_t::RPMDATA: return "RPM Data Shown"; - case data_view_t::SHOCKDATA: return "Shock Data Shown"; - default: return "Unknown"; - } -} - -view_page::view_page(const std::shared_ptr& ctx) - : page{ctx}, is_alive_{std::make_shared(true)}, play_button_{assets::PLAY_BUTTON_PNG}, - pause_button_{assets::PAUSE_BUTTON_PNG}, step_button_{assets::STEP_BUTTON_PNG} {} - -view_page::~view_page() { - *is_alive_ = false; - cleanup(); - log_info(context_->log, "Destroyed ViewPage"); -} - -void view_page::on_enter() { log_info(context_->log, "Entered ViewPage"); } - -void view_page::on_exit() { - cleanup(); - log_info(context_->log, "Exited ViewPage"); -} - -void view_page::update() { - PROFILE_FUNCTION(); - if (ImGui::BeginTable( - "##viewsplt", 2, ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_Resizable)) { - const auto cleanup_table{gsl::finally(ImGui::EndTable)}; - ImGui::TableNextColumn(); - draw_lhs(); - ImGui::TableNextColumn(); - draw_rhs(); - } -} - -void view_page::cleanup() { - stop_decoding_thread(); - try_cleanup_sokol_resources(); -} - -void view_page::draw_lhs() { - if (ImGui::BeginChild("##video")) { - const auto cleanup_video{gsl::finally(ImGui::EndChild)}; - draw_open_video(); - if (video_loaded_ && !video_path_.empty()) { - ImGui::SameLine(); - const std::filesystem::path path{video_path_}; - const auto filename_str = path.filename().string(); - BOLD_DEFAULT(ImGui::TextUnformatted("Current File: ")); - ImGui::SameLine(); - ImGui::TextUnformatted(filename_str.c_str()); - } - ImGui::SameLine(); - - // Clear Video button is right aligned - const char* label = "Clear Video"; - const ImVec2 button_size = ImGui::CalcTextSize(label); - const f32 padding = ImGui::GetStyle().FramePadding.x * 2.0F; - const f32 right_x = ImGui::GetWindowContentRegionMax().x - (button_size.x + padding); - - ImGui::SetCursorPosX(right_x); - if (ImGui::Button(label)) { - stop_decoding_thread(); - is_playing_ = false; - video_path_ = {}; - try_cleanup_sokol_resources(); - return; - } - - ImGui::Separator(); - - // Playback logic and frame skipping can be ignored if paused - bool is_timer_tick = false; - if (is_playing_ && video_fps_ > 0.0F) { - time_accumulator_ += ImGui::GetIO().DeltaTime; - const auto frames_to_advance = static_cast(time_accumulator_ / frame_duration_); - - // We have to handle skipped frames gracefully - if (frames_to_advance > 0) { - time_accumulator_ -= (frames_to_advance * frame_duration_); - - if (frames_to_advance > 1) { - const std::scoped_lock lock{frame_mutex_}; - - const auto recoverable_frames = - std::min(static_cast(frame_queue_.size()) - 1, frames_to_advance - 1); - for (auto i = 0; i < recoverable_frames; i++) { frame_queue_.pop_front(); } - } - - is_timer_tick = true; - } - } - - update_texture(is_timer_tick); - - if (video_texture_.id != SG_INVALID_ID && texture_width_ > 0) { - const f32 aspect = static_cast(texture_width_) / static_cast(texture_height_); - const f32 avail_w = ImGui::GetContentRegionAvail().x; - const f32 h = avail_w / aspect; - - ImGui::Image(video_texture_id_, {avail_w, h}); - video_hovered_ = ImGui::IsItemHovered(); - } - - if (thread_running_) { - ImGui::Separator(); - draw_lhs_controls(); - } - } -} - -void view_page::draw_lhs_controls() { - // Slider - const auto current_timestamp_min = - (static_cast(current_frame_ui_) / total_frames_) * video_length_minutes_; - const auto current_timestamp = local_time::from_minutes(current_timestamp_min); - const auto formatted_timestamp = - current_timestamp.value_or(local_time::zero()).to_string(false); - ImGui::Text("%s / %s", formatted_timestamp.c_str(), video_length_formatted_.c_str()); - ImGui::SameLine(); - - { - ImGui::PushItemWidth(-1); - const auto cleanup_width{gsl::finally(ImGui::PopItemWidth)}; - - i32 slider_pos = current_frame_ui_; - if (ImGui::SliderInt( - "##scrub", &slider_pos, 0, total_frames_, "", ImGuiSliderFlags_NoInput)) { - is_playing_.exchange(false); - request_seek(slider_pos); - } - } - - // Loop checkbox - bool looping = is_looping_; - if (ImGui::Checkbox("Looping", &looping)) { is_looping_ = looping; } - ImGui::SameLine(); - - // Play/Pause - const auto is_playing = is_playing_.load(); - const auto tint_color = context_->style.dark_mode ? ImVec4{1, 1, 1, 1} : ImVec4{-1, -1, -1, 1}; - ImGui::SameLine(); - if (ImGui::ImageButton("##stepback", - step_button_.get_id(), - button_size_, - {1, 0}, - {0, 1}, - {0, 0, 0, 0}, - tint_color)) { - request_seek(current_frame_ui_ - 5); - is_playing_.exchange(false); - } - ImGui::SameLine(); - if (ImGui::ImageButton("##playpause", - is_playing ? pause_button_.get_id() : play_button_.get_id(), - button_size_, - {0, 0}, - {1, 1}, - {0, 0, 0, 0}, - tint_color)) { - is_playing_.exchange(!is_playing); - time_accumulator_ = 0.0; - } - ImGui::SameLine(); - if (ImGui::ImageButton("##stepforward", - step_button_.get_id(), - button_size_, - {0, 0}, - {1, 1}, - {0, 0, 0, 0}, - tint_color)) { - request_seek(current_frame_ui_ + 5); - is_playing_.exchange(false); - } - - // Keyboard shortcuts - if (video_hovered_ && !timestamp_input_focused_ && !context_->is_cmd_input_focused) { - if (ImGui::IsKeyPressed(ImGuiKey_Space)) { - is_playing_.exchange(!is_playing); - time_accumulator_ = 0.0; - } - - if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) { - request_seek(current_frame_ui_ - 1); - is_playing_.exchange(false); - } - - if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, false)) { - request_seek(current_frame_ui_ - 10); - is_playing_.exchange(false); - } - - if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) { - request_seek(current_frame_ui_ + 1); - is_playing_.exchange(false); - } - - if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, false)) { - request_seek(current_frame_ui_ + 10); - is_playing_.exchange(false); - } - } -} - -void view_page::draw_open_video() { - // Check if the file is ready - { - const std::scoped_lock lock{video_path_mutex_}; - if (selected_video_) { - stop_decoding_thread(); - try_cleanup_sokol_resources(); - video_path_ = selected_video_.value().first; - selected_video_ = stdx::none; - video_loaded_ = true; - start_decoding_thread(); - } - } - - // File selection can happen at any point during playback - const bool is_disabled_by_video = video_dialog_running_.load(); - if (is_disabled_by_video) { ImGui::BeginDisabled(); } - if (ImGui::Button("Open Video")) { - video_dialog_running_ = true; - video_loaded_ = false; - dynamic_plotting_ = false; - - const auto alive = is_alive_; - const auto& previous_file = video_path_; - - std::thread([this, alive, previous_file]() noexcept { - try { - const auto path = open_video_file(previous_file); - if (*alive) { - const std::scoped_lock lock{video_path_mutex_}; - selected_video_ = path; - video_dialog_running_ = false; - } - } catch (const std::exception& e) { - video_dialog_running_ = false; - log_critical( - context_->log, "Fatal error occurred while opening video dialog: {}", e.what()); - } catch (...) { - video_dialog_running_ = false; - log_critical(context_->log, "Unknown fatal error occurred"); - } - }).detach(); - } - if (is_disabled_by_video) { ImGui::EndDisabled(); } -} - -void view_page::draw_rhs() { - if (ImGui::BeginChild("##data")) { - const auto cleanup_data{gsl::finally(ImGui::EndChild)}; - HEADER({ - draw_open_text(); - ImGui::SameLine(); - draw_sync_video_buttons(); - ImGui::Separator(); - }); - - if (ImGui::BeginCombo("##DataView", data_type_string(data_show_))) { - const auto cleanup_combo{gsl::finally(ImGui::EndCombo)}; - if (ImGui::Selectable("All Data", data_show_ == data_view_t::ALL)) { - data_show_ = data_view_t::ALL; - } - if (ImGui::Selectable("RPM", data_show_ == data_view_t::RPMDATA)) { - data_show_ = data_view_t::RPMDATA; - } - if (ImGui::Selectable("Shock", data_show_ == data_view_t::SHOCKDATA)) { - data_show_ = data_view_t::SHOCKDATA; - } - } - - const std::shared_lock lock{context_->backend->get_data_latch()}; - const auto& data = context_->backend->get_data(); - - const auto sync_lt = data.get_sync_lt(); - const auto plot_title = sync_lt - ? fmt::format("Data View from {}", sync_lt.value().to_string()) - : "No Synced Time"; - - view_page::dynamic_plot_loop(); - if (ImPlot::BeginPlot(plot_title.c_str(), {-1, -1})) { - const auto cleanup_plot{gsl::finally(ImPlot::EndPlot)}; - utils::plot_if_non_empty("Wheel Speed", - data.get_time(), - data.get_series("W"), - data_show_ == data_view_t::ALL || - data_show_ == data_view_t::RPMDATA, - plot_percent_); - utils::plot_if_non_empty("Engine Speed", - data.get_time(), - data.get_series("E"), - data_show_ == data_view_t::ALL || - data_show_ == data_view_t::RPMDATA, - plot_percent_); - - utils::plot_if_non_empty("Front Right Shock Travel", - data.get_time(), - data.get_series("FR"), - data_show_ == data_view_t::ALL || - data_show_ == data_view_t::SHOCKDATA, - plot_percent_); - utils::plot_if_non_empty("Front Left Shock Travel", - data.get_time(), - data.get_series("FL"), - data_show_ == data_view_t::ALL || - data_show_ == data_view_t::SHOCKDATA, - plot_percent_); - utils::plot_if_non_empty("Rear Right Shock Travel", - data.get_time(), - data.get_series("RR"), - data_show_ == data_view_t::ALL || - data_show_ == data_view_t::SHOCKDATA, - plot_percent_); - utils::plot_if_non_empty("Rear Left Shock Travel", - data.get_time(), - data.get_series("RL"), - data_show_ == data_view_t::ALL || - data_show_ == data_view_t::SHOCKDATA, - plot_percent_); - } - } -} - -void view_page::draw_open_text() { - // Check if the file is ready - { - const std::scoped_lock lock{txt_path_mutex_}; - if (selected_txt_) { - txt_path_ = selected_txt_.value(); - selected_txt_ = stdx::none; - txt_loaded_ = true; - load_data(); - } - } - - // File selection can happen at any point during playback - const bool is_disabled_by_txt = txt_dialog_running_.load(); - if (is_disabled_by_txt) { ImGui::BeginDisabled(); } - if (ImGui::Button("Open Text File")) { - txt_dialog_running_ = true; - txt_loaded_ = false; - dynamic_plotting_ = false; - - const auto alive = is_alive_; - const auto previous_path = txt_path_; - - std::thread([this, alive, previous_path]() noexcept { - try { - const auto path = open_text_file(previous_path); - if (*alive) { - const std::scoped_lock lock{txt_path_mutex_}; - selected_txt_ = path; - txt_dialog_running_ = false; - context_->backend->set_logging(false); - } - } catch (const std::exception& e) { - txt_dialog_running_ = false; - log_critical(context_->log, - "Fatal error occurred while opening text file dialog: {}", - e.what()); - } catch (...) { - txt_dialog_running_ = false; - log_critical(context_->log, "Unknown fatal error occurred"); - } - }).detach(); - } - if (is_disabled_by_txt) { ImGui::EndDisabled(); } -} - -void view_page::draw_sync_video_buttons() { - if (ImGui::Button("Sync Data/Video")) { - data_and_time_sync_ = false; - dynamic_plotting_ = false; - - load_data(); - if (!txt_loaded_ || !video_loaded_) { - log_error(context_->log, "Could not sync data with video:"); - log_error(context_->log, " Text Loaded: {}", txt_loaded_); - log_error(context_->log, " Video Loaded: {}", video_loaded_); - return; - } - - video_creation_ts_ = local_time::from_string(creation_metadata_text_buf_); - if (!video_creation_ts_) { - log_error(context_->log, "Could not parse provided timestamp, or it was not provided."); - return; - } - creation_metadata_text_buf_ = {}; - - stdx::option sync_time_pos; - { - const std::shared_lock lock{context_->backend->get_data_latch()}; - sync_time_pos = sync_data_video(context_->backend->get_data().get_time_no_normal()); - } - - if (!sync_time_pos) { - log_error(context_->log, "Could not get trim position from data/video"); - return; - } - - // This is probably mega unsafe... - delete_extra(sync_time_pos.value()); - request_seek(0); - } - - ImGui::SameLine(); - utils::draw_input_box("##extra_view", creation_metadata_text_buf_, "HH:MM:SS", 120.0F); - timestamp_input_focused_ = ImGui::IsItemFocused(); - ImGui::SameLine(); - if (ImGui::Checkbox("Dynamic Plotting", &dynamic_plotting_)) { - view_page::dynamic_plot_start(); - } -} - -view_page::selected_video_t view_page::open_video_file(const std::string& previous_file) { - const char* const filters[] = {"*.mp4", "*.mov"}; - const char* path = tinyfd_openFileDialog( - "Select a video file", previous_file.c_str(), std::size(filters), filters, nullptr, 0); - if (path == nullptr) { - log_warn(context_->log, "No file selected"); - return stdx::none; - } - - const std::string real_path{path}; - log_info(context_->log, "Selected file: {}", real_path); - - auto dt = date_time::from_video_metadata(real_path); - if (dt) { - log_info( - context_->log, "Selected video with creation timestamp: {}", dt.value().to_string()); - } else { - log_warn(context_->log, "Could not detect datetime metadata from selected video."); - } - - return std::pair{path, dt}; -} - -view_page::selected_txt_file_t view_page::open_text_file(const std::string& previous_file) { - const char* const filters[] = {"*.txt"}; - const char* path = tinyfd_openFileDialog( - "Select a text file", previous_file.c_str(), std::size(filters), filters, nullptr, 0); - if (path == nullptr) { - log_warn(context_->log, "No file selected"); - return stdx::none; - } - - log_info(context_->log, "Selected file: {}", path); - return path; -} - -void view_page::load_data() { - std::ifstream file{txt_path_}; - if (!file.is_open()) { - log_error(context_->log, "Failed to open file: {}", txt_path_); - return; - } - - const std::unique_lock lock{context_->backend->get_data_latch()}; - context_->backend->get_data().clear(); - std::string ident, value; - while (file >> ident >> value) { context_->backend->get_data().write_data(ident, value); } -} - -void view_page::request_seek(i32 frame_index) { - // Prevent stepping out of bounds, though it is recoverable - const auto clamped_frame = std::clamp(frame_index, 0, total_frames_); - - { - const std::scoped_lock lock{frame_mutex_}; - frame_queue_.clear(); - } - - seek_target_ = clamped_frame; - force_update_frame_ = true; - current_frame_ui_ = clamped_frame; - queue_cv_.notify_one(); -} - -void view_page::start_decoding_thread() { - thread_running_ = true; - is_playing_ = true; - - decode_thread_ = std::thread([this]() { - cv::VideoCapture cap{video_path_}; - if (!cap.isOpened()) { - log_error(context_->log, "Failed to open video"); - thread_running_ = false; - return; - } - - video_fps_ = cap.get(cv::CAP_PROP_FPS); - frame_duration_ = 1.0 / video_fps_; - total_frames_ = static_cast(cap.get(cv::CAP_PROP_FRAME_COUNT)); - video_length_minutes_ = (static_cast(total_frames_) / video_fps_) / 60.0; - const auto video_length_lt = local_time::from_minutes(video_length_minutes_); - if (!video_length_lt) { - log_error(context_->log, "Video length could not be determined"); - thread_running_ = false; - return; - } - video_length_formatted_ = video_length_lt.value().to_string(false); - - cv::Mat raw_frame, rgba_frame; - while (thread_running_) { - // Keep the buffer from becoming too large - { - std::unique_lock lock{frame_mutex_}; - queue_cv_.wait(lock, [this] { - return frame_queue_.size() < MAX_QUEUE_SIZE || !thread_running_; - }); - - if (!thread_running_) { break; } - } - - const i32 seek_req = seek_target_.exchange(-1); - bool just_sought = false; - - if (seek_req != -1) { - cap.set(cv::CAP_PROP_POS_FRAMES, seek_req); - const std::scoped_lock lock{frame_mutex_}; - frame_queue_.clear(); - just_sought = true; - } - - // Don't decode if seeking or paused to prevent jitters - if (!is_playing_ && !just_sought) { - std::this_thread::sleep_for(5ms); - continue; - } - - auto current_frame_index = static_cast(cap.get(cv::CAP_PROP_POS_FRAMES)); - { - PROFILE_SCOPE("view_page::decode_frame"); - if (cap.read(raw_frame)) { - cv::cvtColor(raw_frame, rgba_frame, cv::COLOR_BGR2RGBA); - const cv::Mat frame_copy = rgba_frame.clone(); - - const std::scoped_lock lock{frame_mutex_}; - frame_queue_.emplace_back(frame_copy, current_frame_index); - } else { - if (is_looping_) { - cap.set(cv::CAP_PROP_POS_FRAMES, 0); - } else { - std::this_thread::sleep_for(10ms); - } - } - } - } - }); -} - -void view_page::stop_decoding_thread() { - thread_running_ = false; - queue_cv_.notify_all(); - if (decode_thread_.joinable()) { decode_thread_.join(); } - - const std::scoped_lock lock{frame_mutex_}; - frame_queue_.clear(); -} - -void view_page::update_texture(bool is_timer_tick) { - PROFILE_FUNCTION(); - cv::Mat frame_to_upload; - bool frame_ready = false; - { - const std::scoped_lock lock{frame_mutex_}; - const bool should_consume = is_timer_tick || force_update_frame_; - - if (should_consume && !frame_queue_.empty()) { - const auto p = frame_queue_.front(); - frame_to_upload = p.first; - current_frame_ui_ = p.second; - - frame_queue_.pop_front(); - frame_ready = true; - queue_cv_.notify_one(); - force_update_frame_.exchange(false); - } - } - - if (frame_ready && !frame_to_upload.empty()) { - // Initialize Texture if resolution changed or first run - if (texture_width_ != frame_to_upload.cols || texture_height_ != frame_to_upload.rows) { - try_cleanup_sokol_resources(); - - texture_width_ = frame_to_upload.cols; - texture_height_ = frame_to_upload.rows; - - sg_image_desc desc = {}; - desc.width = texture_width_; - desc.height = texture_height_; - desc.pixel_format = SG_PIXELFORMAT_RGBA8; - desc.usage.stream_update = true; - desc.num_mipmaps = 1; - video_texture_ = sg_make_image(&desc); - ASSERT(video_texture_.id != SG_INVALID_ID, "Video texture failed to initialize"); - - sg_view_desc view_desc = {}; - view_desc.texture.image = video_texture_; - video_view_ = sg_make_view(&view_desc); - video_texture_id_ = simgui_imtextureid(video_view_); - } - - // Perform the actual upload - sg_image_data data = {}; - data.mip_levels[0].ptr = frame_to_upload.data; - data.mip_levels[0].size = frame_to_upload.total() * frame_to_upload.elemSize(); - sg_update_image(video_texture_, &data); - } -}; - -void view_page::try_cleanup_sokol_resources() { - if (video_view_.id != SG_INVALID_ID) { - sg_destroy_view(video_view_); - video_view_.id = SG_INVALID_ID; - } - - if (video_texture_.id != SG_INVALID_ID) { - sg_destroy_image(video_texture_); - video_texture_.id = SG_INVALID_ID; - video_texture_id_ = 0; - } - - texture_width_ = 0; - texture_height_ = 0; -} - -stdx::option view_page::sync_data_video(const std::vector& micros_times) { - if (!video_creation_ts_) { return stdx::none; } - - data_and_time_sync_ = true; - const auto creation_timestamp = video_creation_ts_.value(); - const u64 micros_to_sync = creation_timestamp.micros_since_midnight(); - const auto it = std::ranges::lower_bound(micros_times, micros_to_sync); - if (it != micros_times.end()) { return std::distance(micros_times.begin(), it); } - - return stdx::none; -} - -void view_page::delete_extra(usize erase_pos) { - const std::unique_lock lock{context_->backend->get_data_latch()}; - std::vector* const data[] = { - &context_->backend->get_data().time_, - &context_->backend->get_data().get_series("W"), - &context_->backend->get_data().get_series("E"), - &context_->backend->get_data().get_series("FR"), - &context_->backend->get_data().get_series("FL"), - &context_->backend->get_data().get_series("RR"), - &context_->backend->get_data().get_series("RL"), - }; - - for (const auto& datum : data) { - if (datum->size() < erase_pos) { return; } - } - - for (const auto& datum : data) { - datum->erase(datum->begin(), datum->begin() + static_cast(erase_pos)); - } -} - -void view_page::dynamic_plot_start() { - if (dynamic_plotting_) { - const std::shared_lock lock{context_->backend->get_data_latch()}; - const auto& time_vec = context_->backend->get_data().time_; - if (time_vec.empty()) { return; } - const auto begin_time_min = context_->backend->get_data().time_[0]; - - const f64 target_end_time = begin_time_min + video_length_minutes_; - const auto end_it = std::ranges::lower_bound(time_vec, target_end_time); - - const usize end_idx = std::distance(time_vec.begin(), end_it); - const usize end_idy = std::distance(end_it, time_vec.end()); - data_from_end_ = static_cast(end_idy); - data_count_ = static_cast(end_idx); - points_per_ = static_cast(end_idx) / total_frames_; - } -} - -void view_page::dynamic_plot_loop() { - if (dynamic_plotting_) { - plot_percent_ = static_cast(std::max( - data_count_ - (points_per_ * current_frame_ui_) + data_from_end_, data_from_end_)); - return; - } - plot_percent_ = 0; -} - -} // namespace mbr::pages diff --git a/src/app/style.cpp b/src/app/style.cpp deleted file mode 100644 index 19ba722..0000000 --- a/src/app/style.cpp +++ /dev/null @@ -1,170 +0,0 @@ -#include "app/style.hpp" - -#include - -#ifdef _WIN32 -# include -# include -# include -#endif - -#include -#include -#include -#include -#include - -#include "app/assets/fonts/open_sans.hpp" -#include "core/log.hpp" - -namespace mbr { - -namespace { - -constinit stdx::option> color_cache = stdx::none; - -void initialize_imgui_color_cache(ImVec4 colors[ImGuiCol_COUNT]) { - auto& cache = color_cache.emplace(); - for (auto i = 0; i < ImGuiCol_COUNT; i++) { cache[i] = colors[i]; } -} - -void refresh_imgui_color_cache(ImVec4 colors[ImGuiCol_COUNT]) { - ASSERT(color_cache, "Color cache has not yet been initialized"); - auto& cache = *color_cache; - for (auto i = 0; i < ImGuiCol_COUNT; i++) { colors[i] = cache[i]; } -} - -} // namespace - -app_fonts load_fonts() { - ImGuiIO& io = ImGui::GetIO(); - ImFontConfig cfg; - cfg.FontDataOwnedByAtlas = false; - - auto* regular = - io.Fonts->AddFontFromMemoryTTF(assets::OPEN_SANS_REGULAR_TTF.data(), - static_cast(assets::OPEN_SANS_REGULAR_TTF.size()), - DEFAULT_FONT_SIZE, - &cfg); - - auto* bold = io.Fonts->AddFontFromMemoryTTF(assets::OPEN_SANS_BOLD_TTF.data(), - static_cast(assets::OPEN_SANS_BOLD_TTF.size()), - DEFAULT_FONT_SIZE, - &cfg); - auto* italic = - io.Fonts->AddFontFromMemoryTTF(assets::OPEN_SANS_ITALIC_TTF.data(), - static_cast(assets::OPEN_SANS_ITALIC_TTF.size()), - DEFAULT_FONT_SIZE, - &cfg); - auto* bold_italic = - io.Fonts->AddFontFromMemoryTTF(assets::OPEN_SANS_BOLD_ITALIC_TTF.data(), - static_cast(assets::OPEN_SANS_BOLD_ITALIC_TTF.size()), - DEFAULT_FONT_SIZE, - &cfg); - - return {.regular = regular, .bold = bold, .italic = italic, .bold_italic = bold_italic}; -} - -void app_style::set_dark_theme(const log_fn_t& log) { - log_info(log, "Setting dark mode"); -#ifdef _WIN32 - HWND hwnd = reinterpret_cast(const_cast(sapp_win32_get_hwnd())); - BOOL use_dark_mode = TRUE; - DwmSetWindowAttribute(hwnd, 20, &use_dark_mode, sizeof(use_dark_mode)); -#endif - - // This is from my Game Engine - auto& colors = ImGui::GetStyle().Colors; - if (!color_cache) { initialize_imgui_color_cache(colors); } - refresh_imgui_color_cache(colors); - - colors[ImGuiCol_WindowBg] = {0.1F, 0.105F, 0.11F, 1.0F}; - - // Headers - colors[ImGuiCol_Header] = {0.2F, 0.205F, 0.21F, 1.0F}; - colors[ImGuiCol_HeaderHovered] = {0.3F, 0.305F, 0.31F, 1.0F}; - colors[ImGuiCol_HeaderActive] = {0.15F, 0.1505F, 0.151F, 1.0F}; - - // Buttons - colors[ImGuiCol_Button] = {0.2F, 0.205F, 0.21F, 1.0F}; - colors[ImGuiCol_ButtonHovered] = {0.3F, 0.305F, 0.31F, 1.0F}; - colors[ImGuiCol_ButtonActive] = {0.15F, 0.1505F, 0.151F, 1.0F}; - - // Frame BG - colors[ImGuiCol_FrameBg] = {0.2F, 0.205F, 0.21F, 1.0F}; - colors[ImGuiCol_FrameBgHovered] = {0.3F, 0.305F, 0.31F, 1.0F}; - colors[ImGuiCol_FrameBgActive] = {0.15F, 0.1505F, 0.151F, 1.0F}; - - // Tabs - colors[ImGuiCol_Tab] = {0.15F, 0.1505F, 0.151F, 1.0F}; - colors[ImGuiCol_TabHovered] = {0.38F, 0.3805F, 0.381F, 1.0F}; - colors[ImGuiCol_TabActive] = {0.28F, 0.2805F, 0.281F, 1.0F}; - colors[ImGuiCol_TabUnfocused] = {0.15F, 0.1505F, 0.151F, 1.0F}; - colors[ImGuiCol_TabUnfocusedActive] = {0.2F, 0.205F, 0.21F, 1.0F}; - - // Title - colors[ImGuiCol_TitleBg] = {0.15F, 0.1505F, 0.151F, 1.0F}; - colors[ImGuiCol_TitleBgActive] = {0.15F, 0.1505F, 0.151F, 1.0F}; - colors[ImGuiCol_TitleBgCollapsed] = {0.15F, 0.1505F, 0.151F, 1.0F}; - - dark_mode = true; -} - -void app_style::set_light_theme(const log_fn_t& log) { - log_info(log, "Setting light mode"); -#ifdef _WIN32 - HWND hwnd = reinterpret_cast(const_cast(sapp_win32_get_hwnd())); - BOOL use_dark_mode = FALSE; - DwmSetWindowAttribute(hwnd, 20, &use_dark_mode, sizeof(use_dark_mode)); -#endif - - // This isn't from my Game Engine - auto& colors = ImGui::GetStyle().Colors; - if (!color_cache) { initialize_imgui_color_cache(colors); } - refresh_imgui_color_cache(colors); - - colors[ImGuiCol_WindowBg] = {0.94F, 0.94F, 0.94F, 1.0F}; - colors[ImGuiCol_ChildBg] = {0.00F, 0.00F, 0.00F, 0.00F}; - colors[ImGuiCol_PopupBg] = {1.00F, 1.00F, 1.00F, 0.98F}; - - // Text - colors[ImGuiCol_Text] = {0.15F, 0.15F, 0.15F, 1.0F}; - colors[ImGuiCol_TextDisabled] = {0.50F, 0.50F, 0.50F, 1.00F}; - colors[ImGuiCol_InputTextCursor] = {0.15F, 0.15F, 0.15F, 1.0F}; - - // Headers - colors[ImGuiCol_Header] = {0.85F, 0.85F, 0.85F, 1.0F}; - colors[ImGuiCol_HeaderHovered] = {0.78F, 0.78F, 0.78F, 1.0F}; - colors[ImGuiCol_HeaderActive] = {0.70F, 0.70F, 0.70F, 1.0F}; - - // Buttons - colors[ImGuiCol_Button] = {0.85F, 0.85F, 0.85F, 1.0F}; - colors[ImGuiCol_ButtonHovered] = {0.75F, 0.75F, 0.75F, 1.0F}; - colors[ImGuiCol_ButtonActive] = {0.65F, 0.65F, 0.65F, 1.0F}; - - // Frame BG - colors[ImGuiCol_FrameBg] = {1.00F, 1.00F, 1.00F, 1.0F}; - colors[ImGuiCol_FrameBgHovered] = {0.90F, 0.90F, 0.95F, 1.0F}; - colors[ImGuiCol_FrameBgActive] = {0.85F, 0.85F, 0.90F, 1.0F}; - - // Tabs - colors[ImGuiCol_Tab] = {0.85F, 0.85F, 0.85F, 1.0F}; - colors[ImGuiCol_TabHovered] = {0.95F, 0.95F, 0.95F, 1.0F}; - colors[ImGuiCol_TabActive] = {0.94F, 0.94F, 0.94F, 1.0F}; - colors[ImGuiCol_TabUnfocused] = {0.80F, 0.80F, 0.80F, 1.0F}; - colors[ImGuiCol_TabUnfocusedActive] = {0.88F, 0.88F, 0.88F, 1.0F}; - - // Title - colors[ImGuiCol_TitleBg] = {0.90F, 0.90F, 0.90F, 1.0F}; - colors[ImGuiCol_TitleBgActive] = {0.90F, 0.90F, 0.90F, 1.0F}; - colors[ImGuiCol_TitleBgCollapsed] = {0.90F, 0.90F, 0.90F, 1.0F}; - colors[ImGuiCol_MenuBarBg] = {0.90F, 0.90F, 0.90F, 1.0F}; - - // Separators & Borders - colors[ImGuiCol_Separator] = {0.70F, 0.70F, 0.70F, 1.0F}; - colors[ImGuiCol_Border] = {0.70F, 0.70F, 0.70F, 0.5F}; - - dark_mode = false; -} - -} // namespace mbr diff --git a/src/esp32/backend.cpp b/src/esp32/backend.cpp index 0397b3b..dbcb508 100644 --- a/src/esp32/backend.cpp +++ b/src/esp32/backend.cpp @@ -67,7 +67,9 @@ void telemetry_backend::start() { web_sockets_.setOnMessageCallback([this](const ix::WebSocketMessagePtr& msg) { if (msg->type == ix::WebSocketMessageType::Open) { is_connected_ = true; + notify_connection_changed(true); is_receiving_ = false; + notify_receiving_changed(false); send_cmd("STATUS"); log_info(log_, "Connected to ESP32"); } @@ -76,6 +78,8 @@ void telemetry_backend::start() { msg->type == ix::WebSocketMessageType::Error) { is_connected_ = false; is_receiving_ = false; + notify_connection_changed(false); + notify_receiving_changed(false); } if (msg->type == ix::WebSocketMessageType::Close) { log_warn(log_, "WebSocket closed"); } @@ -86,6 +90,7 @@ void telemetry_backend::start() { if (msg->type == ix::WebSocketMessageType::Message) { is_receiving_ = true; + notify_receiving_changed(true); this->on_message(msg); last_data_time_ = std::chrono::steady_clock::now(); } @@ -103,12 +108,19 @@ void telemetry_backend::kill() { } } +void telemetry_backend::restart() { + kill(); + should_kill_ = false; + start(); +} + void telemetry_backend::send_cmd(const std::string& text) { if (web_sockets_.getReadyState() == ix::ReadyState::Open) { const ix::WebSocketSendInfo info = web_sockets_.send(text); if (!info.success) { log_warn(log_, "Send failed:"); is_connected_ = false; + notify_connection_changed(false); return; } @@ -122,9 +134,13 @@ void telemetry_backend::send_cmd(const std::string& text) { void telemetry_backend::worker_loop() { while (!should_kill_) { std::this_thread::sleep_for(10ms); - if (web_sockets_.getReadyState() != ix::ReadyState::Open) { is_connected_ = false; } - if (std::chrono::steady_clock::now() - last_data_time_.load() > 500ms) { + if (web_sockets_.getReadyState() != ix::ReadyState::Open) { + is_connected_ = false; + notify_connection_changed(false); + } + if (std::chrono::steady_clock::now() - last_data_time_.load() > 250ms) { is_receiving_ = false; + notify_receiving_changed(false); } } } @@ -150,17 +166,23 @@ void telemetry_backend::on_message(const ix::WebSocketMessagePtr& msg) { // Now we can safely unpack the packet const std::unique_lock lock{data_latch_}; - // write data - if (!is_logging_) { continue; } - + // Write data and add keys received to signal + std::vector received_keys; + received_keys.reserve(parsed.value().size()); for (const auto& [ident, value] : parsed.value()) { - data_.write_data(std::string{ident}, std::string{value}); + received_keys.push_back(std::string{ident}); + if (is_logging_) { data_.write_data(std::string{ident}, std::string{value}); } } - data_.write_raw_line(line); - for (const auto& [ident, value] : parsed.value()) { - if (ident == "W") { - data_.save_current_line(line); - break; + notify_fields_received(received_keys); + + if (is_logging_) { + notify_data(data_.get_series()); + data_.write_raw_line(line); + for (const auto& [ident, value] : parsed.value()) { + if (ident == "W") { + data_.save_current_line(line); + break; + } } } } @@ -233,7 +255,6 @@ void telemetry_backend::set_ip(const ipv4_t& ipv4) { kill(); should_kill_ = false; start(); - try_connection_ = false; } namespace { @@ -328,78 +349,4 @@ void telemetry_backend::register_handlers() { }; } -/* -auto TelemetryBackend::HandleCommand( - stdx::option>> parsed) -> void { - if (!parsed || parsed->size() < 2) { - LOG_ERROR("Command Not Found"); - return; - } - auto response = parsed.value()[1]; - if (response.first == "SYNC") { - u64 micros = 0; - std::from_chars( - response.second.data(), response.second.data() + response.second.size(), micros); - LocalTime t{micros}; - LOG_INFO("Time successfully synced at: {}", t.String(false)); - } else if (response.first == "SD_START") { - if (response.second == "deadbeef") { - LOG_ERROR("SD Card Failed Initialization"); - } else { - LOG_INFO("SD Card Initialized At {}", response.second); - IsOpen = true; - } - } else if (response.first == "SD_WRITE") { - if (response.second == "1") { - LOG_INFO("SD Card Has Begun Writing"); - IsWriting = true; - } else if (response.second == "0") { - LOG_INFO("SD Card Has Stopped Writing"); - IsWriting = false; - } - } else if (response.first == "SD_CLOSE") { - if (response.second == "1") { - IsOpen = false; - LOG_INFO("SD Card Has Closed Succesfully"); - } else if (response.second == "0") { - LOG_INFO("SD Card Failed Close"); - } - } else if (parsed.value()[0].second == "1") { - LOG_ERROR("Command Not Found"); - } -} -for (const auto& field : m_PacketFields) { - const usize ident_start = pos; - while (pos < str.size() && str[pos] != ' ') { - pos += 1; - } - if (str.substr(ident_start, pos - ident_start) != field) { return stdx::none; } - - // There can be an arbitrary amount of spaces between idents/values - if (pos >= str.size() || str[pos] != ' ') { return stdx::none; } - while (pos < str.size() && str[pos] == ' ') { - pos += 1; - } - - const usize value_start = pos; - while (pos < str.size() && str[pos] != ' ') { - pos += 1; - } - if (value_start == pos) { return stdx::none; } - const auto value = str.substr(value_start, pos - value_start); - - // Uncalibrated packets or unparsable times are invalid - if (field == "T") { - u64 t = 0; - auto res = std::from_chars(value.data(), value.data() + value.size(), t); - if (res.ec != std::errc{} || t == 0) { return stdx::none; } - } - - parsed.emplace_back(field, value); - while (pos < str.size() && str[pos] == ' ') { - pos += 1; - } -} -*/ - } // namespace mbr diff --git a/src/esp32/data.cpp b/src/esp32/data.cpp index 00302ae..8c766ce 100644 --- a/src/esp32/data.cpp +++ b/src/esp32/data.cpp @@ -49,7 +49,7 @@ void telemetry_data::init_json(const std::filesystem::path& path) { } void telemetry_data::init_data() { - for (const auto& data : data_values) { series.try_emplace(data.key, std::vector{}); } + for (const auto& data : data_values) { series_.try_emplace(data.key, std::vector{}); } } void telemetry_data::write_data(const std::string& identifier, const std::string& value) { @@ -57,7 +57,7 @@ void telemetry_data::write_data(const std::string& identifier, const std::string try { // This cannot be from_chars since apple clang doesn't support it :( f64 val = std::stod(value); - series[identifier].emplace_back(val); + series_[identifier].emplace_back(val); if (identifier == "T") { u64 raw_micros = static_cast(val); time_no_normal_micros_.emplace_back(raw_micros); @@ -67,7 +67,7 @@ void telemetry_data::write_data(const std::string& identifier, const std::string } void telemetry_data::clear() { - for (auto& [key, values] : series) { values.clear(); } + for (auto& [key, values] : series_) { values.clear(); } time_.clear(); time_no_normal_micros_.clear(); raw_lines_.clear(); diff --git a/src/main.cpp b/src/main.cpp index 2cdb0b1..db743ec 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,9 @@ -#include -#include - #include "app/app.hpp" +#include -i32 main(i32 argc, char** argv) { +i32 main(i32 argc, char* argv[]) { stdx::profiler profiler{argv[0]}; mbr::app_t app{argc, argv}; app.run(); + return 0; } diff --git a/src/vendor/sokol_impl.cpp b/src/vendor/sokol_impl.cpp deleted file mode 100644 index a971196..0000000 --- a/src/vendor/sokol_impl.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#define SOKOL_APP_IMPL -#define SOKOL_GFX_IMPL -#define SOKOL_GLUE_IMPL -#define SOKOL_IMGUI_IMPL -#define SOKOL_LOG_IMPL -#define SOKOL_NO_ENTRY - -#include - -#include -#include -#include -#include -#include diff --git a/tests/esp32/test_data.cpp b/tests/esp32/test_data.cpp index ab389ab..701533f 100644 --- a/tests/esp32/test_data.cpp +++ b/tests/esp32/test_data.cpp @@ -41,15 +41,15 @@ TEST_CASE("telemetry_data config loading and data writing") { const auto& series_t = data.get_series("T"); const auto& series_e = data.get_series("E"); - REQUIRE(series_t.size() == 1); - CHECK(series_t[0] == Catch::Approx(60'000'000.0)); - REQUIRE(series_e.size() == 1); - CHECK(series_e[0] == Catch::Approx(3500.5)); + REQUIRE(series_t.get()->size() == 1); + CHECK(series_t.get()->at(0) == Catch::Approx(60'000'000.0)); + REQUIRE(series_e.get()->size() == 1); + CHECK(series_e.get()->at(0) == Catch::Approx(3500.5)); } SECTION("Handling missing keys") { const auto& series_missing = data.get_series("UNKNOWN_KEY"); - CHECK(series_missing.empty()); + CHECK_FALSE(series_missing); std::string logs = logger.get_streamed_logs(); CHECK(logs.contains("Key 'UNKNOWN_KEY' not found in telemetry configuration!")); }