/* * GPS Test Tool - GUI front-end * * Plots an NMEA log (input) and the GPX track the embedded code produces * from it (output) on an OpenStreetMap background. The GPX is generated by * running the command-line gps-test-tool (-o single-run mode) with the * settings chosen in the side panel; every change re-runs it. * * Usage: ./gps-test-tool-gui [input.nmea] */ #include #include #include #include #include #include #include #define TILE_SIZE 256 #define MIN_ZOOM 2 #define MAX_ZOOM 19 #define TILE_URL "https://tile.openstreetmap.org/%d/%d/%d.png" #define USER_AGENT "GPStracker-gps-test-tool-gui/1.0" #define MAX_DOWNLOADS 2 /* OSM tile usage policy: at most 2 connections */ #define MEM_CACHE_MAX 600 /* tiles kept in memory before evicting unused ones */ #define TILE_MAX_AGE (30 * 24 * 3600) /* re-download disk-cached tiles older than this */ #define FALLBACK_LEVELS 6 /* how many zoom levels up to look for a placeholder tile */ #define CLICK_SLOP 4 /* pixels a press may move and still count as a click */ #define DOTS_ZOOM 16 /* draw individual points from this zoom level up */ #define REPROCESS_DELAY_MS 150 /* A track point in normalized Web Mercator coordinates (0..1 both ways) */ struct mpoint { double x, y; }; enum { DL_IDLE, DL_QUEUED, DL_LOADING, DL_FAILED, DL_DONE, }; struct tile { int z, x, y; cairo_surface_t *surface; unsigned char dl_state; unsigned char stale; guint last_used; }; struct dl_request { struct tile *tile; SoupMessage *msg; }; struct setting_spin { const char *key; const char *label; const char *cli_opt; int min, max, def; GtkWidget *widget; }; /* Ranges match soft/settings.c's limits_min_u8/limits_max_u8; defaults * match the command-line tool's. */ static struct setting_spin spins[] = { { "skip_points", "Skip points", "-k", 0, 120, 0, NULL }, { "min_sats", "Min. satellites", "-m", 4, 12, 4, NULL }, { "auto_pause_time", "Time (s)", "-t", 10, 120, 30, NULL }, { "auto_pause_dist", "Distance (m)", "-d", 2, 100, 10, NULL }, { "auto_pause_speed", "Speed (km/h)", "-s", 0, 20, 3, NULL }, }; #define SPIN_SKIP 0 #define SPIN_MIN_SATS 1 #define SPIN_AP_FIRST 2 #define SPIN_COUNT (sizeof(spins) / sizeof(spins[0])) /* Per-segment colours for the GPX output */ static const double seg_colors[][3] = { { 0.12, 0.47, 0.71 }, { 1.00, 0.50, 0.05 }, { 0.17, 0.63, 0.17 }, { 0.84, 0.15, 0.16 }, { 0.58, 0.40, 0.74 }, { 0.09, 0.75, 0.81 }, { 0.89, 0.47, 0.76 }, { 0.55, 0.34, 0.29 }, { 0.74, 0.74, 0.13 }, }; #define SEG_COLOR_COUNT (sizeof(seg_colors) / sizeof(seg_colors[0])) static struct { GtkWidget *window, *map, *coord_label, *status_label, *summary_label; GtkWidget *save_button; GtkWidget *chk_disable_filters, *chk_auto_pause, *chk_show_nmea, *chk_show_gpx; gboolean loading_settings; char *input_file; GArray *nmea; /* struct mpoint */ GPtrArray *segments; /* GArray of struct mpoint, one per */ gsize gpx_points; char *cli_path; char *tmpdir, *out_gpx, *out_log; gboolean have_result; GPid child; gboolean running, pending; guint reprocess_id; int zoom; double cx, cy; /* view centre in world pixels at the current zoom */ gboolean need_fit; gboolean pressed, dragging; double press_x, press_y, last_x, last_y; double scroll_accum; gboolean have_cursor, frozen; double cursor_mx, cursor_my, frozen_mx, frozen_my; GHashTable *tiles; /* "z/x/y" -> struct tile */ GQueue dl_queue; /* struct tile, DL_QUEUED */ int dl_active; guint frame; SoupSession *soup; char *cache_dir; GKeyFile *config; char *config_path; } app; /* ===== Projection ===== */ static double world_size(int zoom) { return TILE_SIZE * (double)(1 << zoom); } static void latlon_to_merc(double lat, double lon, struct mpoint *p) { double rlat = lat * M_PI / 180.0; p->x = (lon + 180.0) / 360.0; p->y = (1.0 - log(tan(rlat) + 1.0 / cos(rlat)) / M_PI) / 2.0; } static void merc_to_latlon(double x, double y, double *lat, double *lon) { *lon = x * 360.0 - 180.0; *lat = atan(sinh(M_PI * (1.0 - 2.0 * y))) * 180.0 / M_PI; } static void screen_to_merc(double sx, double sy, double *mx, double *my) { double ws = world_size(app.zoom); /* Same whole-pixel origin as map_draw() */ *mx = (floor(app.cx - gtk_widget_get_allocated_width(app.map) / 2.0) + sx) / ws; *my = (floor(app.cy - gtk_widget_get_allocated_height(app.map) / 2.0) + sy) / ws; } static void clamp_view(void) { double ws = world_size(app.zoom); if (app.cy < 0) app.cy = 0; if (app.cy > ws) app.cy = ws; app.cx = fmod(app.cx, ws); if (app.cx < 0) app.cx += ws; } /* ===== Config ===== */ static void config_save(void) { char *dir = g_path_get_dirname(app.config_path); g_mkdir_with_parents(dir, 0755); g_free(dir); g_key_file_save_to_file(app.config, app.config_path, NULL); } static int config_get_int(const char *group, const char *key, int def) { GError *err = NULL; int v = g_key_file_get_integer(app.config, group, key, &err); if (err) { g_error_free(err); return def; } return v; } static void settings_store(void) { unsigned i; if (app.loading_settings) return; for (i = 0; i < SPIN_COUNT; i++) g_key_file_set_integer(app.config, "settings", spins[i].key, gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget))); g_key_file_set_boolean(app.config, "settings", "disable_filters", gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters))); g_key_file_set_boolean(app.config, "settings", "auto_pause", gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause))); g_key_file_set_boolean(app.config, "view", "show_nmea", gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea))); g_key_file_set_boolean(app.config, "view", "show_gpx", gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx))); config_save(); } /* ===== Tiles ===== */ static char *tile_cache_path(int z, int x, int y) { return g_strdup_printf("%s/%d/%d/%d.png", app.cache_dir, z, x, y); } static cairo_surface_t *surface_from_pixbuf(GdkPixbuf *pix) { cairo_surface_t *s; cairo_t *cr; if (!pix) return NULL; s = cairo_image_surface_create(CAIRO_FORMAT_RGB24, TILE_SIZE, TILE_SIZE); cr = cairo_create(s); gdk_cairo_set_source_pixbuf(cr, pix, 0, 0); cairo_paint(cr); cairo_destroy(cr); g_object_unref(pix); return s; } static void tile_free(gpointer data) { struct tile *t = data; if (t->surface) cairo_surface_destroy(t->surface); g_free(t); } static gboolean tile_visible(struct tile *t) { int w = gtk_widget_get_allocated_width(app.map); int h = gtk_widget_get_allocated_height(app.map); int n = 1 << app.zoom; int x0, x1, y0, y1, x; if (t->z != app.zoom) return FALSE; x0 = (int)floor((app.cx - w / 2.0) / TILE_SIZE); x1 = (int)floor((app.cx + w / 2.0) / TILE_SIZE); y0 = (int)floor((app.cy - h / 2.0) / TILE_SIZE); y1 = (int)floor((app.cy + h / 2.0) / TILE_SIZE); if (t->y < y0 || t->y > y1) return FALSE; for (x = x0; x <= x1; x++) if (((x % n) + n) % n == t->x) return TRUE; return FALSE; } static void dl_pump(void); static void dl_finished(GObject *source, GAsyncResult *res, gpointer data) { struct dl_request *req = data; struct tile *t = req->tile; GError *err = NULL; GBytes *bytes = soup_session_send_and_read_finish(SOUP_SESSION(source), res, &err); app.dl_active--; t->dl_state = DL_FAILED; if (bytes && soup_message_get_status(req->msg) == SOUP_STATUS_OK) { GInputStream *st = g_memory_input_stream_new_from_bytes(bytes); GdkPixbuf *pix = gdk_pixbuf_new_from_stream(st, NULL, NULL); g_object_unref(st); if (pix) { char *path = tile_cache_path(t->z, t->x, t->y); char *dir = g_path_get_dirname(path); g_mkdir_with_parents(dir, 0755); g_file_set_contents(path, g_bytes_get_data(bytes, NULL), g_bytes_get_size(bytes), NULL); g_free(dir); g_free(path); if (t->surface) cairo_surface_destroy(t->surface); t->surface = surface_from_pixbuf(pix); t->stale = 0; t->dl_state = DL_DONE; } } else if (err) { g_warning("Tile %d/%d/%d: %s", t->z, t->x, t->y, err->message); } else { g_warning("Tile %d/%d/%d: HTTP %u", t->z, t->x, t->y, soup_message_get_status(req->msg)); } if (err) g_error_free(err); if (bytes) g_bytes_unref(bytes); g_object_unref(req->msg); g_free(req); gtk_widget_queue_draw(app.map); dl_pump(); } static void dl_pump(void) { while (app.dl_active < MAX_DOWNLOADS && !g_queue_is_empty(&app.dl_queue)) { struct tile *t = g_queue_pop_head(&app.dl_queue); struct dl_request *req; char *url; if (!tile_visible(t)) { t->dl_state = DL_IDLE; /* may be queued again once it's back in view */ continue; } url = g_strdup_printf(TILE_URL, t->z, t->x, t->y); req = g_new0(struct dl_request, 1); req->tile = t; req->msg = soup_message_new("GET", url); g_free(url); t->dl_state = DL_LOADING; app.dl_active++; soup_session_send_and_read_async(app.soup, req->msg, G_PRIORITY_DEFAULT, NULL, dl_finished, req); } } /* Look up a tile, loading it from the disk cache if needed. With fetch set, * a missing or stale tile is queued for download. */ static struct tile *tile_get(int z, int x, int y, gboolean fetch) { char key[48]; struct tile *t; g_snprintf(key, sizeof(key), "%d/%d/%d", z, x, y); t = g_hash_table_lookup(app.tiles, key); if (!t) { char *path = tile_cache_path(z, x, y); GStatBuf st; t = g_new0(struct tile, 1); t->z = z; t->x = x; t->y = y; if (g_stat(path, &st) == 0) { t->surface = surface_from_pixbuf(gdk_pixbuf_new_from_file(path, NULL)); t->stale = time(NULL) - st.st_mtime > TILE_MAX_AGE; } g_free(path); g_hash_table_insert(app.tiles, g_strdup(key), t); } t->last_used = app.frame; if (fetch && (!t->surface || t->stale) && t->dl_state == DL_IDLE) { t->dl_state = DL_QUEUED; g_queue_push_tail(&app.dl_queue, t); } return t; } static gboolean tile_evictable(gpointer key, gpointer value, gpointer data) { struct tile *t = value; (void)key; (void)data; return t->dl_state != DL_QUEUED && t->dl_state != DL_LOADING && t->last_used + 1 < app.frame; } static void draw_tile(cairo_t *cr, int z, int x, int y, double sx, double sy) { struct tile *t = tile_get(z, x, y, TRUE); int d; if (t->surface) { cairo_set_source_surface(cr, t->surface, sx, sy); cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE); cairo_fill(cr); return; } /* Not available (yet): stretch a lower-zoom tile we already have */ for (d = 1; d <= FALLBACK_LEVELS && z - d >= 0; d++) { struct tile *p = tile_get(z - d, x >> d, y >> d, FALSE); if (p->surface) { double scale = 1 << d; cairo_save(cr); cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE); cairo_clip(cr); cairo_translate(cr, sx - (x - ((x >> d) << d)) * TILE_SIZE, sy - (y - ((y >> d) << d)) * TILE_SIZE); cairo_scale(cr, scale, scale); cairo_set_source_surface(cr, p->surface, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR); cairo_paint(cr); cairo_restore(cr); return; } } } /* ===== Drawing ===== */ static void draw_track(cairo_t *cr, GArray *pts, double ox, double oy, double ws, double r, double g, double b, double a, double width, gboolean dots) { guint i; if (!pts || !pts->len) return; cairo_set_source_rgba(cr, r, g, b, a); cairo_set_line_width(cr, width); cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND); cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); for (i = 0; i < pts->len; i++) { struct mpoint *p = &g_array_index(pts, struct mpoint, i); double sx = p->x * ws - ox, sy = p->y * ws - oy; if (i) cairo_line_to(cr, sx, sy); else cairo_move_to(cr, sx, sy); } if (pts->len == 1) cairo_rel_line_to(cr, 0, 0); /* round cap makes it a dot */ cairo_stroke(cr); if (!dots) return; for (i = 0; i < pts->len; i++) { struct mpoint *p = &g_array_index(pts, struct mpoint, i); cairo_new_sub_path(cr); cairo_arc(cr, p->x * ws - ox, p->y * ws - oy, width * 0.9, 0, 2 * M_PI); } cairo_fill(cr); } static void fit_view(void); static gboolean map_draw(GtkWidget *w, cairo_t *cr, gpointer data) { int width = gtk_widget_get_allocated_width(w); int height = gtk_widget_get_allocated_height(w); double ws, ox, oy; int n, tx0, tx1, ty0, ty1, tx, ty; gboolean dots; cairo_text_extents_t ext; const char *attrib = "© OpenStreetMap contributors"; (void)data; if (app.need_fit) fit_view(); app.frame++; if (g_hash_table_size(app.tiles) > MEM_CACHE_MAX) g_hash_table_foreach_remove(app.tiles, tile_evictable, NULL); ws = world_size(app.zoom); n = 1 << app.zoom; /* Whole pixels: tiles drawn at fractional offsets show seams */ ox = floor(app.cx - width / 2.0); oy = floor(app.cy - height / 2.0); cairo_set_source_rgb(cr, 0.85, 0.85, 0.85); cairo_paint(cr); tx0 = (int)floor(ox / TILE_SIZE); tx1 = (int)floor((ox + width) / TILE_SIZE); ty0 = MAX(0, (int)floor(oy / TILE_SIZE)); ty1 = MIN(n - 1, (int)floor((oy + height) / TILE_SIZE)); for (ty = ty0; ty <= ty1; ty++) for (tx = tx0; tx <= tx1; tx++) draw_tile(cr, app.zoom, ((tx % n) + n) % n, ty, tx * TILE_SIZE - ox, ty * TILE_SIZE - oy); dl_pump(); dots = app.zoom >= DOTS_ZOOM; if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)) && app.segments) { guint i; for (i = 0; i < app.segments->len; i++) { const double *c = seg_colors[i % SEG_COLOR_COUNT]; draw_track(cr, g_ptr_array_index(app.segments, i), ox, oy, ws, c[0], c[1], c[2], 0.85, 4.0, dots); } } /* Input on top, thin, so it stays visible where the output follows it */ if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea))) draw_track(cr, app.nmea, ox, oy, ws, 0.1, 0.1, 0.1, 0.75, 1.2, dots); if (app.frozen) { double sx = app.frozen_mx * ws - ox, sy = app.frozen_my * ws - oy; cairo_set_line_width(cr, 2.0); cairo_set_source_rgb(cr, 1, 1, 1); cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI); cairo_stroke(cr); cairo_set_line_width(cr, 1.5); cairo_set_source_rgb(cr, 0.8, 0, 0); cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI); cairo_move_to(cr, sx - 11, sy); cairo_line_to(cr, sx + 11, sy); cairo_move_to(cr, sx, sy - 11); cairo_line_to(cr, sx, sy + 11); cairo_stroke(cr); } /* Attribution, required by the OSM tile usage policy */ cairo_set_font_size(cr, 11); cairo_text_extents(cr, attrib, &ext); cairo_set_source_rgba(cr, 1, 1, 1, 0.75); cairo_rectangle(cr, width - ext.x_advance - 8, height - 17, ext.x_advance + 8, 17); cairo_fill(cr); cairo_set_source_rgb(cr, 0.2, 0.2, 0.2); cairo_move_to(cr, width - ext.x_advance - 4, height - 5); cairo_show_text(cr, attrib); return TRUE; } /* ===== Coordinate bar ===== */ static void update_coord_label(void) { double mx, my, lat, lon; char slat[G_ASCII_DTOSTR_BUF_SIZE], slon[G_ASCII_DTOSTR_BUF_SIZE]; char *text; if (app.frozen || app.have_cursor) { mx = app.frozen ? app.frozen_mx : app.cursor_mx; my = app.frozen ? app.frozen_my : app.cursor_my; merc_to_latlon(mx, my, &lat, &lon); /* Always '.' as the decimal separator, whatever the locale */ g_ascii_formatd(slat, sizeof(slat), "%.6f", lat); g_ascii_formatd(slon, sizeof(slon), "%.6f", lon); if (app.frozen) text = g_strdup_printf("%s, %s (frozen \u2013 right-click to release) zoom %d", slat, slon, app.zoom); else text = g_strdup_printf("%s, %s zoom %d", slat, slon, app.zoom); } else { text = g_strdup_printf("\u2013 zoom %d", app.zoom); } gtk_label_set_markup(GTK_LABEL(app.coord_label), text); g_free(text); } /* ===== View ===== */ static gboolean bbox_add(GArray *a, double *x0, double *y0, double *x1, double *y1) { guint i; for (i = 0; a && i < a->len; i++) { struct mpoint *p = &g_array_index(a, struct mpoint, i); *x0 = MIN(*x0, p->x); *y0 = MIN(*y0, p->y); *x1 = MAX(*x1, p->x); *y1 = MAX(*y1, p->y); } return a && a->len; } static gboolean track_bbox(double *x0, double *y0, double *x1, double *y1) { gboolean any; guint i; *x0 = *y0 = 1.0; *x1 = *y1 = 0.0; any = bbox_add(app.nmea, x0, y0, x1, y1); for (i = 0; app.segments && i < app.segments->len; i++) any |= bbox_add(g_ptr_array_index(app.segments, i), x0, y0, x1, y1); return any; } static void fit_view(void) { int w = gtk_widget_get_allocated_width(app.map); int h = gtk_widget_get_allocated_height(app.map); double x0, y0, x1, y1; if (w <= 1 || h <= 1) return; /* not realized yet; retried on the next draw */ app.need_fit = FALSE; if (!track_bbox(&x0, &y0, &x1, &y1)) return; for (app.zoom = MAX_ZOOM; app.zoom > MIN_ZOOM; app.zoom--) { double ws = world_size(app.zoom); if ((x1 - x0) * ws <= w - 40 && (y1 - y0) * ws <= h - 40) break; } app.cx = (x0 + x1) / 2.0 * world_size(app.zoom); app.cy = (y0 + y1) / 2.0 * world_size(app.zoom); update_coord_label(); } static void zoom_at(int new_zoom, double sx, double sy) { int w = gtk_widget_get_allocated_width(app.map); int h = gtk_widget_get_allocated_height(app.map); double f; new_zoom = CLAMP(new_zoom, MIN_ZOOM, MAX_ZOOM); if (new_zoom == app.zoom) return; f = ldexp(1.0, new_zoom - app.zoom); app.cx = (app.cx + sx - w / 2.0) * f - (sx - w / 2.0); app.cy = (app.cy + sy - h / 2.0) * f - (sy - h / 2.0); app.zoom = new_zoom; clamp_view(); update_coord_label(); gtk_widget_queue_draw(app.map); } static gboolean map_scroll(GtkWidget *w, GdkEventScroll *ev, gpointer data) { double dx, dy; (void)w; (void)data; switch (ev->direction) { case GDK_SCROLL_UP: zoom_at(app.zoom + 1, ev->x, ev->y); break; case GDK_SCROLL_DOWN: zoom_at(app.zoom - 1, ev->x, ev->y); break; case GDK_SCROLL_SMOOTH: if (!gdk_event_get_scroll_deltas((GdkEvent *)ev, &dx, &dy)) break; app.scroll_accum += dy; while (app.scroll_accum <= -1.0) { app.scroll_accum += 1.0; zoom_at(app.zoom + 1, ev->x, ev->y); } while (app.scroll_accum >= 1.0) { app.scroll_accum -= 1.0; zoom_at(app.zoom - 1, ev->x, ev->y); } break; default: break; } return TRUE; } static gboolean map_button_press(GtkWidget *w, GdkEventButton *ev, gpointer data) { (void)w; (void)data; if (ev->type != GDK_BUTTON_PRESS) return FALSE; if (ev->button == 1) { app.pressed = TRUE; app.dragging = FALSE; app.press_x = app.last_x = ev->x; app.press_y = app.last_y = ev->y; } else if (ev->button == 3 && app.frozen) { app.frozen = FALSE; update_coord_label(); gtk_widget_queue_draw(app.map); } return TRUE; } static gboolean map_button_release(GtkWidget *w, GdkEventButton *ev, gpointer data) { (void)w; (void)data; if (ev->button != 1 || !app.pressed) return FALSE; app.pressed = FALSE; if (!app.dragging) { screen_to_merc(ev->x, ev->y, &app.frozen_mx, &app.frozen_my); app.frozen = TRUE; update_coord_label(); gtk_widget_queue_draw(app.map); } app.dragging = FALSE; return TRUE; } static gboolean map_motion(GtkWidget *w, GdkEventMotion *ev, gpointer data) { (void)w; (void)data; if (app.pressed) { if (!app.dragging && (fabs(ev->x - app.press_x) > CLICK_SLOP || fabs(ev->y - app.press_y) > CLICK_SLOP)) app.dragging = TRUE; if (app.dragging) { app.cx -= ev->x - app.last_x; app.cy -= ev->y - app.last_y; clamp_view(); gtk_widget_queue_draw(app.map); } app.last_x = ev->x; app.last_y = ev->y; } screen_to_merc(ev->x, ev->y, &app.cursor_mx, &app.cursor_my); app.have_cursor = TRUE; if (!app.frozen) update_coord_label(); return TRUE; } static gboolean map_leave(GtkWidget *w, GdkEventCrossing *ev, gpointer data) { (void)w; (void)ev; (void)data; app.have_cursor = FALSE; if (!app.frozen) update_coord_label(); return FALSE; } /* ===== Input / output parsing ===== */ static double nmea_coord(const char *val, const char *hemi) { double v = g_ascii_strtod(val, NULL); double deg = floor(v / 100.0); v = deg + (v - deg * 100.0) / 60.0; return (*hemi == 'S' || *hemi == 'W') ? -v : v; } /* Extract a position from a valid, checksummed $..RMC sentence */ static gboolean nmea_rmc_point(const char *line, struct mpoint *p) { const char *s = strchr(line, '$'), *c; char **f; unsigned char sum = 0; unsigned int want; gboolean ok = FALSE; char *body; if (!s || strncmp(s + 3, "RMC,", 4)) return FALSE; for (c = s + 1; *c && *c != '*'; c++) sum ^= (unsigned char)*c; if (*c != '*' || sscanf(c + 1, "%2x", &want) != 1 || want != sum) return FALSE; body = g_strndup(s + 1, c - s - 1); f = g_strsplit(body, ",", 0); if (g_strv_length(f) >= 7 && !strcmp(f[2], "A") && *f[3] && *f[5]) { latlon_to_merc(nmea_coord(f[3], f[4]), nmea_coord(f[5], f[6]), p); ok = TRUE; } g_strfreev(f); g_free(body); return ok; } static gboolean load_nmea(const char *path) { char *data, **lines; guint i; GError *err = NULL; if (!g_file_get_contents(path, &data, NULL, &err)) { GtkWidget *d = gtk_message_dialog_new(GTK_WINDOW(app.window), GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot open %s: %s", path, err->message); gtk_dialog_run(GTK_DIALOG(d)); gtk_widget_destroy(d); g_error_free(err); return FALSE; } if (app.nmea) g_array_free(app.nmea, TRUE); app.nmea = g_array_new(FALSE, FALSE, sizeof(struct mpoint)); lines = g_strsplit(data, "\n", 0); for (i = 0; lines[i]; i++) { struct mpoint p; if (nmea_rmc_point(lines[i], &p)) g_array_append_val(app.nmea, p); } g_strfreev(lines); g_free(data); return TRUE; } static void free_segments(void) { if (app.segments) g_ptr_array_free(app.segments, TRUE); app.segments = NULL; app.gpx_points = 0; } static void load_gpx(const char *path) { char *data, *s; GArray *seg = NULL; free_segments(); app.segments = g_ptr_array_new_with_free_func((GDestroyNotify)g_array_unref); if (!g_file_get_contents(path, &data, NULL, NULL)) return; for (s = data; (s = strchr(s, '<')); s++) { if (!strncmp(s, "", 8)) { seg = g_array_new(FALSE, FALSE, sizeof(struct mpoint)); g_ptr_array_add(app.segments, seg); } else if (!strncmp(s, "'); struct mpoint p; if (!la || !lo || !end || la > end || lo > end) continue; latlon_to_merc(g_ascii_strtod(la + 5, NULL), g_ascii_strtod(lo + 5, NULL), &p); g_array_append_val(seg, p); app.gpx_points++; } } g_free(data); } static void load_summary(const char *path) { char *data, *s; if (!g_file_get_contents(path, &data, NULL, NULL)) { gtk_label_set_text(GTK_LABEL(app.summary_label), ""); return; } s = strstr(data, "=== Summary ===\n"); gtk_label_set_text(GTK_LABEL(app.summary_label), s ? g_strstrip(s + 16) : ""); g_free(data); } /* ===== Processing (runs the command-line tool) ===== */ static void set_status(const char *text) { gtk_label_set_text(GTK_LABEL(app.status_label), text); } static void start_processing(void); static void child_exited(GPid pid, gint status, gpointer data) { GError *err = NULL; (void)data; g_spawn_close_pid(pid); app.running = FALSE; if (app.pending) { start_processing(); return; } if (!g_spawn_check_wait_status(status, &err)) { char *msg = g_strdup_printf("Processing failed: %s", err->message); set_status(msg); g_free(msg); g_error_free(err); app.have_result = FALSE; free_segments(); } else { char *msg; load_gpx(app.out_gpx); load_summary(app.out_log); app.have_result = TRUE; msg = g_strdup_printf("%u NMEA fixes → %" G_GSIZE_FORMAT " GPX points in %u segment(s)", app.nmea ? app.nmea->len : 0, app.gpx_points, app.segments->len); set_status(msg); g_free(msg); } gtk_widget_set_sensitive(app.save_button, app.have_result); gtk_widget_queue_draw(app.map); } static void start_processing(void) { GPtrArray *argv; GError *err = NULL; char *vals[SPIN_COUNT]; unsigned i; if (!app.input_file) return; if (app.running) { /* Restart with the new settings once this run is gone */ app.pending = TRUE; kill(app.child, SIGTERM); return; } app.pending = FALSE; argv = g_ptr_array_new(); g_ptr_array_add(argv, app.cli_path); g_ptr_array_add(argv, "-o"); g_ptr_array_add(argv, app.out_gpx); g_ptr_array_add(argv, "-l"); g_ptr_array_add(argv, app.out_log); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters))) g_ptr_array_add(argv, "-n"); if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause))) g_ptr_array_add(argv, "-A"); for (i = 0; i < SPIN_COUNT; i++) { vals[i] = g_strdup_printf("%d", gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget))); g_ptr_array_add(argv, (gpointer)spins[i].cli_opt); g_ptr_array_add(argv, vals[i]); } g_ptr_array_add(argv, "--"); g_ptr_array_add(argv, app.input_file); g_ptr_array_add(argv, NULL); if (g_spawn_async(NULL, (char **)argv->pdata, NULL, G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_STDOUT_TO_DEV_NULL, NULL, NULL, &app.child, &err)) { app.running = TRUE; g_child_watch_add(app.child, child_exited, NULL); set_status("Processing…"); } else { char *msg = g_strdup_printf("Cannot run %s: %s", app.cli_path, err->message); set_status(msg); g_free(msg); g_error_free(err); } for (i = 0; i < SPIN_COUNT; i++) g_free(vals[i]); g_ptr_array_free(argv, TRUE); } static gboolean reprocess_timeout(gpointer data) { (void)data; app.reprocess_id = 0; start_processing(); return G_SOURCE_REMOVE; } static void setting_changed(GtkWidget *w, gpointer data) { unsigned i; (void)w; (void)data; if (app.loading_settings) return; for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++) gtk_widget_set_sensitive(spins[i].widget, gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause))); settings_store(); /* Debounced: holding a spin button's arrow fires many changes */ if (app.reprocess_id) g_source_remove(app.reprocess_id); app.reprocess_id = g_timeout_add(REPROCESS_DELAY_MS, reprocess_timeout, NULL); } static void view_changed(GtkWidget *w, gpointer data) { (void)w; (void)data; settings_store(); gtk_widget_queue_draw(app.map); } /* ===== File handling ===== */ static void open_file(const char *path) { char *title, *base; if (!load_nmea(path)) return; g_free(app.input_file); app.input_file = g_canonicalize_filename(path, NULL); g_key_file_set_string(app.config, "view", "input_file", app.input_file); config_save(); base = g_path_get_basename(path); title = g_strdup_printf("%s – GPS Test Tool", base); gtk_window_set_title(GTK_WINDOW(app.window), title); g_free(title); g_free(base); free_segments(); app.have_result = FALSE; gtk_widget_set_sensitive(app.save_button, FALSE); gtk_label_set_text(GTK_LABEL(app.summary_label), ""); app.need_fit = TRUE; gtk_widget_queue_draw(app.map); start_processing(); } static void open_clicked(GtkWidget *w, gpointer data) { GtkWidget *d; GtkFileFilter *f; (void)w; (void)data; d = gtk_file_chooser_dialog_new("Open NMEA log", GTK_WINDOW(app.window), GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, NULL); f = gtk_file_filter_new(); gtk_file_filter_set_name(f, "NMEA logs"); gtk_file_filter_add_pattern(f, "*.nmea"); gtk_file_filter_add_pattern(f, "*.NMEA"); gtk_file_filter_add_pattern(f, "*.log"); gtk_file_filter_add_pattern(f, "*.LOG"); gtk_file_filter_add_pattern(f, "*.txt"); gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f); f = gtk_file_filter_new(); gtk_file_filter_set_name(f, "All files"); gtk_file_filter_add_pattern(f, "*"); gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f); if (app.input_file) { char *dir = g_path_get_dirname(app.input_file); gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir); g_free(dir); } if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) { char *path = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(d)); gtk_widget_destroy(d); open_file(path); g_free(path); return; } gtk_widget_destroy(d); } static void save_clicked(GtkWidget *w, gpointer data) { GtkWidget *d; (void)w; (void)data; if (!app.have_result) return; d = gtk_file_chooser_dialog_new("Save GPX", GTK_WINDOW(app.window), GTK_FILE_CHOOSER_ACTION_SAVE, "_Cancel", GTK_RESPONSE_CANCEL, "_Save", GTK_RESPONSE_ACCEPT, NULL); gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(d), TRUE); if (app.input_file) { char *dir = g_path_get_dirname(app.input_file); char *base = g_path_get_basename(app.input_file); char *dot = strrchr(base, '.'); char *name; if (dot) *dot = '\0'; name = g_strdup_printf("%s.gpx", base); gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir); gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(d), name); g_free(name); g_free(base); g_free(dir); } if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) { GFile *src = g_file_new_for_path(app.out_gpx); GFile *dst = gtk_file_chooser_get_file(GTK_FILE_CHOOSER(d)); GError *err = NULL; if (!g_file_copy(src, dst, G_FILE_COPY_OVERWRITE, NULL, NULL, NULL, &err)) { GtkWidget *m = gtk_message_dialog_new(GTK_WINDOW(d), GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot save: %s", err->message); gtk_dialog_run(GTK_DIALOG(m)); gtk_widget_destroy(m); g_error_free(err); } g_object_unref(src); g_object_unref(dst); } gtk_widget_destroy(d); } static void fit_clicked(GtkWidget *w, gpointer data) { (void)w; (void)data; app.need_fit = TRUE; gtk_widget_queue_draw(app.map); } /* ===== Setup ===== */ static char *find_cli(void) { char *self = g_file_read_link("/proc/self/exe", NULL); if (self) { char *dir = g_path_get_dirname(self); char *path = g_build_filename(dir, "gps-test-tool", NULL); g_free(dir); g_free(self); if (g_file_test(path, G_FILE_TEST_IS_EXECUTABLE)) return path; g_free(path); } return g_find_program_in_path("gps-test-tool"); } static GtkWidget *section(GtkWidget *box, const char *title) { GtkWidget *frame = gtk_frame_new(title); GtkWidget *inner = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_container_set_border_width(GTK_CONTAINER(inner), 6); gtk_container_add(GTK_CONTAINER(frame), inner); gtk_box_pack_start(GTK_BOX(box), frame, FALSE, FALSE, 0); return inner; } static GtkWidget *check(GtkWidget *box, const char *label, const char *group, const char *key, gboolean def, GCallback cb) { GtkWidget *w = gtk_check_button_new_with_label(label); GError *err = NULL; gboolean v = g_key_file_get_boolean(app.config, group, key, &err); if (err) { g_error_free(err); v = def; } gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(w), v); g_signal_connect(w, "toggled", cb, NULL); gtk_box_pack_start(GTK_BOX(box), w, FALSE, FALSE, 0); return w; } static void spin(GtkWidget *box, struct setting_spin *s) { GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); int v = CLAMP(config_get_int("settings", s->key, s->def), s->min, s->max); s->widget = gtk_spin_button_new_with_range(s->min, s->max, 1); gtk_spin_button_set_value(GTK_SPIN_BUTTON(s->widget), v); g_signal_connect(s->widget, "value-changed", G_CALLBACK(setting_changed), NULL); gtk_box_pack_start(GTK_BOX(row), gtk_label_new(s->label), FALSE, FALSE, 0); gtk_box_pack_end(GTK_BOX(row), s->widget, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(box), row, FALSE, FALSE, 0); } static void build_ui(void) { GtkWidget *header, *button, *hbox, *side, *sec, *vbox, *bar, *scroll; PangoAttrList *attrs; unsigned i; app.window = gtk_window_new(GTK_WINDOW_TOPLEVEL); gtk_window_set_default_size(GTK_WINDOW(app.window), config_get_int("view", "width", 1200), config_get_int("view", "height", 800)); g_signal_connect(app.window, "destroy", G_CALLBACK(gtk_main_quit), NULL); header = gtk_header_bar_new(); gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), TRUE); gtk_header_bar_set_title(GTK_HEADER_BAR(header), "GPS Test Tool"); button = gtk_button_new_with_mnemonic("_Open NMEA…"); g_signal_connect(button, "clicked", G_CALLBACK(open_clicked), NULL); gtk_header_bar_pack_start(GTK_HEADER_BAR(header), button); app.save_button = gtk_button_new_with_mnemonic("_Save GPX…"); gtk_widget_set_sensitive(app.save_button, FALSE); g_signal_connect(app.save_button, "clicked", G_CALLBACK(save_clicked), NULL); gtk_header_bar_pack_start(GTK_HEADER_BAR(header), app.save_button); gtk_window_set_titlebar(GTK_WINDOW(app.window), header); vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_box_pack_start(GTK_BOX(vbox), hbox, TRUE, TRUE, 0); side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_container_set_border_width(GTK_CONTAINER(side), 8); scroll = gtk_scrolled_window_new(NULL, NULL); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_container_add(GTK_CONTAINER(scroll), side); gtk_widget_set_size_request(scroll, 270, -1); gtk_box_pack_start(GTK_BOX(hbox), scroll, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(hbox), gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0); app.loading_settings = TRUE; sec = section(side, "Filtering"); app.chk_disable_filters = check(sec, "Disable filters", "settings", "disable_filters", FALSE, G_CALLBACK(setting_changed)); spin(sec, &spins[SPIN_SKIP]); spin(sec, &spins[SPIN_MIN_SATS]); sec = section(side, "Auto-pause"); app.chk_auto_pause = check(sec, "Enabled", "settings", "auto_pause", TRUE, G_CALLBACK(setting_changed)); for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++) { spin(sec, &spins[i]); gtk_widget_set_sensitive(spins[i].widget, gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause))); } sec = section(side, "Display"); app.chk_show_nmea = check(sec, "NMEA input (grey)", "view", "show_nmea", TRUE, G_CALLBACK(view_changed)); app.chk_show_gpx = check(sec, "GPX output (colour per segment)", "view", "show_gpx", TRUE, G_CALLBACK(view_changed)); button = gtk_button_new_with_label("Zoom to fit"); g_signal_connect(button, "clicked", G_CALLBACK(fit_clicked), NULL); gtk_box_pack_start(GTK_BOX(sec), button, FALSE, FALSE, 0); app.loading_settings = FALSE; sec = section(side, "Summary"); app.summary_label = gtk_label_new(""); gtk_label_set_selectable(GTK_LABEL(app.summary_label), TRUE); gtk_label_set_xalign(GTK_LABEL(app.summary_label), 0); gtk_label_set_line_wrap(GTK_LABEL(app.summary_label), TRUE); attrs = pango_attr_list_new(); pango_attr_list_insert(attrs, pango_attr_family_new("monospace")); pango_attr_list_insert(attrs, pango_attr_scale_new(0.85)); gtk_label_set_attributes(GTK_LABEL(app.summary_label), attrs); pango_attr_list_unref(attrs); gtk_box_pack_start(GTK_BOX(sec), app.summary_label, FALSE, FALSE, 0); app.map = gtk_drawing_area_new(); gtk_widget_add_events(app.map, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK | GDK_SMOOTH_SCROLL_MASK | GDK_LEAVE_NOTIFY_MASK); g_signal_connect(app.map, "draw", G_CALLBACK(map_draw), NULL); g_signal_connect(app.map, "scroll-event", G_CALLBACK(map_scroll), NULL); g_signal_connect(app.map, "button-press-event", G_CALLBACK(map_button_press), NULL); g_signal_connect(app.map, "button-release-event", G_CALLBACK(map_button_release), NULL); g_signal_connect(app.map, "motion-notify-event", G_CALLBACK(map_motion), NULL); g_signal_connect(app.map, "leave-notify-event", G_CALLBACK(map_leave), NULL); gtk_box_pack_start(GTK_BOX(hbox), app.map, TRUE, TRUE, 0); bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); gtk_container_set_border_width(GTK_CONTAINER(bar), 4); app.coord_label = gtk_label_new(""); gtk_label_set_selectable(GTK_LABEL(app.coord_label), TRUE); gtk_box_pack_start(GTK_BOX(bar), app.coord_label, FALSE, FALSE, 4); app.status_label = gtk_label_new("Open an NMEA log to start"); gtk_box_pack_end(GTK_BOX(bar), app.status_label, FALSE, FALSE, 4); gtk_box_pack_start(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(vbox), bar, FALSE, FALSE, 0); gtk_container_add(GTK_CONTAINER(app.window), vbox); } static gboolean window_configure(GtkWidget *w, GdkEvent *ev, gpointer data) { int width, height; (void)ev; (void)data; gtk_window_get_size(GTK_WINDOW(w), &width, &height); g_key_file_set_integer(app.config, "view", "width", width); g_key_file_set_integer(app.config, "view", "height", height); return FALSE; } static gboolean quit_signal(gpointer data) { (void)data; gtk_main_quit(); return G_SOURCE_REMOVE; } int main(int argc, char *argv[]) { char *input; gtk_init(&argc, &argv); app.config = g_key_file_new(); app.config_path = g_build_filename(g_get_user_config_dir(), "gps-test-tool", "gui.ini", NULL); g_key_file_load_from_file(app.config, app.config_path, G_KEY_FILE_NONE, NULL); app.cache_dir = g_build_filename(g_get_user_cache_dir(), "gps-test-tool", "tiles", NULL); app.cli_path = find_cli(); app.tmpdir = g_dir_make_tmp("gps-test-tool-gui-XXXXXX", NULL); if (!app.cli_path || !app.tmpdir) { fprintf(stderr, "Error: %s\n", app.cli_path ? "cannot create temporary directory" : "gps-test-tool binary not found next to this program or in PATH"); return 1; } app.out_gpx = g_build_filename(app.tmpdir, "output.gpx", NULL); app.out_log = g_build_filename(app.tmpdir, "debug.txt", NULL); app.tiles = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, tile_free); g_queue_init(&app.dl_queue); app.soup = soup_session_new_with_options("user-agent", USER_AGENT, "max-conns-per-host", MAX_DOWNLOADS, NULL); app.zoom = MIN_ZOOM; app.cx = app.cy = world_size(app.zoom) / 2; build_ui(); g_signal_connect(app.window, "configure-event", G_CALLBACK(window_configure), NULL); gtk_widget_show_all(app.window); update_coord_label(); input = argc > 1 ? g_strdup(argv[1]) : g_key_file_get_string(app.config, "view", "input_file", NULL); if (input && (argc > 1 || g_file_test(input, G_FILE_TEST_EXISTS))) open_file(input); g_free(input); /* Clean up the temporary directory on Ctrl+C / kill too */ g_unix_signal_add(SIGINT, quit_signal, NULL); g_unix_signal_add(SIGTERM, quit_signal, NULL); gtk_main(); if (app.running) kill(app.child, SIGTERM); config_save(); g_unlink(app.out_gpx); g_unlink(app.out_log); g_rmdir(app.tmpdir); return 0; }