Jelajahi Sumber

gps-test-tool: add GUI with OSM map view and live settings

k4be 1 hari lalu
induk
melakukan
f9adcc3212
4 mengubah file dengan 1244 tambahan dan 2 penghapusan
  1. 1 0
      gps-test-tool/.gitignore
  2. 13 2
      gps-test-tool/Makefile
  3. 23 0
      gps-test-tool/README.md
  4. 1207 0
      gps-test-tool/gui.c

+ 1 - 0
gps-test-tool/.gitignore

@@ -1,4 +1,5 @@
 gps-test-tool
+gps-test-tool-gui
 test-malformed
 *.nmea
 !malformed_test.nmea

+ 13 - 2
gps-test-tool/Makefile

@@ -8,10 +8,14 @@ TARGET = gps-test-tool
 SOURCES = main.c gpx_wrapper.c nmea_actual_wrapper.c syslog_wrapper.c autopause_wrapper.c
 OBJECTS = $(SOURCES:.c=.o)
 
+GUI = gps-test-tool-gui
+GUI_CFLAGS = -Wall -Wextra -g -O2 $(shell pkg-config --cflags gtk+-3.0 libsoup-3.0)
+GUI_LDFLAGS = $(shell pkg-config --libs gtk+-3.0 libsoup-3.0) -lm
+
 TEST_MALFORMED = test-malformed
 TEST_MALFORMED_OBJECTS = test_malformed.o gpx_wrapper.o nmea_actual_wrapper.o syslog_wrapper.o
 
-.PHONY: all clean run-test-malformed
+.PHONY: all gui clean run-test-malformed
 
 all: $(TARGET)
 
@@ -21,6 +25,11 @@ $(TARGET): $(OBJECTS)
 	@echo "Build complete! Run with:"
 	@echo "  ./$(TARGET) <input.nmea>"
 
+gui: $(TARGET) $(GUI)
+
+$(GUI): gui.c
+	$(CC) $(GUI_CFLAGS) gui.c -o $(GUI) $(GUI_LDFLAGS)
+
 $(TEST_MALFORMED): $(TEST_MALFORMED_OBJECTS)
 	$(CC) $(TEST_MALFORMED_OBJECTS) -o $(TEST_MALFORMED) $(LDFLAGS)
 
@@ -31,7 +40,7 @@ run-test-malformed: $(TEST_MALFORMED)
 	$(CC) $(CFLAGS) -c $< -o $@
 
 clean:
-	rm -f $(OBJECTS) $(TEST_MALFORMED) test_malformed.o $(TARGET) output_*.gpx debug_*.txt
+	rm -f $(OBJECTS) $(TEST_MALFORMED) test_malformed.o $(TARGET) $(GUI) output_*.gpx debug_*.txt
 
 test: $(TARGET)
 	@echo "Running test with sample NMEA data..."
@@ -50,6 +59,7 @@ help:
 	@echo ""
 	@echo "Targets:"
 	@echo "  all (default) - Build the gps-test-tool executable"
+	@echo "  gui           - Also build gps-test-tool-gui (needs GTK3, libsoup3)"
 	@echo "  clean         - Remove built files and output"
 	@echo "  test          - Build and run with sample data"
 	@echo "  help          - Show this help message"
@@ -58,3 +68,4 @@ help:
 	@echo "  make           # Build"
 	@echo "  make test      # Build and test"
 	@echo "  ./gps-test-tool <input.nmea>  # Run"
+	@echo "  ./gps-test-tool-gui [input.nmea]  # Run GUI"

+ 23 - 0
gps-test-tool/README.md

@@ -92,6 +92,29 @@ $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
 $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
 ```
 
+## GUI
+
+```
+make gui                          # builds ./gps-test-tool-gui too (needs GTK3, libsoup3)
+./gps-test-tool-gui [input.nmea]
+```
+
+Shows the input NMEA track (thin dark line) and the resulting GPX track
+(one colour per `<trkseg>`, i.e. per pause-separated segment) on an
+OpenStreetMap background. The GPX is produced by running `gps-test-tool -o`
+(looked up next to the GUI binary, then in `PATH`), so it is always the
+same result the command-line tool gives. Changing any setting in the side
+panel reprocesses the file immediately; the summary from the debug output
+is shown below the settings. "Save GPX..." stores the current result.
+
+- Mouse wheel zooms around the cursor, left-drag pans, "Zoom to fit" goes
+  back to the whole track.
+- Coordinates under the cursor are shown in the bottom bar; a left click
+  freezes them (and marks the point), a right click releases them.
+- Settings, the last file and the window size are remembered in
+  `~/.config/gps-test-tool/gui.ini`; map tiles are cached in
+  `~/.cache/gps-test-tool/tiles`.
+
 ## Automated tests
 
 A regression test (`test-malformed`) feeds the real `gps_parse()` a fixture

+ 1207 - 0
gps-test-tool/gui.c

@@ -0,0 +1,1207 @@
+/*
+ * 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 <gtk/gtk.h>
+#include <libsoup/soup.h>
+#include <glib/gstdio.h>
+#include <glib-unix.h>
+#include <signal.h>
+#include <string.h>
+#include <math.h>
+
+#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 <trkseg> */
+	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("<b>%s, %s</b>  (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, "<trkseg>", 8)) {
+			seg = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
+			g_ptr_array_add(app.segments, seg);
+		} else if (!strncmp(s, "<trkpt ", 7) && seg) {
+			const char *la = strstr(s, "lat=\""), *lo = strstr(s, "lon=\""), *end = strchr(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;
+}