From 95d95c6a4b9653cf29d699b300c00498df4081ca Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 6 Jun 2026 21:54:50 -0700 Subject: [PATCH] fix: own legend label bytes in LegendEntryLabel forwarder (macOS issue #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LegendEntryLabel returned the raw const char* from ImPlot's GetLegendLabel, which points straight into the per-frame Legend.Labels buffer. daslang aliases a const char* return (no copy), so the captured das string pointed into that buffer. The buffer is valid at capture time (post-EndPlot) but the snapshot serializes a frame later, by when the next BeginPlot/SetupFinish has reset and (on macOS) realloc'd it, leaving the string dangling — empty/garbage labels. Win/Linux kept the same allocation and rewrote identical bytes in place, so the alias still read correctly there. Fix in the forwarder (which we own, unlike the generated bindings): thread das::Context*/LineInfoArg* and return context->allocateString(...) so the das string owns its bytes and stays valid regardless of the buffer's later fate. Mirrors the rest of the string-returning API (dasImgui's text_range_string / ImGTB_Slice). Drops the macOS skip in test_multi_axes — the pressure/Y2 legend assertion now runs on all platforms. Full integration suite passes 13/13 headless on macOS. Closes #9 Co-Authored-By: Claude Opus 4.8 --- daslib/implot_boost_v2.das | 6 ++++-- src/dasIMPLOT.main.cpp | 15 +++++++++++---- tests/integration/test_multi_axes.das | 9 +++------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/daslib/implot_boost_v2.das b/daslib/implot_boost_v2.das index 2c6271d..8b08bac 100644 --- a/daslib/implot_boost_v2.das +++ b/daslib/implot_boost_v2.das @@ -80,8 +80,10 @@ let private LEGEND_LABEL_RETRY_FRAMES = 240 // bounded window to resolve a jus def private capture_legend(var state : PlotScopeState; title : string) { // Read the plot's legend into the snapshot AFTER EndPlot, by title — the rect / shown are - // written during EndPlot's legend render, so GetPlot(title) gives current-frame values. Labels - // come from ImPlot as a fresh string per call, so we cache them (rebuild only when the entry set + // written during EndPlot's legend render, so GetPlot(title) gives current-frame values. + // LegendEntryLabel returns a das-heap-owned copy (it allocateString's into our context — see + // the C++ forwarder, issue #9), so the cached label stays valid even after ImPlot resets its + // internal Legend.Labels buffer next frame. We cache labels (rebuild only when the entry set // changes) rather than re-allocating every frame. Rects / shown are cheap scalars, refreshed // every frame. (A relabel with no count change reads stale — exotic for a steady plot.) let n = LegendEntryCount(title) diff --git a/src/dasIMPLOT.main.cpp b/src/dasIMPLOT.main.cpp index 058dbc0..875cfea 100644 --- a/src/dasIMPLOT.main.cpp +++ b/src/dasIMPLOT.main.cpp @@ -150,10 +150,17 @@ int LegendEntryCount(const char* title) { ImPlotPlot* plot = ImPlot::GetPlot(title); return plot ? plot->Items.GetLegendCount() : 0; } -const char* LegendEntryLabel(const char* title, int index) { +// allocateString (das heap), NOT the raw GetLegendLabel pointer: that points into ImPlot's +// per-frame Legend.Labels buffer, which the next BeginPlot/SetupFinish resets and may realloc — a +// raw return would dangle by the time the snapshot serializes a frame later (empty/garbage on +// macOS, issue #9). Owning the bytes here keeps the das string valid; mirrors the rest of the +// string-returning API (dasImgui's text_range_string / ImGTB_Slice). +char* LegendEntryLabel(const char* title, int index, das::Context* context, das::LineInfoArg* at) { ImPlotPlot* plot = ImPlot::GetPlot(title); - if (!plot || index < 0 || index >= plot->Items.GetLegendCount()) return ""; - return plot->Items.GetLegendLabel(index); + if (!plot || index < 0 || index >= plot->Items.GetLegendCount()) + return context->allocateString(nullptr, 0, at); + const char* label = plot->Items.GetLegendLabel(index); + return context->allocateString(label, strlen(label), at); } bool LegendEntryShown(const char* title, int index) { ImPlotPlot* plot = ImPlot::GetPlot(title); @@ -287,7 +294,7 @@ void Module_dasIMPLOT::initMain () { addExtern(*this, lib, "LegendEntryCount", SideEffects::accessExternal, "das::LegendEntryCount")->args({"title"}); addExtern(*this, lib, "LegendEntryLabel", - SideEffects::accessExternal, "das::LegendEntryLabel")->args({"title", "index"}); + SideEffects::accessExternal, "das::LegendEntryLabel")->args({"title", "index", "context", "lineinfo"}); addExtern(*this, lib, "LegendEntryShown", SideEffects::accessExternal, "das::LegendEntryShown")->args({"title", "index"}); addExtern(*this, lib, "LegendEntryHovered", diff --git a/tests/integration/test_multi_axes.das b/tests/integration/test_multi_axes.das index 0b2cb76..bd2d06a 100644 --- a/tests/integration/test_multi_axes.das +++ b/tests/integration/test_multi_axes.das @@ -37,12 +37,9 @@ def test_multi_axes(t : T?) { t |> success(abs(plot_axis_limit(m, s, "y2_max") - 1050.0lf) < 1.0lf, "Y2 (pressure) max captured ~1050") // The Y2-routed series' legend label resolves (the first-frame label fix). Poll, don't // one-shot: ImPlot can resolve the label a few frames after the item first registers, so a - // read on the axis-convergence snapshot races it. Skipped on macOS — the Y2-routed label - // stays empty there (issue #9, to be debugged on an osx box); the rail's drag checks below - // still run on all platforms. - if (get_platform_name() != "darwin") { - t |> success(wait_for_series_shown(s, "pressure", true) != null, "pressure series present in legend (Y2 label captured)") - } + // read on the axis-convergence snapshot races it. Now runs on all platforms — issue #9 (the + // macOS empty/garbage label) is fixed in the LegendEntryLabel forwarder (returns an owned copy). + t |> success(wait_for_series_shown(s, "pressure", true) != null, "pressure series present in legend (Y2 label captured)") // Drag ONLY the Y2 gutter: pressure's range must move while temp (Y1) and X1 stay put — the // independent-axis pan the tutorial teaches, distinct from a whole-plot body pan (moves all).