diff --git a/glass/BUILD.bazel b/glass/BUILD.bazel index 4c61ed3eb4..ce4cb16883 100644 --- a/glass/BUILD.bazel +++ b/glass/BUILD.bazel @@ -1,10 +1,17 @@ -load("@rules_cc//cc:defs.bzl", "cc_binary") -load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_static_library") +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") +load("//shared/bazel/rules:cc_rules.bzl", "third_party_cc_lib_helper", "wpilib_cc_library", "wpilib_cc_static_library") load("//shared/bazel/rules:packaging.bzl", "package_binary_cc_project", "package_static_cc_project") load("//shared/bazel/rules:publishing.bzl", "host_architectures") load("//shared/bazel/rules/gen:gen-resources.bzl", "generate_resources") load("//shared/bazel/rules/gen:gen-version-file.bzl", "generate_version_file") +third_party_cc_lib_helper( + name = "mrccomm", + include_root = "src/lib/native/thirdparty/mrccomm/include", + src_root = "src/lib/native/thirdparty/mrccomm/src", + visibility = ["//visibility:public"], +) + wpilib_cc_library( name = "glass", srcs = glob(["src/lib/native/cpp/**/*.cpp"]), @@ -16,6 +23,9 @@ wpilib_cc_library( tags = [ "wpi-cpp-gui", ], + third_party_libraries = [ + ":mrccomm", + ], visibility = ["//visibility:public"], deps = [ "//fields", @@ -39,6 +49,19 @@ wpilib_cc_static_library( ], ) +cc_test( + name = "glass-test", + size = "small", + srcs = glob(["src/test/native/**/*.cpp"]), + tags = [ + "wpi-cpp-gui", + ], + deps = [ + ":glass", + "//thirdparty/catch2", + ], +) + wpilib_cc_library( name = "glassnt", srcs = glob(["src/libnt/native/cpp/*.cpp"]), diff --git a/glass/CMakeLists.txt b/glass/CMakeLists.txt index be58821577..f6983c8ca1 100644 --- a/glass/CMakeLists.txt +++ b/glass/CMakeLists.txt @@ -1,13 +1,14 @@ project(glass) include(CompileWarnings) +include(AddTest) include(GenResources) include(LinkMacOSGUI) # # libglass # -file(GLOB_RECURSE libglass_src src/lib/native/cpp/*.cpp) +file(GLOB_RECURSE libglass_src src/lib/native/cpp/*.cpp src/lib/native/thirdparty/mrccomm/src/*.cpp) add_library(libglass STATIC ${libglass_src}) set_target_properties(libglass PROPERTIES DEBUG_POSTFIX "d") @@ -25,12 +26,21 @@ target_include_directories( libglass PUBLIC $ + $ $ ) install(TARGETS libglass EXPORT libglass) export(TARGETS libglass FILE libglass.cmake NAMESPACE libglass::) -install(DIRECTORY src/lib/native/include/ DESTINATION "${include_dest}/glass") +install( + DIRECTORY src/lib/native/include/ src/lib/native/thirdparty/mrccomm/include/ + DESTINATION "${include_dest}/glass" +) + +if(WITH_TESTS) + wpilib_add_test_catch2(glass src/test/native/cpp) + target_link_libraries(glass_test libglass) +endif() # # libglassnt diff --git a/glass/build.gradle b/glass/build.gradle index 762e8c9b22..db3c337377 100644 --- a/glass/build.gradle +++ b/glass/build.gradle @@ -7,6 +7,7 @@ if (project.hasProperty('onlylinuxathena') || project.hasProperty('onlylinuxsyst description = "A different kind of dashboard" apply plugin: 'cpp' +apply plugin: 'google-test-test-suite' apply plugin: 'visual-studio' apply plugin: 'org.wpilib.NativeUtils' @@ -16,10 +17,13 @@ if (OperatingSystem.current().isWindows()) { ext { nativeName = 'glass' + staticGtestConfigs = [:] } +staticGtestConfigs["${nativeName}Catch2Test"] = [] apply from: "${rootDir}/shared/resources.gradle" apply from: "${rootDir}/shared/config.gradle" +apply from: "${rootDir}/shared/catch2.gradle" def wpilibVersionFileInput = file("src/app/generate/WPILibVersion.cpp.in") def wpilibVersionFileOutput = file("$buildDir/generated/app/cpp/WPILibVersion.cpp") @@ -60,6 +64,7 @@ gradle.taskGraph.addTaskExecutionGraphListener { graph -> } def generateTask = createGenerateResourcesTask('app', 'GLASS', 'wpi::glass', project) +def mrccommIncludeDir = file('src/lib/native/thirdparty/mrccomm/include').absolutePath project(':').libraryBuild.dependsOn build tasks.withType(CppCompile) { @@ -77,7 +82,7 @@ model { "${nativeName}"(NativeLibrarySpec) { sources.cpp { source { - srcDirs 'src/lib/native/cpp' + srcDirs 'src/lib/native/cpp', 'src/lib/native/thirdparty/mrccomm/src' include '**/*.cpp' } exportedHeaders { @@ -94,6 +99,12 @@ model { return } + if (it.targetPlatform.operatingSystem.isWindows()) { + it.cppCompiler.args.add("/I${mrccommIncludeDir}") + } else { + it.cppCompiler.args.add("-I${mrccommIncludeDir}") + } + lib project: ':wpimath', library: 'wpimath', linkage: 'shared' lib project: ':wpigui', library: 'wpigui', linkage: 'static' lib project: ':fields', library: 'fields', linkage: 'shared' @@ -183,6 +194,51 @@ model { } } } + testSuites { + "${nativeName}Catch2Test"(GoogleTestTestSuiteSpec) { + for (NativeComponentSpec c : $.components) { + if (c.name == nativeName) { + testing c + break + } + } + sources { + cpp { + source { + srcDirs 'src/test/native/cpp' + include '**/*.cpp' + } + exportedHeaders { + srcDirs 'src/test/native/include', + 'src/lib/native/include' + } + } + } + } + } + binaries { + withType(GoogleTestTestSuiteBinarySpec) { + if (it.targetPlatform.name == nativeUtils.wpi.platforms.systemcore) { + it.buildable = false + return + } + lib project: ':thirdparty:catch2', library: 'catch2', linkage: 'static' + lib project: ':wpimath', library: 'wpimath', linkage: 'shared' + lib project: ':wpigui', library: 'wpigui', linkage: 'static' + lib project: ':fields', library: 'fields', linkage: 'static' + lib project: ':thirdparty:imgui_suite', library: 'imguiSuite', linkage: 'static' + lib project: ':wpiutil', library: 'wpiutil', linkage: 'shared' + if (it.targetPlatform.operatingSystem.isWindows()) { + it.cppCompiler.args.add("/I${mrccommIncludeDir}") + it.linker.args << 'kernel32.lib' << 'Gdi32.lib' << 'User32.lib' << 'Shell32.lib' << 'd3d11.lib' << 'd3dcompiler.lib' + } else if (it.targetPlatform.operatingSystem.isMacOsX()) { + it.cppCompiler.args.add("-I${mrccommIncludeDir}") + it.linker.args << '-framework' << 'Metal' << '-framework' << 'MetalKit' << '-framework' << 'Cocoa' << '-framework' << 'IOKit' << '-framework' << 'CoreFoundation' << '-framework' << 'CoreVideo' << '-framework' << 'QuartzCore' + } else { + it.cppCompiler.args.add("-I${mrccommIncludeDir}") + } + } + } } apply from: 'publish.gradle' diff --git a/glass/publish.gradle b/glass/publish.gradle index 1cae7970e4..5d7dbd6964 100644 --- a/glass/publish.gradle +++ b/glass/publish.gradle @@ -21,6 +21,8 @@ task libCppSourcesZip(type: Zip) { from(licenseFile) { into '/' } from('src/lib/native/cpp') { into '/' } + from('src/lib/native/thirdparty/mrccomm/src') { into '/' } + from('src/lib/native/thirdparty/mrccomm/include') { into '/' } } task libCppHeadersZip(type: Zip) { diff --git a/glass/src/lib/native/cpp/other/AnsiDisplay.cpp b/glass/src/lib/native/cpp/other/AnsiDisplay.cpp new file mode 100644 index 0000000000..cb8c157952 --- /dev/null +++ b/glass/src/lib/native/cpp/other/AnsiDisplay.cpp @@ -0,0 +1,376 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +#include "wpi/glass/other/AnsiDisplay.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mrc/AnsiDisplayState.h" + +using namespace wpi::glass; + +namespace { +constexpr ImU32 MakeColor(int r, int g, int b) { + return IM_COL32(r, g, b, 255); +} + +constexpr ImU32 DEFAULT_FOREGROUND = MakeColor(0xff, 0xff, 0xff); +constexpr ImU32 DEFAULT_BACKGROUND = MakeColor(0x24, 0x24, 0x24); +constexpr std::array ANSI_COLORS = { + MakeColor(0x00, 0x00, 0x00), MakeColor(0x80, 0x00, 0x00), + MakeColor(0x00, 0x80, 0x00), MakeColor(0x80, 0x80, 0x00), + MakeColor(0x00, 0x00, 0x80), MakeColor(0x80, 0x00, 0x80), + MakeColor(0x00, 0x80, 0x80), MakeColor(0xc0, 0xc0, 0xc0), + MakeColor(0x80, 0x80, 0x80), MakeColor(0xff, 0x00, 0x00), + MakeColor(0x00, 0xff, 0x00), MakeColor(0xff, 0xff, 0x00), + MakeColor(0x00, 0x00, 0xff), MakeColor(0xff, 0x00, 0xff), + MakeColor(0x00, 0xff, 0xff), MakeColor(0xff, 0xff, 0xff), +}; + +int ClampColorChannel(int value) { + return std::clamp(value, 0, 255); +} + +ImU32 GetAnsiColor(int index, bool bright) { + int paletteIndex = (bright ? 8 : 0) + index; + if (paletteIndex < 0 || + paletteIndex >= static_cast(ANSI_COLORS.size())) { + return DEFAULT_FOREGROUND; + } + + return ANSI_COLORS[paletteIndex]; +} + +int ColorCubeChannel(int value) { + return value == 0 ? 0 : 55 + (value * 40); +} + +ImU32 GetAnsi256Color(int index) { + index = std::clamp(index, 0, 255); + if (index < 16) { + return GetAnsiColor(index % 8, index >= 8); + } + + if (index >= 232) { + int channel = 8 + ((index - 232) * 10); + return MakeColor(channel, channel, channel); + } + + int colorIndex = index - 16; + int r = ColorCubeChannel(colorIndex / 36); + int g = ColorCubeChannel((colorIndex / 6) % 6); + int b = ColorCubeChannel(colorIndex % 6); + return MakeColor(r, g, b); +} + +bool GetColor(const mrc::AnsiDisplayColor& color, ImU32* output) { + switch (color.ColorKind) { + case mrc::AnsiDisplayColor::Kind::Default: + return false; + case mrc::AnsiDisplayColor::Kind::Basic: + *output = GetAnsiColor(color.Index, color.Bright); + return true; + case mrc::AnsiDisplayColor::Kind::Indexed256: + *output = GetAnsi256Color(color.Index); + return true; + case mrc::AnsiDisplayColor::Kind::Rgb: + *output = MakeColor(ClampColorChannel(color.Red), + ClampColorChannel(color.Green), + ClampColorChannel(color.Blue)); + return true; + } + + return false; +} + +AnsiDisplayStyle ConvertStyle(const mrc::AnsiDisplayStyle& style) { + AnsiDisplayStyle converted; + converted.hasForeground = GetColor(style.Foreground, &converted.foreground); + converted.hasBackground = GetColor(style.Background, &converted.background); + converted.bold = style.Bold; + converted.italic = style.Italic; + converted.underline = style.Underline; + converted.inverse = style.Inverse; + return converted; +} + +std::string BuildPlainText(const std::vector& lines) { + std::string text; + size_t textLength = lines.empty() ? 0 : lines.size() - 1; + for (const auto& line : lines) { + size_t column = 0; + for (const auto& segment : line.segments) { + if (segment.startColumn > column) { + textLength += segment.startColumn - column; + } + textLength += segment.text.size(); + column = segment.startColumn + segment.columns; + } + } + text.reserve(textLength); + + for (size_t row = 0; row < lines.size(); ++row) { + size_t column = 0; + for (const auto& segment : lines[row].segments) { + if (segment.startColumn > column) { + text.append(segment.startColumn - column, ' '); + } + text += segment.text; + column = segment.startColumn + segment.columns; + } + if (row < lines.size() - 1) { + text += '\n'; + } + } + return text; +} + +struct ResolvedStyle { + ImU32 foreground = DEFAULT_FOREGROUND; + bool hasBackground = false; + ImU32 background = DEFAULT_BACKGROUND; + bool bold = false; + bool underline = false; +}; + +ResolvedStyle ResolveStyle(const AnsiDisplayStyle& style) { + ResolvedStyle resolved; + resolved.bold = style.bold; + resolved.underline = style.underline; + if (style.inverse) { + resolved.foreground = + style.hasBackground ? style.background : DEFAULT_BACKGROUND; + resolved.background = + style.hasForeground ? style.foreground : DEFAULT_FOREGROUND; + resolved.hasBackground = true; + } else { + resolved.foreground = + style.hasForeground ? style.foreground : DEFAULT_FOREGROUND; + resolved.background = + style.hasBackground ? style.background : DEFAULT_BACKGROUND; + resolved.hasBackground = style.hasBackground; + } + return resolved; +} +} // namespace + +struct AnsiDisplayModel::Impl { + void Invalidate() const { + cacheValid = false; + plainTextValid = false; + } + + void RebuildCache() const { + if (cacheValid) { + return; + } + + class SnapshotBuilder : public mrc::AnsiDisplayRunVisitor { + public: + explicit SnapshotBuilder(AnsiDisplaySnapshot& snapshot) + : m_snapshot{snapshot} { + m_snapshot.maxColumns = 0; + for (auto& line : m_snapshot.lines) { + line.columns = 0; + line.segments.clear(); + } + } + + void StartRun(size_t row, size_t column, size_t columns, + const mrc::AnsiDisplayStyle& style) override { + if (m_snapshot.lines.size() <= row) { + m_snapshot.lines.resize(row + 1); + } + m_usedLines = std::max(m_usedLines, row + 1); + + auto& line = m_snapshot.lines[row]; + line.columns = std::max(line.columns, column + columns); + m_snapshot.maxColumns = std::max(m_snapshot.maxColumns, line.columns); + + m_segment = &line.segments.emplace_back(); + m_segment->startColumn = column; + m_segment->columns = columns; + m_segment->text.reserve(columns); + m_segment->style = ConvertStyle(style); + } + + void AppendRunText(std::string_view text) override { + m_segment->text += text; + } + + void Finish() { m_snapshot.lines.resize(m_usedLines); } + + private: + AnsiDisplaySnapshot& m_snapshot; + AnsiDisplaySegment* m_segment = nullptr; + size_t m_usedLines = 0; + }; + + // RebuildCache() is called while the model mutex is held. Once published + // below, readers can keep the immutable snapshot alive without the lock. + auto nextSnapshot = GetWritableSnapshot(); + SnapshotBuilder builder{*nextSnapshot}; + state.VisitRuns(builder); + builder.Finish(); + + snapshot = std::move(nextSnapshot); + cacheValid = true; + } + + std::shared_ptr GetWritableSnapshot() const { + // Reuse the published snapshot only when the model is its sole owner. If a + // caller still holds a shared snapshot, allocate a separate object instead. + if (!snapshot || snapshot.use_count() != 1) { + return std::make_shared(); + } + + // Move the unique snapshot out before rewriting it so Impl::snapshot is + // never pointing at the object currently being rebuilt. + return std::move(snapshot); + } + + mutable std::mutex mutex; + mrc::AnsiDisplayState state; + mutable bool cacheValid = false; + mutable std::shared_ptr snapshot; + mutable bool plainTextValid = false; + mutable std::string plainText; +}; + +AnsiDisplayModel::AnsiDisplayModel() : m_impl{std::make_unique()} {} + +AnsiDisplayModel::~AnsiDisplayModel() = default; + +void AnsiDisplayModel::Clear() { + std::scoped_lock lock{m_impl->mutex}; + m_impl->state = mrc::AnsiDisplayState{}; + m_impl->Invalidate(); +} + +void AnsiDisplayModel::Append(std::string_view text) { + std::scoped_lock lock{m_impl->mutex}; + m_impl->state.Apply(text); + m_impl->Invalidate(); +} + +std::shared_ptr AnsiDisplayModel::GetSnapshot() + const { + std::scoped_lock lock{m_impl->mutex}; + m_impl->RebuildCache(); + return m_impl->snapshot; +} + +std::string AnsiDisplayModel::GetPlainText() const { + std::scoped_lock lock{m_impl->mutex}; + m_impl->RebuildCache(); + if (!m_impl->plainTextValid) { + m_impl->plainText = BuildPlainText(m_impl->snapshot->lines); + m_impl->plainTextValid = true; + } + return m_impl->plainText; +} + +void wpi::glass::DisplayAnsiDisplay(AnsiDisplayModel* model, bool autoScroll) { + if (!model) { + return; + } + + auto snapshot = model->GetSnapshot(); + const auto& lines = snapshot->lines; + + float lineHeight = ImGui::GetTextLineHeight(); + float cellWidth = ImGui::CalcTextSize("M").x; + ImVec2 contentSize{ + static_cast(snapshot->maxColumns) * cellWidth, + std::max(static_cast(lines.size()) * lineHeight, lineHeight)}; + ImGui::SetNextWindowContentSize(contentSize); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, + ImGui::ColorConvertU32ToFloat4(DEFAULT_BACKGROUND)); + if (ImGui::BeginChild("##ansi_display", ImVec2{0, 0}, false, + ImGuiWindowFlags_HorizontalScrollbar)) { + float windowHeight = ImGui::GetWindowHeight(); + float scrollX = ImGui::GetScrollX(); + float scrollY = ImGui::GetScrollY(); + float scrollMaxY = ImGui::GetScrollMaxY(); + bool shouldScroll = + autoScroll && (scrollMaxY <= 0.0f || scrollY >= scrollMaxY); + size_t firstLine = + std::min(lines.size(), + static_cast(std::max(0.0f, scrollY / lineHeight))); + size_t lastLine = std::min( + lines.size(), + static_cast( + std::max(0.0f, (scrollY + windowHeight) / lineHeight) + 2)); + + ImVec2 origin = ImGui::GetCursorScreenPos(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + for (size_t row = firstLine; row < lastLine; ++row) { + float y = origin.y + static_cast(row) * lineHeight; + for (const auto& segment : lines[row].segments) { + auto style = ResolveStyle(segment.style); + float x = + origin.x + static_cast(segment.startColumn) * cellWidth; + float width = static_cast(segment.columns) * cellWidth; + ImVec2 pos{x, y}; + if (style.hasBackground) { + drawList->AddRectFilled(pos, ImVec2{x + width, y + lineHeight}, + style.background); + } + const char* textBegin = segment.text.data(); + const char* textEnd = textBegin + segment.text.size(); + drawList->AddText(pos, style.foreground, textBegin, textEnd); + if (style.bold) { + drawList->AddText(ImVec2{x + 1.0f, y}, style.foreground, textBegin, + textEnd); + } + if (style.underline) { + float underlineY = y + lineHeight - 1.0f; + drawList->AddLine(ImVec2{x, underlineY}, + ImVec2{x + width, underlineY}, style.foreground); + } + } + } + + ImGui::Dummy( + ImVec2{std::max(contentSize.x, ImGui::GetContentRegionAvail().x), + contentSize.y}); + ImGui::SetScrollX(scrollX); + if (shouldScroll) { + ImGui::SetScrollHereY(1.0f); + } else { + ImGui::SetScrollY(scrollY); + } + } + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void AnsiDisplayView::Display() { + DisplayAnsiDisplay(m_model); +} + +void AnsiDisplayView::Settings() { + if (ImGui::Selectable("Clear")) { + m_model->Clear(); + } + auto snapshot = m_model->GetSnapshot(); + bool hasText = !snapshot->lines.empty(); + if (ImGui::Selectable("Copy to Clipboard", false, + hasText ? 0 : ImGuiSelectableFlags_Disabled)) { + std::string text = m_model->GetPlainText(); + ImGui::SetClipboardText(text.c_str()); + } +} + +bool AnsiDisplayView::HasSettings() { + return true; +} diff --git a/glass/src/lib/native/include/wpi/glass/other/AnsiDisplay.hpp b/glass/src/lib/native/include/wpi/glass/other/AnsiDisplay.hpp new file mode 100644 index 0000000000..e7f2c14c3c --- /dev/null +++ b/glass/src/lib/native/include/wpi/glass/other/AnsiDisplay.hpp @@ -0,0 +1,165 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "wpi/glass/Model.hpp" +#include "wpi/glass/View.hpp" + +namespace wpi::glass { + +/** + * ANSI display text style. + */ +struct AnsiDisplayStyle { + bool hasForeground = false; + ImU32 foreground = 0; + bool hasBackground = false; + ImU32 background = 0; + bool bold = false; + bool italic = false; + bool underline = false; + bool inverse = false; + + bool operator==(const AnsiDisplayStyle& other) const = default; +}; + +/** + * Rendered run of same-styled ANSI display cells. + */ +struct AnsiDisplaySegment { + size_t startColumn = 0; + size_t columns = 0; + std::string text; + AnsiDisplayStyle style; +}; + +/** + * Rendered ANSI display line. + */ +struct AnsiDisplayLine { + size_t columns = 0; + std::vector segments; +}; + +/** + * Rendered ANSI display snapshot. + */ +struct AnsiDisplaySnapshot { + std::vector lines; + size_t maxColumns = 0; +}; + +/** + * Model for an ANSI console-style display. + */ +class AnsiDisplayModel : public Model { + public: + /** + * Constructor. + */ + AnsiDisplayModel(); + + /** + * Destructor. + */ + ~AnsiDisplayModel() override; + + /** + * Updates the model. + */ + void Update() override {} + + /** + * Returns whether the display exists. + * + * @return True + */ + bool Exists() override { return true; } + + /** + * Clears the display and resets parser state. + */ + void Clear(); + + /** + * Appends ANSI text to the display. + * + * @param text ANSI text + */ + void Append(std::string_view text); + + /** + * Gets an immutable render snapshot of the display. + * + * The returned snapshot remains valid after later model updates as long as + * the shared pointer is retained. + * + * @return Shared rendered display snapshot + */ + std::shared_ptr GetSnapshot() const; + + /** + * Gets the display as plain text. + * + * @return Plain text display contents + */ + std::string GetPlainText() const; + + private: + struct Impl; + std::unique_ptr m_impl; +}; + +/** + * Displays an ANSI console-style display. + * + * @param model Display model + * @param autoScroll True to scroll to the bottom as new lines arrive instead of + * preserving the current scroll offsets. + */ +void DisplayAnsiDisplay(AnsiDisplayModel* model, bool autoScroll = false); + +/** + * ANSI display view with display settings. + */ +class AnsiDisplayView : public View { + public: + /** + * Constructor. + * + * @param model Display model + */ + explicit AnsiDisplayView(AnsiDisplayModel* model) : m_model{model} {} + + /** + * Displays the view contents. + */ + void Display() override; + + /** + * Displays view settings. + */ + void Settings() override; + + /** + * Returns whether this view has settings. + * + * @return True + */ + bool HasSettings() override; + + private: + AnsiDisplayModel* m_model; +}; + +} // namespace wpi::glass diff --git a/glass/src/lib/native/thirdparty/mrccomm/include/mrc/AnsiDisplayState.h b/glass/src/lib/native/thirdparty/mrccomm/include/mrc/AnsiDisplayState.h new file mode 100644 index 0000000000..57d24b933c --- /dev/null +++ b/glass/src/lib/native/thirdparty/mrccomm/include/mrc/AnsiDisplayState.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include +#include + +namespace mrc { + +struct AnsiDisplayColor { + enum class Kind { + Default, + Basic, + Indexed256, + Rgb, + }; + + Kind ColorKind = Kind::Default; + int Index = 0; + bool Bright = false; + int Red = 0; + int Green = 0; + int Blue = 0; + + bool operator==(const AnsiDisplayColor& Other) const = default; +}; + +struct AnsiDisplayStyle { + AnsiDisplayColor Foreground; + AnsiDisplayColor Background; + bool Bold = false; + bool Italic = false; + bool Underline = false; + bool Inverse = false; + + bool operator==(const AnsiDisplayStyle& Other) const = default; +}; + +/** + * Visitor for renderer-neutral ANSI display runs. + */ +class AnsiDisplayRunVisitor { + public: + /** + * Destructor. + */ + virtual ~AnsiDisplayRunVisitor() = default; + + /** + * Starts a contiguous run of same-styled display cells. + * + * @param Row Row index + * @param Column Starting column index + * @param Columns Number of display columns + * @param Style Run style + */ + virtual void StartRun(size_t Row, size_t Column, size_t Columns, + const AnsiDisplayStyle& Style) = 0; + + /** + * Appends one display cell's text to the current run. + * + * @param Text Cell text + */ + virtual void AppendRunText(std::string_view Text) = 0; +}; + +class AnsiDisplayState { + public: + static constexpr std::string_view ClearScreenAnsi = "\x1b[2J\x1b[H"; + + AnsiDisplayState(); + ~AnsiDisplayState(); + + AnsiDisplayState(const AnsiDisplayState&) = delete; + AnsiDisplayState& operator=(const AnsiDisplayState&) = delete; + + AnsiDisplayState(AnsiDisplayState&&) noexcept; + AnsiDisplayState& operator=(AnsiDisplayState&&) noexcept; + + void Apply(std::string_view Text); + + [[nodiscard]] std::string BuildSnapshot() const; + + /** + * Visits renderer-neutral styled runs. + * + * @param Visitor Run visitor + */ + void VisitRuns(AnsiDisplayRunVisitor& Visitor) const; + + private: + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace mrc diff --git a/glass/src/lib/native/thirdparty/mrccomm/src/AnsiDisplayState.cpp b/glass/src/lib/native/thirdparty/mrccomm/src/AnsiDisplayState.cpp new file mode 100644 index 0000000000..7f67d9d0f0 --- /dev/null +++ b/glass/src/lib/native/thirdparty/mrccomm/src/AnsiDisplayState.cpp @@ -0,0 +1,794 @@ +#include "mrc/AnsiDisplayState.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace mrc { +namespace { +static constexpr size_t MaxDisplayRows = 2000; +static constexpr size_t MaxDisplayColumns = 4096; +static constexpr size_t MaxDisplayRowIndex = MaxDisplayRows - 1; +static constexpr size_t MaxDisplayColumnIndex = MaxDisplayColumns - 1; +static constexpr size_t DisplayTabWidth = 8; +static constexpr std::string_view SgrResetAnsi = "\x1b[0m"; + +AnsiDisplayColor MakeBasicColor(int Index, bool Bright) { + AnsiDisplayColor Color; + Color.ColorKind = AnsiDisplayColor::Kind::Basic; + Color.Index = Index; + Color.Bright = Bright; + return Color; +} + +AnsiDisplayColor MakeIndexed256Color(int Index) { + AnsiDisplayColor Color; + Color.ColorKind = AnsiDisplayColor::Kind::Indexed256; + Color.Index = Index; + return Color; +} + +AnsiDisplayColor MakeRgbColor(int Red, int Green, int Blue) { + AnsiDisplayColor Color; + Color.ColorKind = AnsiDisplayColor::Kind::Rgb; + Color.Red = Red; + Color.Green = Green; + Color.Blue = Blue; + return Color; +} + +bool IsDefault(const AnsiDisplayColor& Color) { + return Color.ColorKind == AnsiDisplayColor::Kind::Default; +} + +bool IsDefault(const AnsiDisplayStyle& Style) { + return IsDefault(Style.Foreground) && IsDefault(Style.Background) && + !Style.Bold && !Style.Italic && !Style.Underline && !Style.Inverse; +} + +std::string ToAnsiParameter(const AnsiDisplayColor& Color, bool Foreground) { + switch (Color.ColorKind) { + case AnsiDisplayColor::Kind::Default: + return {}; + case AnsiDisplayColor::Kind::Basic: + return std::to_string((Foreground ? (Color.Bright ? 90 : 30) + : (Color.Bright ? 100 : 40)) + + Color.Index); + case AnsiDisplayColor::Kind::Indexed256: + return std::string{Foreground ? "38;5;" : "48;5;"} + + std::to_string(Color.Index); + case AnsiDisplayColor::Kind::Rgb: + return std::string{Foreground ? "38;2;" : "48;2;"} + + std::to_string(Color.Red) + ";" + + std::to_string(Color.Green) + ";" + + std::to_string(Color.Blue); + } + + return {}; +} + +std::string ToAnsi(const AnsiDisplayStyle& Style) { + std::string Output; + auto AppendParam = [&Output](std::string_view Param) { + if (Param.empty()) { + return; + } + if (Output.empty()) { + Output = "\x1b["; + } else { + Output += ';'; + } + Output += Param; + }; + + if (Style.Bold) { + AppendParam("1"); + } + if (Style.Italic) { + AppendParam("3"); + } + if (Style.Underline) { + AppendParam("4"); + } + if (Style.Inverse) { + AppendParam("7"); + } + AppendParam(ToAnsiParameter(Style.Foreground, true)); + AppendParam(ToAnsiParameter(Style.Background, false)); + if (Output.empty()) { + return {}; + } + + Output += 'm'; + return Output; +} + +struct DisplayCell { + std::string Text{" "}; + AnsiDisplayStyle Style; + + bool IsDefaultBlank() const { return Text == " " && IsDefault(Style); } +}; + +struct DisplayLine { + std::map Cells; +}; + +static void AppendCursorMove(std::string& Output, size_t Row, size_t Column) { + Output += "\x1b["; + Output += std::to_string(Row + 1); + Output += ';'; + Output += std::to_string(Column + 1); + Output += 'H'; +} + +static bool IsUtf8ContinuationByte(unsigned char Ch) { + return (Ch & 0xc0) == 0x80; +} + +static bool IsCsiParameterOrIntermediateByte(char Ch) { + return Ch >= 0x20 && Ch <= 0x3f; +} + +static size_t GetUtf8ExpectedLength(unsigned char Lead) { + if (Lead >= 0xc2 && Lead <= 0xdf) { + return 2; + } else if (Lead >= 0xe0 && Lead <= 0xef) { + return 3; + } else if (Lead >= 0xf0 && Lead <= 0xf4) { + return 4; + } + + return 0; +} + +struct Utf8SequenceInfo { + size_t Length = 0; + bool Incomplete = false; +}; + +static Utf8SequenceInfo GetUtf8SequenceInfo(std::string_view Text, + size_t Index) { + unsigned char Lead = static_cast(Text[Index]); + size_t Length = GetUtf8ExpectedLength(Lead); + if (Length == 0) { + return {}; + } + + for (size_t Offset = 1; Offset < Length; Offset++) { + if (Index + Offset >= Text.size()) { + return {.Incomplete = true}; + } + if (!IsUtf8ContinuationByte( + static_cast(Text[Index + Offset]))) { + return {}; + } + } + + return {.Length = Length}; +} +} // namespace + +struct AnsiDisplayState::Impl { + enum class EscapeState { + Normal, + Escape, + Csi, + Osc, + OscEscape, + }; + + void Apply(std::string_view Text) { + size_t I = ConsumePendingUtf8(Text); + for (; I < Text.size();) { + if (State == EscapeState::Normal) { + unsigned char Ch = static_cast(Text[I]); + if (Ch >= 0x80) { + auto Sequence = GetUtf8SequenceInfo(Text, I); + if (Sequence.Length != 0) { + WriteCharacter(Text.substr(I, Sequence.Length)); + I += Sequence.Length; + continue; + } + if (Sequence.Incomplete) { + PendingUtf8.assign(Text.data() + I, Text.size() - I); + break; + } + } + } + + ProcessCharacter(Text[I]); + I++; + } + } + + void ClearScreenAndHome() { + ClearScreen(); + CursorRow = 0; + CursorColumn = 0; + } + + void ClearScreen() { + Lines.clear(); + Lines.resize(1); + } + + std::string BuildSnapshot() const { + std::string Output{AnsiDisplayState::ClearScreenAnsi}; + Output += SgrResetAnsi; + AnsiDisplayStyle EmittedStyle; + + for (size_t Row = 0; Row < Lines.size(); Row++) { + const auto& Cells = Lines[Row].Cells; + auto It = Cells.begin(); + while (It != Cells.end()) { + const size_t StartColumn = It->first; + const auto& Style = It->second.Style; + AppendCursorMove(Output, Row, StartColumn); + if (Style != EmittedStyle) { + Output += SgrResetAnsi; + Output += ToAnsi(Style); + EmittedStyle = Style; + } + + size_t NextColumn = StartColumn; + while (It != Cells.end() && It->first == NextColumn && + It->second.Style == Style) { + Output += It->second.Text; + ++It; + ++NextColumn; + } + } + } + + Output += SgrResetAnsi; + AppendCursorMove(Output, SavedCursorRow, SavedCursorColumn); + Output += "\x1b[s"; + AppendCursorMove(Output, CursorRow, CursorColumn); + Output += ToAnsi(CurrentStyle); + return Output; + } + + void VisitRuns(AnsiDisplayRunVisitor& Visitor) const { + for (size_t Row = 0; Row < Lines.size(); Row++) { + const auto& Cells = Lines[Row].Cells; + auto It = Cells.begin(); + while (It != Cells.end()) { + const size_t StartColumn = It->first; + const auto& Style = It->second.Style; + auto EndIt = It; + + size_t Columns = 0; + size_t NextColumn = StartColumn; + while (EndIt != Cells.end() && EndIt->first == NextColumn && + EndIt->second.Style == Style) { + ++Columns; + ++EndIt; + ++NextColumn; + } + + Visitor.StartRun(Row, StartColumn, Columns, Style); + while (It != EndIt) { + Visitor.AppendRunText(It->second.Text); + ++It; + } + } + } + } + + private: + size_t ConsumePendingUtf8(std::string_view Text) { + if (PendingUtf8.empty()) { + return 0; + } + + size_t I = 0; + size_t Length = GetUtf8ExpectedLength( + static_cast(PendingUtf8.front())); + while (I < Text.size()) { + unsigned char Ch = static_cast(Text[I]); + if (!IsUtf8ContinuationByte(Ch)) { + FlushPendingUtf8(); + return I; + } + + PendingUtf8 += Text[I]; + I++; + if (PendingUtf8.size() == Length) { + WriteCharacter(PendingUtf8); + PendingUtf8.clear(); + return I; + } + } + + return I; + } + + void FlushPendingUtf8() { + for (char Ch : PendingUtf8) { + WriteCharacter(std::string_view{&Ch, 1}); + } + PendingUtf8.clear(); + } + + void ProcessCharacter(char Ch) { + switch (State) { + case EscapeState::Normal: + ProcessNormalCharacter(Ch); + break; + case EscapeState::Escape: + ProcessEscapeCharacter(Ch); + break; + case EscapeState::Csi: + ProcessCsiCharacter(Ch); + break; + case EscapeState::Osc: + ProcessOscCharacter(Ch); + break; + case EscapeState::OscEscape: + State = EscapeState::Normal; + break; + } + } + + void ProcessNormalCharacter(char Ch) { + switch (Ch) { + case '\x1b': + State = EscapeState::Escape; + EscapeBuilder.clear(); + break; + case '\r': + SetCursorColumn(0); + break; + case '\n': + MoveToNextLine(); + break; + case '\b': + MoveCursorColumns(-1); + break; + case '\t': + WriteTab(); + break; + default: + if (static_cast(Ch) >= 0x20 && + static_cast(Ch) != 0x7f) { + WriteCharacter(std::string_view{&Ch, 1}); + } + break; + } + } + + void ProcessEscapeCharacter(char Ch) { + switch (Ch) { + case '[': + State = EscapeState::Csi; + EscapeBuilder.clear(); + break; + case ']': + State = EscapeState::Osc; + EscapeBuilder.clear(); + break; + case '7': + SaveCursor(); + State = EscapeState::Normal; + break; + case '8': + RestoreCursor(); + State = EscapeState::Normal; + break; + case 'c': + ClearScreenAndHome(); + CurrentStyle = {}; + State = EscapeState::Normal; + break; + default: + State = EscapeState::Normal; + break; + } + } + + void ProcessCsiCharacter(char Ch) { + if (Ch >= 0x40 && Ch <= 0x7e) { + ProcessCsi(Ch, EscapeBuilder); + ResetEscapeSequence(); + return; + } + + if (!IsCsiParameterOrIntermediateByte(Ch) || + EscapeBuilder.size() >= MaxCsiLength) { + ResetEscapeSequence(); + ProcessNormalCharacter(Ch); + return; + } + + EscapeBuilder += Ch; + } + + void ResetEscapeSequence() { + State = EscapeState::Normal; + EscapeBuilder.clear(); + } + + void ProcessOscCharacter(char Ch) { + if (Ch == '\a') { + State = EscapeState::Normal; + } else if (Ch == '\x1b') { + State = EscapeState::OscEscape; + } + } + + void ProcessCsi(char Command, std::string_view Parameters) { + auto Values = ParseCsiParameters(Parameters); + switch (Command) { + case 'm': + ApplySgr(Values); + break; + case 'J': + EraseDisplay(GetValue(Values, 0, 0)); + break; + case 'K': + EraseLine(GetValue(Values, 0, 0)); + break; + case 'A': + MoveCursorRows(-GetValue(Values, 0, 1)); + break; + case 'B': + MoveCursorRows(GetValue(Values, 0, 1)); + break; + case 'C': + MoveCursorColumns(GetValue(Values, 0, 1)); + break; + case 'D': + MoveCursorColumns(-GetValue(Values, 0, 1)); + break; + case 'E': + MoveCursorRows(GetValue(Values, 0, 1)); + SetCursorColumn(0); + break; + case 'F': + MoveCursorRows(-GetValue(Values, 0, 1)); + SetCursorColumn(0); + break; + case 'G': + SetCursorColumn(GetValue(Values, 0, 1) - 1); + break; + case 'H': + case 'f': + SetCursorPosition(GetValue(Values, 0, 1) - 1, + GetValue(Values, 1, 1) - 1); + break; + case 'd': + SetCursorRow(GetValue(Values, 0, 1) - 1); + break; + case 's': + SaveCursor(); + break; + case 'u': + RestoreCursor(); + break; + default: + break; + } + } + + void ApplySgr(const std::vector& Values) { + if (Values.empty()) { + CurrentStyle = {}; + return; + } + + for (size_t I = 0; I < Values.size(); I++) { + int Value = Values[I]; + switch (Value) { + case 0: + CurrentStyle = {}; + break; + case 1: + CurrentStyle.Bold = true; + break; + case 3: + CurrentStyle.Italic = true; + break; + case 4: + CurrentStyle.Underline = true; + break; + case 7: + CurrentStyle.Inverse = true; + break; + case 22: + CurrentStyle.Bold = false; + break; + case 23: + CurrentStyle.Italic = false; + break; + case 24: + CurrentStyle.Underline = false; + break; + case 27: + CurrentStyle.Inverse = false; + break; + case 39: + CurrentStyle.Foreground = {}; + break; + case 49: + CurrentStyle.Background = {}; + break; + default: + ApplyColorSgr(Values, &I); + break; + } + } + } + + void ApplyColorSgr(const std::vector& Values, size_t* Index) { + int Value = Values[*Index]; + if (Value >= 30 && Value <= 37) { + CurrentStyle.Foreground = MakeBasicColor(Value - 30, false); + } else if (Value >= 40 && Value <= 47) { + CurrentStyle.Background = MakeBasicColor(Value - 40, false); + } else if (Value >= 90 && Value <= 97) { + CurrentStyle.Foreground = MakeBasicColor(Value - 90, true); + } else if (Value >= 100 && Value <= 107) { + CurrentStyle.Background = MakeBasicColor(Value - 100, true); + } else if (Value == 38 || Value == 48) { + AnsiDisplayColor Color; + if (TryReadExtendedColor(Values, Index, &Color)) { + if (Value == 38) { + CurrentStyle.Foreground = Color; + } else { + CurrentStyle.Background = Color; + } + } + } + } + + static bool TryReadExtendedColor(const std::vector& Values, + size_t* Index, AnsiDisplayColor* Output) { + if (*Index + 2 >= Values.size()) { + return false; + } + + int Mode = Values[*Index + 1]; + if (Mode == 5) { + *Output = MakeIndexed256Color(Values[*Index + 2]); + *Index += 2; + return true; + } + + if (Mode == 2 && *Index + 4 < Values.size()) { + *Output = MakeRgbColor(Values[*Index + 2], Values[*Index + 3], + Values[*Index + 4]); + *Index += 4; + return true; + } + + return false; + } + + void EraseDisplay(int Mode) { + ClampCursor(); + switch (Mode) { + case 0: + EraseLine(0); + for (size_t Row = CursorRow + 1; Row < Lines.size(); Row++) { + Lines[Row].Cells.clear(); + } + break; + case 1: + for (size_t Row = 0; Row < CursorRow && Row < Lines.size(); + Row++) { + Lines[Row].Cells.clear(); + } + EraseLine(1); + break; + case 2: + case 3: + ClearScreen(); + break; + default: + break; + } + } + + void EraseLine(int Mode) { + ClampCursor(); + EnsureLine(CursorRow); + auto& Cells = Lines[CursorRow].Cells; + switch (Mode) { + case 0: + Cells.erase(Cells.lower_bound(CursorColumn), Cells.end()); + break; + case 1: + for (size_t Column = 0; Column <= CursorColumn; Column++) { + SetCell(CursorRow, Column, {" ", CurrentStyle}); + } + break; + case 2: + Cells.clear(); + break; + default: + break; + } + } + + void WriteCharacter(std::string_view Text) { + ClampCursor(); + EnsureLine(CursorRow); + SetCell(CursorRow, CursorColumn, + {std::string{Text.data(), Text.size()}, CurrentStyle}); + SetCursorColumn(CursorColumn + 1); + } + + void WriteTab() { + size_t TargetColumn = + ((CursorColumn / DisplayTabWidth) + 1) * DisplayTabWidth; + TargetColumn = std::min(TargetColumn, MaxDisplayColumnIndex); + do { + WriteCharacter(" "); + } while (CursorColumn < TargetColumn); + } + + void SetCell(size_t Row, size_t Column, DisplayCell Cell) { + if (Row > MaxDisplayRowIndex || Column > MaxDisplayColumnIndex) { + return; + } + + EnsureLine(Row); + auto& Cells = Lines[Row].Cells; + if (Cell.IsDefaultBlank()) { + Cells.erase(Column); + } else { + Cells[Column] = std::move(Cell); + } + } + + void MoveToNextLine() { + if (CursorRow >= MaxDisplayRowIndex) { + if (!Lines.empty()) { + Lines.erase(Lines.begin()); + } + Lines.emplace_back(); + CursorRow = MaxDisplayRowIndex; + SavedCursorRow = SavedCursorRow == 0 ? 0 : SavedCursorRow - 1; + } else { + SetCursorRow(CursorRow + 1); + } + SetCursorColumn(0); + } + + void EnsureLine(size_t Row) { + Row = std::min(Row, MaxDisplayRowIndex); + while (Lines.size() <= Row) { + Lines.emplace_back(); + } + } + + void SaveCursor() { + ClampCursor(); + SavedCursorRow = CursorRow; + SavedCursorColumn = CursorColumn; + } + + void RestoreCursor() { + SetCursorPosition(SavedCursorRow, SavedCursorColumn); + } + + void MoveCursorRows(int Delta) { + SetCursorRow(AddClamped(CursorRow, Delta, MaxDisplayRowIndex)); + } + + void MoveCursorColumns(int Delta) { + SetCursorColumn(AddClamped(CursorColumn, Delta, MaxDisplayColumnIndex)); + } + + void SetCursorPosition(size_t Row, size_t Column) { + CursorRow = std::min(Row, MaxDisplayRowIndex); + CursorColumn = std::min(Column, MaxDisplayColumnIndex); + EnsureLine(CursorRow); + } + + void SetCursorRow(size_t Row) { + CursorRow = std::min(Row, MaxDisplayRowIndex); + EnsureLine(CursorRow); + } + + void SetCursorColumn(size_t Column) { + CursorColumn = std::min(Column, MaxDisplayColumnIndex); + } + + void ClampCursor() { + CursorRow = std::min(CursorRow, MaxDisplayRowIndex); + CursorColumn = std::min(CursorColumn, MaxDisplayColumnIndex); + } + + static size_t AddClamped(size_t Value, int Delta, size_t Max) { + if (Delta < 0) { + size_t Amount = static_cast(-Delta); + return Amount > Value ? 0 : Value - Amount; + } + + size_t Amount = static_cast(Delta); + if (Value > Max - std::min(Amount, Max)) { + return Max; + } + return std::min(Value + Amount, Max); + } + + static std::vector ParseCsiParameters(std::string_view Parameters) { + while (!Parameters.empty() && + (Parameters.front() == '?' || Parameters.front() == '>' || + Parameters.front() == '!')) { + Parameters.remove_prefix(1); + } + + std::vector Values; + Values.reserve(std::min((Parameters.size() / 2) + 1, 8)); + int CurrentValue = 0; + bool HasDigits = false; + for (char Ch : Parameters) { + if (Ch >= '0' && Ch <= '9') { + HasDigits = true; + if (CurrentValue < 1000000) { + CurrentValue = (CurrentValue * 10) + (Ch - '0'); + } + } else { + Values.emplace_back(HasDigits ? CurrentValue : 0); + CurrentValue = 0; + HasDigits = false; + } + } + + if (HasDigits || !Parameters.empty()) { + Values.emplace_back(HasDigits ? CurrentValue : 0); + } + + return Values; + } + + static int GetValue(const std::vector& Values, size_t Index, + int Fallback) { + if (Index >= Values.size() || Values[Index] == 0) { + return Fallback; + } + return Values[Index]; + } + + static constexpr size_t MaxCsiLength = 128; + + std::vector Lines{1}; + AnsiDisplayStyle CurrentStyle; + size_t CursorRow = 0; + size_t CursorColumn = 0; + size_t SavedCursorRow = 0; + size_t SavedCursorColumn = 0; + EscapeState State = EscapeState::Normal; + std::string EscapeBuilder; + std::string PendingUtf8; +}; + +AnsiDisplayState::AnsiDisplayState() : m_impl{std::make_unique()} {} + +AnsiDisplayState::~AnsiDisplayState() = default; + +AnsiDisplayState::AnsiDisplayState(AnsiDisplayState&&) noexcept = default; + +AnsiDisplayState& AnsiDisplayState::operator=(AnsiDisplayState&&) noexcept = + default; + +void AnsiDisplayState::Apply(std::string_view Text) { + m_impl->Apply(Text); +} + +std::string AnsiDisplayState::BuildSnapshot() const { + return m_impl->BuildSnapshot(); +} + +void AnsiDisplayState::VisitRuns(AnsiDisplayRunVisitor& Visitor) const { + m_impl->VisitRuns(Visitor); +} + +} // namespace mrc diff --git a/glass/src/test/native/cpp/AnsiDisplayTest.cpp b/glass/src/test/native/cpp/AnsiDisplayTest.cpp new file mode 100644 index 0000000000..8ccbc6bd3c --- /dev/null +++ b/glass/src/test/native/cpp/AnsiDisplayTest.cpp @@ -0,0 +1,220 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +#include "wpi/glass/other/AnsiDisplay.hpp" + +#include +#include +#include +#include + +#include + +#include "mrc/AnsiDisplayState.h" + +namespace { +struct TestRun { + size_t row = 0; + size_t column = 0; + size_t columns = 0; + std::string text; + mrc::AnsiDisplayStyle style; +}; + +class TestRunVisitor : public mrc::AnsiDisplayRunVisitor { + public: + void StartRun(size_t row, size_t column, size_t columns, + const mrc::AnsiDisplayStyle& style) override { + auto& run = runs.emplace_back(); + run.row = row; + run.column = column; + run.columns = columns; + run.style = style; + } + + void AppendRunText(std::string_view text) override { + runs.back().text += text; + } + + std::vector runs; +}; +} // namespace + +TEST_CASE("AnsiDisplayTest SnapshotsPlainText", "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("Hello\nWorld"); + + CHECK(model.GetPlainText() == "Hello\nWorld"); +} + +TEST_CASE("AnsiDisplayTest ClearAnsiRemovesOldCells", "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("old"); + model.Append("\x1b[2J\x1b[Hnew"); + + CHECK(model.GetPlainText() == "new"); +} + +TEST_CASE("AnsiDisplayTest SnapshotTracksMaxColumns", "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("short\n123456"); + + auto snapshot = model.GetSnapshot(); + CHECK(snapshot->lines.size() == 2u); + CHECK(snapshot->maxColumns == 6u); + + model.Append("\x1b[2J\x1b[Hok"); + + auto nextSnapshot = model.GetSnapshot(); + auto* reusableSnapshotAddress = nextSnapshot.get(); + CHECK(nextSnapshot.get() != snapshot.get()); + CHECK(nextSnapshot->lines.size() == 1u); + CHECK(nextSnapshot->maxColumns == 2u); + CHECK(snapshot->lines.size() == 2u); + CHECK(snapshot->maxColumns == 6u); + + nextSnapshot.reset(); + snapshot.reset(); + model.Append("\x1b[2J\x1b[Habc"); + + auto reusedSnapshot = model.GetSnapshot(); + CHECK(reusedSnapshot.get() == reusableSnapshotAddress); + CHECK(reusedSnapshot->lines.size() == 1u); + CHECK(reusedSnapshot->maxColumns == 3u); +} + +TEST_CASE("AnsiDisplayTest EraseDisplayDoesNotMoveCursor", "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("abc\x1b[2Jd"); + + CHECK(model.GetPlainText() == " d"); +} + +TEST_CASE("AnsiDisplayTest CursorRewritesPreserveVisibleCells", + "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("abc\rZ"); + + CHECK(model.GetPlainText() == "Zbc"); +} + +TEST_CASE("AnsiDisplayTest Utf8TextAdvancesOneCell", "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append( + "\xc3\xa9" + "A\rB"); + + CHECK(model.GetPlainText() == "BA"); +} + +TEST_CASE("AnsiDisplayTest SgrStyledRunsAreSegmented", "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("\x1b[1;31;44mX\x1b[0mY"); + + auto snapshot = model.GetSnapshot(); + const auto& lines = snapshot->lines; + REQUIRE(lines.size() == 1u); + REQUIRE(lines[0].segments.size() == 2u); + CHECK(lines[0].segments[0].startColumn == 0u); + CHECK(lines[0].segments[0].columns == 1u); + CHECK(lines[0].segments[0].text == "X"); + CHECK(lines[0].segments[0].style.bold); + CHECK(lines[0].segments[0].style.hasForeground); + CHECK(lines[0].segments[0].style.foreground == + IM_COL32(0x80, 0x00, 0x00, 255)); + CHECK(lines[0].segments[0].style.hasBackground); + CHECK(lines[0].segments[0].style.background == + IM_COL32(0x00, 0x00, 0x80, 255)); + CHECK(lines[0].segments[1].text == "Y"); + CHECK_FALSE(lines[0].segments[1].style.hasForeground); + CHECK_FALSE(lines[0].segments[1].style.hasBackground); +} + +TEST_CASE("AnsiDisplayTest SgrColorResetRestoresDefaultStyle", + "[ansi-display]") { + wpi::glass::AnsiDisplayModel model; + + model.Append("A\x1b[31;44mB\x1b[39;49mC"); + + auto snapshot = model.GetSnapshot(); + const auto& lines = snapshot->lines; + REQUIRE(lines.size() == 1u); + REQUIRE(lines[0].segments.size() == 3u); + CHECK(lines[0].segments[0].text == "A"); + CHECK_FALSE(lines[0].segments[0].style.hasForeground); + CHECK_FALSE(lines[0].segments[0].style.hasBackground); + CHECK(lines[0].segments[1].text == "B"); + CHECK(lines[0].segments[1].style.hasForeground); + CHECK(lines[0].segments[1].style.hasBackground); + CHECK(lines[0].segments[2].text == "C"); + CHECK(lines[0].segments[0].style == lines[0].segments[2].style); +} + +TEST_CASE("AnsiDisplayStateTest VisitRunsExposesRendererNeutralStyledCells", + "[ansi-display-state]") { + mrc::AnsiDisplayState state; + + state.Apply("\x1b[1;38;5;208;48;2;1;2;3mX\x1b[0mY"); + + TestRunVisitor visitor; + state.VisitRuns(visitor); + REQUIRE(visitor.runs.size() == 2u); + CHECK(visitor.runs[0].row == 0u); + CHECK(visitor.runs[0].column == 0u); + CHECK(visitor.runs[0].columns == 1u); + CHECK(visitor.runs[0].text == "X"); + CHECK(visitor.runs[0].style.Bold); + CHECK(visitor.runs[0].style.Foreground.ColorKind == + mrc::AnsiDisplayColor::Kind::Indexed256); + CHECK(visitor.runs[0].style.Foreground.Index == 208); + CHECK(visitor.runs[0].style.Background.ColorKind == + mrc::AnsiDisplayColor::Kind::Rgb); + CHECK(visitor.runs[0].style.Background.Red == 1); + CHECK(visitor.runs[0].style.Background.Green == 2); + CHECK(visitor.runs[0].style.Background.Blue == 3); + + CHECK(visitor.runs[1].column == 1u); + CHECK(visitor.runs[1].text == "Y"); + CHECK(visitor.runs[1].style.Foreground.ColorKind == + mrc::AnsiDisplayColor::Kind::Default); + CHECK(visitor.runs[1].style.Background.ColorKind == + mrc::AnsiDisplayColor::Kind::Default); +} + +TEST_CASE("AnsiDisplayStateTest SnapshotReplayResetsReceiverStyle", + "[ansi-display-state]") { + mrc::AnsiDisplayState source; + source.Apply("plain"); + + mrc::AnsiDisplayState receiver; + receiver.Apply("\x1b[1;31m"); + receiver.Apply(source.BuildSnapshot()); + + TestRunVisitor visitor; + receiver.VisitRuns(visitor); + REQUIRE(visitor.runs.size() == 1u); + CHECK(visitor.runs[0].text == "plain"); + CHECK(visitor.runs[0].style == mrc::AnsiDisplayStyle{}); +} + +TEST_CASE("AnsiDisplayStateTest Utf8SplitAcrossApplyCallsWritesOneCell", + "[ansi-display-state]") { + mrc::AnsiDisplayState state; + + state.Apply("\xc3"); + state.Apply("\xa9\rX"); + + TestRunVisitor visitor; + state.VisitRuns(visitor); + REQUIRE(visitor.runs.size() == 1u); + CHECK(visitor.runs[0].columns == 1u); + CHECK(visitor.runs[0].text == "X"); +} diff --git a/glass/src/test/native/cpp/main.cpp b/glass/src/test/native/cpp/main.cpp new file mode 100644 index 0000000000..a2c05f2d23 --- /dev/null +++ b/glass/src/test/native/cpp/main.cpp @@ -0,0 +1,9 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +#include + +int main(int argc, char** argv) { + return Catch::Session().run(argc, argv); +} diff --git a/hal/src/main/native/include/wpi/hal/simulation/MockHooks.h b/hal/src/main/native/include/wpi/hal/simulation/MockHooks.h index 3c105e3681..a864d3068f 100644 --- a/hal/src/main/native/include/wpi/hal/simulation/MockHooks.h +++ b/hal/src/main/native/include/wpi/hal/simulation/MockHooks.h @@ -36,6 +36,10 @@ void HALSIM_SetSendError(HALSIM_SendErrorHandler handler); typedef int32_t (*HALSIM_SendConsoleLineHandler)(const struct WPI_String* line); void HALSIM_SetSendConsoleLine(HALSIM_SendConsoleLineHandler handler); +typedef int32_t (*HALSIM_WriteDisplayAnsiHandler)( + const struct WPI_String* data); +void HALSIM_SetWriteDisplayAnsi(HALSIM_WriteDisplayAnsiHandler handler); + typedef void (*HALSIM_SimPeriodicCallback)(void* param); int32_t HALSIM_RegisterSimPeriodicBeforeCallback( HALSIM_SimPeriodicCallback callback, void* param); diff --git a/hal/src/main/native/sim/DriverStation.cpp b/hal/src/main/native/sim/DriverStation.cpp index 5b332d8085..c2ed979fdc 100644 --- a/hal/src/main/native/sim/DriverStation.cpp +++ b/hal/src/main/native/sim/DriverStation.cpp @@ -203,6 +203,8 @@ MrcLibDsSimImpl::MrcLibDsSimImpl() { static std::atomic sendErrorHandler{nullptr}; static std::atomic sendConsoleLineHandler{ nullptr}; +static std::atomic writeDisplayAnsiHandler{ + nullptr}; extern "C" { @@ -213,6 +215,10 @@ void HALSIM_SetSendError(HALSIM_SendErrorHandler handler) { void HALSIM_SetSendConsoleLine(HALSIM_SendConsoleLineHandler handler) { sendConsoleLineHandler.store(handler); } + +void HALSIM_SetWriteDisplayAnsi(HALSIM_WriteDisplayAnsiHandler handler) { + writeDisplayAnsiHandler.store(handler); +} } // extern "C" int32_t MrcLibDsSimImpl::BackendPrintFunctionImpl( @@ -509,6 +515,10 @@ int32_t MrcLibDsSimImpl::getSystemTimeValid(bool* systemTimeValid) { } int32_t MrcLibDsSimImpl::writeDisplayAnsi(const struct WPI_String* line) { + auto handler = writeDisplayAnsiHandler.load(); + if (handler) { + return handler(line); + } return 0; } diff --git a/hal/src/main/native/systemcore/mockdata/MockHooks.cpp b/hal/src/main/native/systemcore/mockdata/MockHooks.cpp index f17d5b87b0..47fb6c0887 100644 --- a/hal/src/main/native/systemcore/mockdata/MockHooks.cpp +++ b/hal/src/main/native/systemcore/mockdata/MockHooks.cpp @@ -40,6 +40,8 @@ void HALSIM_SetSendError(HALSIM_SendErrorHandler handler) {} void HALSIM_SetSendConsoleLine(HALSIM_SendConsoleLineHandler handler) {} +void HALSIM_SetWriteDisplayAnsi(HALSIM_WriteDisplayAnsiHandler handler) {} + int32_t HALSIM_RegisterSimPeriodicBeforeCallback( HALSIM_SimPeriodicCallback callback, void* param) { return 0; diff --git a/hal/src/main/python/semiwrap/simulation/MockHooks_c.yml b/hal/src/main/python/semiwrap/simulation/MockHooks_c.yml index 61cc1f4d63..ab253597f6 100644 --- a/hal/src/main/python/semiwrap/simulation/MockHooks_c.yml +++ b/hal/src/main/python/semiwrap/simulation/MockHooks_c.yml @@ -26,6 +26,8 @@ functions: ignore: true HALSIM_SetSendConsoleLine: ignore: true + HALSIM_SetWriteDisplayAnsi: + ignore: true HALSIM_RegisterSimPeriodicBeforeCallback: param_override: diff --git a/hal/src/test/native/cpp/WriteDisplayAnsiTest.cpp b/hal/src/test/native/cpp/WriteDisplayAnsiTest.cpp new file mode 100644 index 0000000000..76234d45e4 --- /dev/null +++ b/hal/src/test/native/cpp/WriteDisplayAnsiTest.cpp @@ -0,0 +1,32 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +#include + +#include + +#include "wpi/hal/DriverStation.h" +#include "wpi/hal/simulation/MockHooks.h" +#include "wpi/util/string.hpp" + +namespace { +std::string gDisplayAnsi; + +int32_t CaptureDisplayAnsi(const struct WPI_String* data) { + gDisplayAnsi = wpi::util::to_string_view(data); + return 0; +} +} // namespace + +TEST(DriverStationDisplayAnsiTest, WriteDisplayAnsiUsesSimulationHook) { + HALSIM_SetWriteDisplayAnsi(nullptr); + gDisplayAnsi.clear(); + HALSIM_SetWriteDisplayAnsi(CaptureDisplayAnsi); + + auto data = wpi::util::make_string("Robot display"); + HAL_WriteDisplayAnsi(&data); + + HALSIM_SetWriteDisplayAnsi(nullptr); + EXPECT_EQ("Robot display", gDisplayAnsi); +} diff --git a/simulation/halsim_gui/src/main/native/cpp/DriverStationGui.cpp b/simulation/halsim_gui/src/main/native/cpp/DriverStationGui.cpp index ac32fe293e..f4cbba9a66 100644 --- a/simulation/halsim_gui/src/main/native/cpp/DriverStationGui.cpp +++ b/simulation/halsim_gui/src/main/native/cpp/DriverStationGui.cpp @@ -20,6 +20,7 @@ #include "wpi/glass/Context.hpp" #include "wpi/glass/Storage.hpp" +#include "wpi/glass/other/AnsiDisplay.hpp" #include "wpi/glass/other/FMS.hpp" #include "wpi/glass/support/ExtraGuiWidgets.hpp" #include "wpi/glass/support/NameSetting.hpp" @@ -280,6 +281,7 @@ static std::unique_ptr gJoystickSources[HAL_MAX_JOYSTICKS]; // FMS static std::unique_ptr gFMSModel; +static std::unique_ptr gDisplayModel; // Window management std::unique_ptr DriverStationGui::dsManager; @@ -1556,8 +1558,13 @@ void DriverStationGui::GlobalInit() { dsManager->GlobalInit(); gFMSModel = std::make_unique(); + gDisplayModel = std::make_unique(); HALSIM_RegisterOpModeOptionsCallback(UpdateOpModes, nullptr, true); + HALSIM_SetWriteDisplayAnsi([](const struct WPI_String* data) { + gDisplayModel->Append(wpi::util::to_string_view(data)); + return 0; + }); wpi::gui::AddEarlyExecute(DriverStationExecute); @@ -1617,6 +1624,13 @@ void DriverStationGui::GlobalInit() { win->SetFlags(ImGuiWindowFlags_AlwaysAutoResize); win->SetDefaultPos(5, 540); } + if (auto win = dsManager->AddWindow( + "Display", std::make_unique( + gDisplayModel.get()))) { + win->DisableRenamePopup(); + win->SetDefaultPos(250, 20); + win->SetDefaultSize(650, 320); + } if (auto win = dsManager->AddWindow("System Joysticks", DisplaySystemJoysticks)) { win->DisableRenamePopup(); @@ -1632,6 +1646,7 @@ void DriverStationGui::GlobalInit() { storageRoot.SetCustomClear([&storageRoot] { dsManager->EraseWindows(); + gDisplayModel->Clear(); gKeyboardJoysticks.clear(); gRobotJoysticks.clear(); storageRoot.GetChildArray("keyboardJoysticks").clear();