cleaned up ui added exporter

This commit is contained in:
Martino Ferrari committed 2026-10-01 16:16:17 +02:00
1 parent 8059b90de7
commit ea560ed272
4 files changed
+225 -3

No files matched your search

+152
View File
@@ -16,6 +16,7 @@
#include "acquisition.hpp"
#include "config.hpp"
#include "export.hpp"
#include "model.hpp"
#include "plot_host.hpp"
@@ -26,6 +27,8 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
@@ -255,6 +258,14 @@ std::string format_window(double s) {
return buf;
}
/// A short-lived line in front of the status text: the outcome of an action that the
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires.
void set_notice(App& app, std::string what, int seconds = 4) {
app.notice = std::move(what);
app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND;
}
// --- plot widgets -----------------------------------------------------------
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
@@ -864,6 +875,89 @@ void on_cursors_reset(GtkButton*, gpointer user) {
app->rows->reset_cursors();
}
// --- export -----------------------------------------------------------------
/// The table travels to the async callback. It is collected when the button is clicked,
/// not when the dialog is answered: a live view keeps scrolling while the file chooser
/// is up, so collecting late would write a window the user never saw.
struct ExportContext {
App* app = nullptr;
ExportTable table;
};
void on_export_response(GObject* source, GAsyncResult* result, gpointer user) {
std::unique_ptr<ExportContext> ctx(static_cast<ExportContext*>(user));
GError* err = nullptr;
GFile* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), result, &err);
if (file == nullptr) {
// A cancelled dialog is not a failure and says nothing; anything else does.
if (err != nullptr && !g_error_matches(err, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) {
set_notice(*ctx->app, std::string("export failed: ") + err->message);
}
if (err != nullptr) g_error_free(err);
return;
}
char* raw = g_file_get_path(file);
const std::string path = raw != nullptr ? raw : std::string();
g_free(raw);
g_object_unref(file);
if (path.empty()) {
set_notice(*ctx->app, "export failed: that location is not a local file");
return;
}
// The extension picks the writer, so the filter the user chose is honoured by way of
// the name it put in the entry rather than by a second piece of state.
const bool csv = path.size() >= 4 && path.compare(path.size() - 4, 4, ".csv") == 0;
std::string error;
const bool ok = csv ? write_csv(path, ctx->table, error) : write_npy(path, ctx->table, error);
if (!ok) {
set_notice(*ctx->app, "export failed: " + error);
return;
}
set_notice(*ctx->app, "exported " + std::to_string(ctx->table.rows()) + " samples x " +
std::to_string(ctx->table.columns.size()) + " signals to " + path);
}
void on_export_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
auto ctx = std::make_unique<ExportContext>();
ctx->app = app;
ctx->table = collect_export(*app->model, *app->rows);
if (ctx->table.empty()) {
set_notice(*app, "nothing to export: no signal is plotted in the visible range");
return;
}
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Export visible range");
char stamp[32];
const std::time_t now = std::time(nullptr);
std::tm tm{};
localtime_r(&now, &tm);
std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm);
gtk_file_dialog_set_initial_name(dialog, (std::string("udpscope-") + stamp + ".npy").c_str());
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
GtkFileFilter* npy = gtk_file_filter_new();
gtk_file_filter_set_name(npy, "NumPy array (*.npy)");
gtk_file_filter_add_pattern(npy, "*.npy");
g_list_store_append(filters, npy);
GtkFileFilter* csv = gtk_file_filter_new();
gtk_file_filter_set_name(csv, "CSV (*.csv)");
gtk_file_filter_add_pattern(csv, "*.csv");
g_list_store_append(filters, csv);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
gtk_file_dialog_set_default_filter(dialog, npy);
g_object_unref(npy);
g_object_unref(csv);
g_object_unref(filters);
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
ctx.release());
g_object_unref(dialog);
}
// --- right-click menu -------------------------------------------------------
void on_y_auto_clicked(GtkButton* button, gpointer user) {
@@ -1570,6 +1664,15 @@ GtkWidget* build_toolbar(App& app) {
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
GtkWidget* export_btn = gtk_button_new_with_label("Export visible range...");
gtk_widget_set_tooltip_text(export_btn,
"write every plotted signal over the visible x range, at the "
"stored sample rate, to a .npy or .csv file");
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), export_btn);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app.signal_toggle = gtk_toggle_button_new_with_label("Signal list");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true);
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
@@ -2007,6 +2110,52 @@ gboolean pause_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// Headless self-check for the export: collect the same table the menu item would and
/// write it both ways, skipping only the file chooser. Two numbers are the assertion --
/// `time=` must equal `axis=` (the export is the *visible* range, not the whole ring)
/// and `rows=` must be that span times the stream's real rate, so a table cut from the
/// LOD pyramid or from a stale window shows up as a count far short of it.
gboolean export_probe(gpointer user) {
App* a = static_cast<App*>(user);
const char* env = g_getenv("UDPSCOPE_EXPORT_PATH");
const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export";
const RowSet::Row& r0 = a->rows->row(0);
const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
const ExportTable table = collect_export(*a->model, *a->rows);
std::fprintf(stderr,
"[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] "
"stream=[%.6f..%.6f]\n",
a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live",
axis.min, axis.max, table.rows(), table.columns.size(),
table.rows() != 0 ? table.time.front() : 0.0,
table.rows() != 0 ? table.time.back() : 0.0,
table.rows() != 0 ? table.stream_time.front() : 0.0,
table.rows() != 0 ? table.stream_time.back() : 0.0);
for (const ExportTable::Column& col : table.columns) {
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
}
for (const char* ext : {".npy", ".csv"}) {
const std::string path = prefix + ext;
std::string error;
const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error)
: write_npy(path, table, error);
long bytes = -1;
if (ok) {
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
std::fseek(f, 0, SEEK_END);
bytes = std::ftell(f);
std::fclose(f);
}
}
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised.
@@ -2055,6 +2204,9 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().