cleaned up ui added exporter
This commit is contained in:
1 parent
8059b90de7
commit
ea560ed272
4 files changed
+225
-3
No files matched your search
+152
@@ -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().
|
||||
|
||||
Reference in new issue
Block a user