gui.c 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  1. /*
  2. * GPS Test Tool - GUI front-end
  3. *
  4. * Plots an NMEA log (input) and the GPX track the embedded code produces
  5. * from it (output) on an OpenStreetMap background. The GPX is generated by
  6. * running the command-line gps-test-tool (-o single-run mode) with the
  7. * settings chosen in the side panel; every change re-runs it.
  8. *
  9. * Usage: ./gps-test-tool-gui [input.nmea]
  10. */
  11. #include <gtk/gtk.h>
  12. #include <libsoup/soup.h>
  13. #include <glib/gstdio.h>
  14. #include <glib-unix.h>
  15. #include <signal.h>
  16. #include <string.h>
  17. #include <math.h>
  18. #define TILE_SIZE 256
  19. #define MIN_ZOOM 2
  20. #define MAX_ZOOM 19
  21. #define TILE_URL "https://tile.openstreetmap.org/%d/%d/%d.png"
  22. #define USER_AGENT "GPStracker-gps-test-tool-gui/1.0"
  23. #define MAX_DOWNLOADS 2 /* OSM tile usage policy: at most 2 connections */
  24. #define MEM_CACHE_MAX 600 /* tiles kept in memory before evicting unused ones */
  25. #define TILE_MAX_AGE (30 * 24 * 3600) /* re-download disk-cached tiles older than this */
  26. #define FALLBACK_LEVELS 6 /* how many zoom levels up to look for a placeholder tile */
  27. #define CLICK_SLOP 4 /* pixels a press may move and still count as a click */
  28. #define DOTS_ZOOM 16 /* draw individual points from this zoom level up */
  29. #define REPROCESS_DELAY_MS 150
  30. /* A track point in normalized Web Mercator coordinates (0..1 both ways) */
  31. struct mpoint {
  32. double x, y;
  33. };
  34. enum {
  35. DL_IDLE,
  36. DL_QUEUED,
  37. DL_LOADING,
  38. DL_FAILED,
  39. DL_DONE,
  40. };
  41. struct tile {
  42. int z, x, y;
  43. cairo_surface_t *surface;
  44. unsigned char dl_state;
  45. unsigned char stale;
  46. guint last_used;
  47. };
  48. struct dl_request {
  49. struct tile *tile;
  50. SoupMessage *msg;
  51. };
  52. struct setting_spin {
  53. const char *key;
  54. const char *label;
  55. const char *cli_opt;
  56. int min, max, def;
  57. GtkWidget *widget;
  58. };
  59. /* Ranges match soft/settings.c's limits_min_u8/limits_max_u8; defaults
  60. * match the command-line tool's. */
  61. static struct setting_spin spins[] = {
  62. { "skip_points", "Skip points", "-k", 0, 120, 0, NULL },
  63. { "min_sats", "Min. satellites", "-m", 4, 12, 4, NULL },
  64. { "auto_pause_time", "Time (s)", "-t", 10, 120, 30, NULL },
  65. { "auto_pause_dist", "Distance (m)", "-d", 2, 100, 10, NULL },
  66. { "auto_pause_speed", "Speed (km/h)", "-s", 0, 20, 3, NULL },
  67. };
  68. #define SPIN_SKIP 0
  69. #define SPIN_MIN_SATS 1
  70. #define SPIN_AP_FIRST 2
  71. #define SPIN_COUNT (sizeof(spins) / sizeof(spins[0]))
  72. /* Per-segment colours for the GPX output */
  73. static const double seg_colors[][3] = {
  74. { 0.12, 0.47, 0.71 },
  75. { 1.00, 0.50, 0.05 },
  76. { 0.17, 0.63, 0.17 },
  77. { 0.84, 0.15, 0.16 },
  78. { 0.58, 0.40, 0.74 },
  79. { 0.09, 0.75, 0.81 },
  80. { 0.89, 0.47, 0.76 },
  81. { 0.55, 0.34, 0.29 },
  82. { 0.74, 0.74, 0.13 },
  83. };
  84. #define SEG_COLOR_COUNT (sizeof(seg_colors) / sizeof(seg_colors[0]))
  85. static struct {
  86. GtkWidget *window, *map, *coord_label, *status_label, *summary_label;
  87. GtkWidget *save_button;
  88. GtkWidget *chk_disable_filters, *chk_auto_pause, *chk_show_nmea, *chk_show_gpx;
  89. gboolean loading_settings;
  90. char *input_file;
  91. GArray *nmea; /* struct mpoint */
  92. GPtrArray *segments; /* GArray of struct mpoint, one per <trkseg> */
  93. gsize gpx_points;
  94. char *cli_path;
  95. char *tmpdir, *out_gpx, *out_log;
  96. gboolean have_result;
  97. GPid child;
  98. gboolean running, pending;
  99. guint reprocess_id;
  100. int zoom;
  101. double cx, cy; /* view centre in world pixels at the current zoom */
  102. gboolean need_fit;
  103. gboolean pressed, dragging;
  104. double press_x, press_y, last_x, last_y;
  105. double scroll_accum;
  106. gboolean have_cursor, frozen;
  107. double cursor_mx, cursor_my, frozen_mx, frozen_my;
  108. GHashTable *tiles; /* "z/x/y" -> struct tile */
  109. GQueue dl_queue; /* struct tile, DL_QUEUED */
  110. int dl_active;
  111. guint frame;
  112. SoupSession *soup;
  113. char *cache_dir;
  114. GKeyFile *config;
  115. char *config_path;
  116. } app;
  117. /* ===== Projection ===== */
  118. static double world_size(int zoom) {
  119. return TILE_SIZE * (double)(1 << zoom);
  120. }
  121. static void latlon_to_merc(double lat, double lon, struct mpoint *p) {
  122. double rlat = lat * M_PI / 180.0;
  123. p->x = (lon + 180.0) / 360.0;
  124. p->y = (1.0 - log(tan(rlat) + 1.0 / cos(rlat)) / M_PI) / 2.0;
  125. }
  126. static void merc_to_latlon(double x, double y, double *lat, double *lon) {
  127. *lon = x * 360.0 - 180.0;
  128. *lat = atan(sinh(M_PI * (1.0 - 2.0 * y))) * 180.0 / M_PI;
  129. }
  130. static void screen_to_merc(double sx, double sy, double *mx, double *my) {
  131. double ws = world_size(app.zoom);
  132. /* Same whole-pixel origin as map_draw() */
  133. *mx = (floor(app.cx - gtk_widget_get_allocated_width(app.map) / 2.0) + sx) / ws;
  134. *my = (floor(app.cy - gtk_widget_get_allocated_height(app.map) / 2.0) + sy) / ws;
  135. }
  136. static void clamp_view(void) {
  137. double ws = world_size(app.zoom);
  138. if (app.cy < 0)
  139. app.cy = 0;
  140. if (app.cy > ws)
  141. app.cy = ws;
  142. app.cx = fmod(app.cx, ws);
  143. if (app.cx < 0)
  144. app.cx += ws;
  145. }
  146. /* ===== Config ===== */
  147. static void config_save(void) {
  148. char *dir = g_path_get_dirname(app.config_path);
  149. g_mkdir_with_parents(dir, 0755);
  150. g_free(dir);
  151. g_key_file_save_to_file(app.config, app.config_path, NULL);
  152. }
  153. static int config_get_int(const char *group, const char *key, int def) {
  154. GError *err = NULL;
  155. int v = g_key_file_get_integer(app.config, group, key, &err);
  156. if (err) {
  157. g_error_free(err);
  158. return def;
  159. }
  160. return v;
  161. }
  162. static void settings_store(void) {
  163. unsigned i;
  164. if (app.loading_settings)
  165. return;
  166. for (i = 0; i < SPIN_COUNT; i++)
  167. g_key_file_set_integer(app.config, "settings", spins[i].key,
  168. gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget)));
  169. g_key_file_set_boolean(app.config, "settings", "disable_filters",
  170. gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters)));
  171. g_key_file_set_boolean(app.config, "settings", "auto_pause",
  172. gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
  173. g_key_file_set_boolean(app.config, "view", "show_nmea",
  174. gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea)));
  175. g_key_file_set_boolean(app.config, "view", "show_gpx",
  176. gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)));
  177. config_save();
  178. }
  179. /* ===== Tiles ===== */
  180. static char *tile_cache_path(int z, int x, int y) {
  181. return g_strdup_printf("%s/%d/%d/%d.png", app.cache_dir, z, x, y);
  182. }
  183. static cairo_surface_t *surface_from_pixbuf(GdkPixbuf *pix) {
  184. cairo_surface_t *s;
  185. cairo_t *cr;
  186. if (!pix)
  187. return NULL;
  188. s = cairo_image_surface_create(CAIRO_FORMAT_RGB24, TILE_SIZE, TILE_SIZE);
  189. cr = cairo_create(s);
  190. gdk_cairo_set_source_pixbuf(cr, pix, 0, 0);
  191. cairo_paint(cr);
  192. cairo_destroy(cr);
  193. g_object_unref(pix);
  194. return s;
  195. }
  196. static void tile_free(gpointer data) {
  197. struct tile *t = data;
  198. if (t->surface)
  199. cairo_surface_destroy(t->surface);
  200. g_free(t);
  201. }
  202. static gboolean tile_visible(struct tile *t) {
  203. int w = gtk_widget_get_allocated_width(app.map);
  204. int h = gtk_widget_get_allocated_height(app.map);
  205. int n = 1 << app.zoom;
  206. int x0, x1, y0, y1, x;
  207. if (t->z != app.zoom)
  208. return FALSE;
  209. x0 = (int)floor((app.cx - w / 2.0) / TILE_SIZE);
  210. x1 = (int)floor((app.cx + w / 2.0) / TILE_SIZE);
  211. y0 = (int)floor((app.cy - h / 2.0) / TILE_SIZE);
  212. y1 = (int)floor((app.cy + h / 2.0) / TILE_SIZE);
  213. if (t->y < y0 || t->y > y1)
  214. return FALSE;
  215. for (x = x0; x <= x1; x++)
  216. if (((x % n) + n) % n == t->x)
  217. return TRUE;
  218. return FALSE;
  219. }
  220. static void dl_pump(void);
  221. static void dl_finished(GObject *source, GAsyncResult *res, gpointer data) {
  222. struct dl_request *req = data;
  223. struct tile *t = req->tile;
  224. GError *err = NULL;
  225. GBytes *bytes = soup_session_send_and_read_finish(SOUP_SESSION(source), res, &err);
  226. app.dl_active--;
  227. t->dl_state = DL_FAILED;
  228. if (bytes && soup_message_get_status(req->msg) == SOUP_STATUS_OK) {
  229. GInputStream *st = g_memory_input_stream_new_from_bytes(bytes);
  230. GdkPixbuf *pix = gdk_pixbuf_new_from_stream(st, NULL, NULL);
  231. g_object_unref(st);
  232. if (pix) {
  233. char *path = tile_cache_path(t->z, t->x, t->y);
  234. char *dir = g_path_get_dirname(path);
  235. g_mkdir_with_parents(dir, 0755);
  236. g_file_set_contents(path, g_bytes_get_data(bytes, NULL), g_bytes_get_size(bytes), NULL);
  237. g_free(dir);
  238. g_free(path);
  239. if (t->surface)
  240. cairo_surface_destroy(t->surface);
  241. t->surface = surface_from_pixbuf(pix);
  242. t->stale = 0;
  243. t->dl_state = DL_DONE;
  244. }
  245. } else if (err) {
  246. g_warning("Tile %d/%d/%d: %s", t->z, t->x, t->y, err->message);
  247. } else {
  248. g_warning("Tile %d/%d/%d: HTTP %u", t->z, t->x, t->y, soup_message_get_status(req->msg));
  249. }
  250. if (err)
  251. g_error_free(err);
  252. if (bytes)
  253. g_bytes_unref(bytes);
  254. g_object_unref(req->msg);
  255. g_free(req);
  256. gtk_widget_queue_draw(app.map);
  257. dl_pump();
  258. }
  259. static void dl_pump(void) {
  260. while (app.dl_active < MAX_DOWNLOADS && !g_queue_is_empty(&app.dl_queue)) {
  261. struct tile *t = g_queue_pop_head(&app.dl_queue);
  262. struct dl_request *req;
  263. char *url;
  264. if (!tile_visible(t)) {
  265. t->dl_state = DL_IDLE; /* may be queued again once it's back in view */
  266. continue;
  267. }
  268. url = g_strdup_printf(TILE_URL, t->z, t->x, t->y);
  269. req = g_new0(struct dl_request, 1);
  270. req->tile = t;
  271. req->msg = soup_message_new("GET", url);
  272. g_free(url);
  273. t->dl_state = DL_LOADING;
  274. app.dl_active++;
  275. soup_session_send_and_read_async(app.soup, req->msg, G_PRIORITY_DEFAULT, NULL, dl_finished, req);
  276. }
  277. }
  278. /* Look up a tile, loading it from the disk cache if needed. With fetch set,
  279. * a missing or stale tile is queued for download. */
  280. static struct tile *tile_get(int z, int x, int y, gboolean fetch) {
  281. char key[48];
  282. struct tile *t;
  283. g_snprintf(key, sizeof(key), "%d/%d/%d", z, x, y);
  284. t = g_hash_table_lookup(app.tiles, key);
  285. if (!t) {
  286. char *path = tile_cache_path(z, x, y);
  287. GStatBuf st;
  288. t = g_new0(struct tile, 1);
  289. t->z = z;
  290. t->x = x;
  291. t->y = y;
  292. if (g_stat(path, &st) == 0) {
  293. t->surface = surface_from_pixbuf(gdk_pixbuf_new_from_file(path, NULL));
  294. t->stale = time(NULL) - st.st_mtime > TILE_MAX_AGE;
  295. }
  296. g_free(path);
  297. g_hash_table_insert(app.tiles, g_strdup(key), t);
  298. }
  299. t->last_used = app.frame;
  300. if (fetch && (!t->surface || t->stale) && t->dl_state == DL_IDLE) {
  301. t->dl_state = DL_QUEUED;
  302. g_queue_push_tail(&app.dl_queue, t);
  303. }
  304. return t;
  305. }
  306. static gboolean tile_evictable(gpointer key, gpointer value, gpointer data) {
  307. struct tile *t = value;
  308. (void)key;
  309. (void)data;
  310. return t->dl_state != DL_QUEUED && t->dl_state != DL_LOADING && t->last_used + 1 < app.frame;
  311. }
  312. static void draw_tile(cairo_t *cr, int z, int x, int y, double sx, double sy) {
  313. struct tile *t = tile_get(z, x, y, TRUE);
  314. int d;
  315. if (t->surface) {
  316. cairo_set_source_surface(cr, t->surface, sx, sy);
  317. cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE);
  318. cairo_fill(cr);
  319. return;
  320. }
  321. /* Not available (yet): stretch a lower-zoom tile we already have */
  322. for (d = 1; d <= FALLBACK_LEVELS && z - d >= 0; d++) {
  323. struct tile *p = tile_get(z - d, x >> d, y >> d, FALSE);
  324. if (p->surface) {
  325. double scale = 1 << d;
  326. cairo_save(cr);
  327. cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE);
  328. cairo_clip(cr);
  329. cairo_translate(cr, sx - (x - ((x >> d) << d)) * TILE_SIZE,
  330. sy - (y - ((y >> d) << d)) * TILE_SIZE);
  331. cairo_scale(cr, scale, scale);
  332. cairo_set_source_surface(cr, p->surface, 0, 0);
  333. cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR);
  334. cairo_paint(cr);
  335. cairo_restore(cr);
  336. return;
  337. }
  338. }
  339. }
  340. /* ===== Drawing ===== */
  341. static void draw_track(cairo_t *cr, GArray *pts, double ox, double oy, double ws,
  342. double r, double g, double b, double a, double width, gboolean dots) {
  343. guint i;
  344. if (!pts || !pts->len)
  345. return;
  346. cairo_set_source_rgba(cr, r, g, b, a);
  347. cairo_set_line_width(cr, width);
  348. cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
  349. cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
  350. for (i = 0; i < pts->len; i++) {
  351. struct mpoint *p = &g_array_index(pts, struct mpoint, i);
  352. double sx = p->x * ws - ox, sy = p->y * ws - oy;
  353. if (i)
  354. cairo_line_to(cr, sx, sy);
  355. else
  356. cairo_move_to(cr, sx, sy);
  357. }
  358. if (pts->len == 1)
  359. cairo_rel_line_to(cr, 0, 0); /* round cap makes it a dot */
  360. cairo_stroke(cr);
  361. if (!dots)
  362. return;
  363. for (i = 0; i < pts->len; i++) {
  364. struct mpoint *p = &g_array_index(pts, struct mpoint, i);
  365. cairo_new_sub_path(cr);
  366. cairo_arc(cr, p->x * ws - ox, p->y * ws - oy, width * 0.9, 0, 2 * M_PI);
  367. }
  368. cairo_fill(cr);
  369. }
  370. static void fit_view(void);
  371. static gboolean map_draw(GtkWidget *w, cairo_t *cr, gpointer data) {
  372. int width = gtk_widget_get_allocated_width(w);
  373. int height = gtk_widget_get_allocated_height(w);
  374. double ws, ox, oy;
  375. int n, tx0, tx1, ty0, ty1, tx, ty;
  376. gboolean dots;
  377. cairo_text_extents_t ext;
  378. const char *attrib = "© OpenStreetMap contributors";
  379. (void)data;
  380. if (app.need_fit)
  381. fit_view();
  382. app.frame++;
  383. if (g_hash_table_size(app.tiles) > MEM_CACHE_MAX)
  384. g_hash_table_foreach_remove(app.tiles, tile_evictable, NULL);
  385. ws = world_size(app.zoom);
  386. n = 1 << app.zoom;
  387. /* Whole pixels: tiles drawn at fractional offsets show seams */
  388. ox = floor(app.cx - width / 2.0);
  389. oy = floor(app.cy - height / 2.0);
  390. cairo_set_source_rgb(cr, 0.85, 0.85, 0.85);
  391. cairo_paint(cr);
  392. tx0 = (int)floor(ox / TILE_SIZE);
  393. tx1 = (int)floor((ox + width) / TILE_SIZE);
  394. ty0 = MAX(0, (int)floor(oy / TILE_SIZE));
  395. ty1 = MIN(n - 1, (int)floor((oy + height) / TILE_SIZE));
  396. for (ty = ty0; ty <= ty1; ty++)
  397. for (tx = tx0; tx <= tx1; tx++)
  398. draw_tile(cr, app.zoom, ((tx % n) + n) % n, ty, tx * TILE_SIZE - ox, ty * TILE_SIZE - oy);
  399. dl_pump();
  400. dots = app.zoom >= DOTS_ZOOM;
  401. if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)) && app.segments) {
  402. guint i;
  403. for (i = 0; i < app.segments->len; i++) {
  404. const double *c = seg_colors[i % SEG_COLOR_COUNT];
  405. draw_track(cr, g_ptr_array_index(app.segments, i), ox, oy, ws,
  406. c[0], c[1], c[2], 0.85, 4.0, dots);
  407. }
  408. }
  409. /* Input on top, thin, so it stays visible where the output follows it */
  410. if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea)))
  411. draw_track(cr, app.nmea, ox, oy, ws, 0.1, 0.1, 0.1, 0.75, 1.2, dots);
  412. if (app.frozen) {
  413. double sx = app.frozen_mx * ws - ox, sy = app.frozen_my * ws - oy;
  414. cairo_set_line_width(cr, 2.0);
  415. cairo_set_source_rgb(cr, 1, 1, 1);
  416. cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI);
  417. cairo_stroke(cr);
  418. cairo_set_line_width(cr, 1.5);
  419. cairo_set_source_rgb(cr, 0.8, 0, 0);
  420. cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI);
  421. cairo_move_to(cr, sx - 11, sy);
  422. cairo_line_to(cr, sx + 11, sy);
  423. cairo_move_to(cr, sx, sy - 11);
  424. cairo_line_to(cr, sx, sy + 11);
  425. cairo_stroke(cr);
  426. }
  427. /* Attribution, required by the OSM tile usage policy */
  428. cairo_set_font_size(cr, 11);
  429. cairo_text_extents(cr, attrib, &ext);
  430. cairo_set_source_rgba(cr, 1, 1, 1, 0.75);
  431. cairo_rectangle(cr, width - ext.x_advance - 8, height - 17, ext.x_advance + 8, 17);
  432. cairo_fill(cr);
  433. cairo_set_source_rgb(cr, 0.2, 0.2, 0.2);
  434. cairo_move_to(cr, width - ext.x_advance - 4, height - 5);
  435. cairo_show_text(cr, attrib);
  436. return TRUE;
  437. }
  438. /* ===== Coordinate bar ===== */
  439. static void update_coord_label(void) {
  440. double mx, my, lat, lon;
  441. char slat[G_ASCII_DTOSTR_BUF_SIZE], slon[G_ASCII_DTOSTR_BUF_SIZE];
  442. char *text;
  443. if (app.frozen || app.have_cursor) {
  444. mx = app.frozen ? app.frozen_mx : app.cursor_mx;
  445. my = app.frozen ? app.frozen_my : app.cursor_my;
  446. merc_to_latlon(mx, my, &lat, &lon);
  447. /* Always '.' as the decimal separator, whatever the locale */
  448. g_ascii_formatd(slat, sizeof(slat), "%.6f", lat);
  449. g_ascii_formatd(slon, sizeof(slon), "%.6f", lon);
  450. if (app.frozen)
  451. text = g_strdup_printf("<b>%s, %s</b> (frozen \u2013 right-click to release) zoom %d", slat, slon, app.zoom);
  452. else
  453. text = g_strdup_printf("%s, %s zoom %d", slat, slon, app.zoom);
  454. } else {
  455. text = g_strdup_printf("\u2013 zoom %d", app.zoom);
  456. }
  457. gtk_label_set_markup(GTK_LABEL(app.coord_label), text);
  458. g_free(text);
  459. }
  460. /* ===== View ===== */
  461. static gboolean bbox_add(GArray *a, double *x0, double *y0, double *x1, double *y1) {
  462. guint i;
  463. for (i = 0; a && i < a->len; i++) {
  464. struct mpoint *p = &g_array_index(a, struct mpoint, i);
  465. *x0 = MIN(*x0, p->x);
  466. *y0 = MIN(*y0, p->y);
  467. *x1 = MAX(*x1, p->x);
  468. *y1 = MAX(*y1, p->y);
  469. }
  470. return a && a->len;
  471. }
  472. static gboolean track_bbox(double *x0, double *y0, double *x1, double *y1) {
  473. gboolean any;
  474. guint i;
  475. *x0 = *y0 = 1.0;
  476. *x1 = *y1 = 0.0;
  477. any = bbox_add(app.nmea, x0, y0, x1, y1);
  478. for (i = 0; app.segments && i < app.segments->len; i++)
  479. any |= bbox_add(g_ptr_array_index(app.segments, i), x0, y0, x1, y1);
  480. return any;
  481. }
  482. static void fit_view(void) {
  483. int w = gtk_widget_get_allocated_width(app.map);
  484. int h = gtk_widget_get_allocated_height(app.map);
  485. double x0, y0, x1, y1;
  486. if (w <= 1 || h <= 1)
  487. return; /* not realized yet; retried on the next draw */
  488. app.need_fit = FALSE;
  489. if (!track_bbox(&x0, &y0, &x1, &y1))
  490. return;
  491. for (app.zoom = MAX_ZOOM; app.zoom > MIN_ZOOM; app.zoom--) {
  492. double ws = world_size(app.zoom);
  493. if ((x1 - x0) * ws <= w - 40 && (y1 - y0) * ws <= h - 40)
  494. break;
  495. }
  496. app.cx = (x0 + x1) / 2.0 * world_size(app.zoom);
  497. app.cy = (y0 + y1) / 2.0 * world_size(app.zoom);
  498. update_coord_label();
  499. }
  500. static void zoom_at(int new_zoom, double sx, double sy) {
  501. int w = gtk_widget_get_allocated_width(app.map);
  502. int h = gtk_widget_get_allocated_height(app.map);
  503. double f;
  504. new_zoom = CLAMP(new_zoom, MIN_ZOOM, MAX_ZOOM);
  505. if (new_zoom == app.zoom)
  506. return;
  507. f = ldexp(1.0, new_zoom - app.zoom);
  508. app.cx = (app.cx + sx - w / 2.0) * f - (sx - w / 2.0);
  509. app.cy = (app.cy + sy - h / 2.0) * f - (sy - h / 2.0);
  510. app.zoom = new_zoom;
  511. clamp_view();
  512. update_coord_label();
  513. gtk_widget_queue_draw(app.map);
  514. }
  515. static gboolean map_scroll(GtkWidget *w, GdkEventScroll *ev, gpointer data) {
  516. double dx, dy;
  517. (void)w;
  518. (void)data;
  519. switch (ev->direction) {
  520. case GDK_SCROLL_UP:
  521. zoom_at(app.zoom + 1, ev->x, ev->y);
  522. break;
  523. case GDK_SCROLL_DOWN:
  524. zoom_at(app.zoom - 1, ev->x, ev->y);
  525. break;
  526. case GDK_SCROLL_SMOOTH:
  527. if (!gdk_event_get_scroll_deltas((GdkEvent *)ev, &dx, &dy))
  528. break;
  529. app.scroll_accum += dy;
  530. while (app.scroll_accum <= -1.0) {
  531. app.scroll_accum += 1.0;
  532. zoom_at(app.zoom + 1, ev->x, ev->y);
  533. }
  534. while (app.scroll_accum >= 1.0) {
  535. app.scroll_accum -= 1.0;
  536. zoom_at(app.zoom - 1, ev->x, ev->y);
  537. }
  538. break;
  539. default:
  540. break;
  541. }
  542. return TRUE;
  543. }
  544. static gboolean map_button_press(GtkWidget *w, GdkEventButton *ev, gpointer data) {
  545. (void)w;
  546. (void)data;
  547. if (ev->type != GDK_BUTTON_PRESS)
  548. return FALSE;
  549. if (ev->button == 1) {
  550. app.pressed = TRUE;
  551. app.dragging = FALSE;
  552. app.press_x = app.last_x = ev->x;
  553. app.press_y = app.last_y = ev->y;
  554. } else if (ev->button == 3 && app.frozen) {
  555. app.frozen = FALSE;
  556. update_coord_label();
  557. gtk_widget_queue_draw(app.map);
  558. }
  559. return TRUE;
  560. }
  561. static gboolean map_button_release(GtkWidget *w, GdkEventButton *ev, gpointer data) {
  562. (void)w;
  563. (void)data;
  564. if (ev->button != 1 || !app.pressed)
  565. return FALSE;
  566. app.pressed = FALSE;
  567. if (!app.dragging) {
  568. screen_to_merc(ev->x, ev->y, &app.frozen_mx, &app.frozen_my);
  569. app.frozen = TRUE;
  570. update_coord_label();
  571. gtk_widget_queue_draw(app.map);
  572. }
  573. app.dragging = FALSE;
  574. return TRUE;
  575. }
  576. static gboolean map_motion(GtkWidget *w, GdkEventMotion *ev, gpointer data) {
  577. (void)w;
  578. (void)data;
  579. if (app.pressed) {
  580. if (!app.dragging && (fabs(ev->x - app.press_x) > CLICK_SLOP || fabs(ev->y - app.press_y) > CLICK_SLOP))
  581. app.dragging = TRUE;
  582. if (app.dragging) {
  583. app.cx -= ev->x - app.last_x;
  584. app.cy -= ev->y - app.last_y;
  585. clamp_view();
  586. gtk_widget_queue_draw(app.map);
  587. }
  588. app.last_x = ev->x;
  589. app.last_y = ev->y;
  590. }
  591. screen_to_merc(ev->x, ev->y, &app.cursor_mx, &app.cursor_my);
  592. app.have_cursor = TRUE;
  593. if (!app.frozen)
  594. update_coord_label();
  595. return TRUE;
  596. }
  597. static gboolean map_leave(GtkWidget *w, GdkEventCrossing *ev, gpointer data) {
  598. (void)w;
  599. (void)ev;
  600. (void)data;
  601. app.have_cursor = FALSE;
  602. if (!app.frozen)
  603. update_coord_label();
  604. return FALSE;
  605. }
  606. /* ===== Input / output parsing ===== */
  607. static double nmea_coord(const char *val, const char *hemi) {
  608. double v = g_ascii_strtod(val, NULL);
  609. double deg = floor(v / 100.0);
  610. v = deg + (v - deg * 100.0) / 60.0;
  611. return (*hemi == 'S' || *hemi == 'W') ? -v : v;
  612. }
  613. /* Extract a position from a valid, checksummed $..RMC sentence */
  614. static gboolean nmea_rmc_point(const char *line, struct mpoint *p) {
  615. const char *s = strchr(line, '$'), *c;
  616. char **f;
  617. unsigned char sum = 0;
  618. unsigned int want;
  619. gboolean ok = FALSE;
  620. char *body;
  621. if (!s || strncmp(s + 3, "RMC,", 4))
  622. return FALSE;
  623. for (c = s + 1; *c && *c != '*'; c++)
  624. sum ^= (unsigned char)*c;
  625. if (*c != '*' || sscanf(c + 1, "%2x", &want) != 1 || want != sum)
  626. return FALSE;
  627. body = g_strndup(s + 1, c - s - 1);
  628. f = g_strsplit(body, ",", 0);
  629. if (g_strv_length(f) >= 7 && !strcmp(f[2], "A") && *f[3] && *f[5]) {
  630. latlon_to_merc(nmea_coord(f[3], f[4]), nmea_coord(f[5], f[6]), p);
  631. ok = TRUE;
  632. }
  633. g_strfreev(f);
  634. g_free(body);
  635. return ok;
  636. }
  637. static gboolean load_nmea(const char *path) {
  638. char *data, **lines;
  639. guint i;
  640. GError *err = NULL;
  641. if (!g_file_get_contents(path, &data, NULL, &err)) {
  642. GtkWidget *d = gtk_message_dialog_new(GTK_WINDOW(app.window), GTK_DIALOG_MODAL,
  643. GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot open %s: %s", path, err->message);
  644. gtk_dialog_run(GTK_DIALOG(d));
  645. gtk_widget_destroy(d);
  646. g_error_free(err);
  647. return FALSE;
  648. }
  649. if (app.nmea)
  650. g_array_free(app.nmea, TRUE);
  651. app.nmea = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
  652. lines = g_strsplit(data, "\n", 0);
  653. for (i = 0; lines[i]; i++) {
  654. struct mpoint p;
  655. if (nmea_rmc_point(lines[i], &p))
  656. g_array_append_val(app.nmea, p);
  657. }
  658. g_strfreev(lines);
  659. g_free(data);
  660. return TRUE;
  661. }
  662. static void free_segments(void) {
  663. if (app.segments)
  664. g_ptr_array_free(app.segments, TRUE);
  665. app.segments = NULL;
  666. app.gpx_points = 0;
  667. }
  668. static void load_gpx(const char *path) {
  669. char *data, *s;
  670. GArray *seg = NULL;
  671. free_segments();
  672. app.segments = g_ptr_array_new_with_free_func((GDestroyNotify)g_array_unref);
  673. if (!g_file_get_contents(path, &data, NULL, NULL))
  674. return;
  675. for (s = data; (s = strchr(s, '<')); s++) {
  676. if (!strncmp(s, "<trkseg>", 8)) {
  677. seg = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
  678. g_ptr_array_add(app.segments, seg);
  679. } else if (!strncmp(s, "<trkpt ", 7) && seg) {
  680. const char *la = strstr(s, "lat=\""), *lo = strstr(s, "lon=\""), *end = strchr(s, '>');
  681. struct mpoint p;
  682. if (!la || !lo || !end || la > end || lo > end)
  683. continue;
  684. latlon_to_merc(g_ascii_strtod(la + 5, NULL), g_ascii_strtod(lo + 5, NULL), &p);
  685. g_array_append_val(seg, p);
  686. app.gpx_points++;
  687. }
  688. }
  689. g_free(data);
  690. }
  691. static void load_summary(const char *path) {
  692. char *data, *s;
  693. if (!g_file_get_contents(path, &data, NULL, NULL)) {
  694. gtk_label_set_text(GTK_LABEL(app.summary_label), "");
  695. return;
  696. }
  697. s = strstr(data, "=== Summary ===\n");
  698. gtk_label_set_text(GTK_LABEL(app.summary_label), s ? g_strstrip(s + 16) : "");
  699. g_free(data);
  700. }
  701. /* ===== Processing (runs the command-line tool) ===== */
  702. static void set_status(const char *text) {
  703. gtk_label_set_text(GTK_LABEL(app.status_label), text);
  704. }
  705. static void start_processing(void);
  706. static void child_exited(GPid pid, gint status, gpointer data) {
  707. GError *err = NULL;
  708. (void)data;
  709. g_spawn_close_pid(pid);
  710. app.running = FALSE;
  711. if (app.pending) {
  712. start_processing();
  713. return;
  714. }
  715. if (!g_spawn_check_wait_status(status, &err)) {
  716. char *msg = g_strdup_printf("Processing failed: %s", err->message);
  717. set_status(msg);
  718. g_free(msg);
  719. g_error_free(err);
  720. app.have_result = FALSE;
  721. free_segments();
  722. } else {
  723. char *msg;
  724. load_gpx(app.out_gpx);
  725. load_summary(app.out_log);
  726. app.have_result = TRUE;
  727. msg = g_strdup_printf("%u NMEA fixes → %" G_GSIZE_FORMAT " GPX points in %u segment(s)",
  728. app.nmea ? app.nmea->len : 0, app.gpx_points, app.segments->len);
  729. set_status(msg);
  730. g_free(msg);
  731. }
  732. gtk_widget_set_sensitive(app.save_button, app.have_result);
  733. gtk_widget_queue_draw(app.map);
  734. }
  735. static void start_processing(void) {
  736. GPtrArray *argv;
  737. GError *err = NULL;
  738. char *vals[SPIN_COUNT];
  739. unsigned i;
  740. if (!app.input_file)
  741. return;
  742. if (app.running) {
  743. /* Restart with the new settings once this run is gone */
  744. app.pending = TRUE;
  745. kill(app.child, SIGTERM);
  746. return;
  747. }
  748. app.pending = FALSE;
  749. argv = g_ptr_array_new();
  750. g_ptr_array_add(argv, app.cli_path);
  751. g_ptr_array_add(argv, "-o");
  752. g_ptr_array_add(argv, app.out_gpx);
  753. g_ptr_array_add(argv, "-l");
  754. g_ptr_array_add(argv, app.out_log);
  755. if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters)))
  756. g_ptr_array_add(argv, "-n");
  757. if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)))
  758. g_ptr_array_add(argv, "-A");
  759. for (i = 0; i < SPIN_COUNT; i++) {
  760. vals[i] = g_strdup_printf("%d", gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget)));
  761. g_ptr_array_add(argv, (gpointer)spins[i].cli_opt);
  762. g_ptr_array_add(argv, vals[i]);
  763. }
  764. g_ptr_array_add(argv, "--");
  765. g_ptr_array_add(argv, app.input_file);
  766. g_ptr_array_add(argv, NULL);
  767. if (g_spawn_async(NULL, (char **)argv->pdata, NULL,
  768. G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_STDOUT_TO_DEV_NULL,
  769. NULL, NULL, &app.child, &err)) {
  770. app.running = TRUE;
  771. g_child_watch_add(app.child, child_exited, NULL);
  772. set_status("Processing…");
  773. } else {
  774. char *msg = g_strdup_printf("Cannot run %s: %s", app.cli_path, err->message);
  775. set_status(msg);
  776. g_free(msg);
  777. g_error_free(err);
  778. }
  779. for (i = 0; i < SPIN_COUNT; i++)
  780. g_free(vals[i]);
  781. g_ptr_array_free(argv, TRUE);
  782. }
  783. static gboolean reprocess_timeout(gpointer data) {
  784. (void)data;
  785. app.reprocess_id = 0;
  786. start_processing();
  787. return G_SOURCE_REMOVE;
  788. }
  789. static void setting_changed(GtkWidget *w, gpointer data) {
  790. unsigned i;
  791. (void)w;
  792. (void)data;
  793. if (app.loading_settings)
  794. return;
  795. for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++)
  796. gtk_widget_set_sensitive(spins[i].widget,
  797. gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
  798. settings_store();
  799. /* Debounced: holding a spin button's arrow fires many changes */
  800. if (app.reprocess_id)
  801. g_source_remove(app.reprocess_id);
  802. app.reprocess_id = g_timeout_add(REPROCESS_DELAY_MS, reprocess_timeout, NULL);
  803. }
  804. static void view_changed(GtkWidget *w, gpointer data) {
  805. (void)w;
  806. (void)data;
  807. settings_store();
  808. gtk_widget_queue_draw(app.map);
  809. }
  810. /* ===== File handling ===== */
  811. static void open_file(const char *path) {
  812. char *title, *base;
  813. if (!load_nmea(path))
  814. return;
  815. g_free(app.input_file);
  816. app.input_file = g_canonicalize_filename(path, NULL);
  817. g_key_file_set_string(app.config, "view", "input_file", app.input_file);
  818. config_save();
  819. base = g_path_get_basename(path);
  820. title = g_strdup_printf("%s – GPS Test Tool", base);
  821. gtk_window_set_title(GTK_WINDOW(app.window), title);
  822. g_free(title);
  823. g_free(base);
  824. free_segments();
  825. app.have_result = FALSE;
  826. gtk_widget_set_sensitive(app.save_button, FALSE);
  827. gtk_label_set_text(GTK_LABEL(app.summary_label), "");
  828. app.need_fit = TRUE;
  829. gtk_widget_queue_draw(app.map);
  830. start_processing();
  831. }
  832. static void open_clicked(GtkWidget *w, gpointer data) {
  833. GtkWidget *d;
  834. GtkFileFilter *f;
  835. (void)w;
  836. (void)data;
  837. d = gtk_file_chooser_dialog_new("Open NMEA log", GTK_WINDOW(app.window),
  838. GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL,
  839. "_Open", GTK_RESPONSE_ACCEPT, NULL);
  840. f = gtk_file_filter_new();
  841. gtk_file_filter_set_name(f, "NMEA logs");
  842. gtk_file_filter_add_pattern(f, "*.nmea");
  843. gtk_file_filter_add_pattern(f, "*.NMEA");
  844. gtk_file_filter_add_pattern(f, "*.log");
  845. gtk_file_filter_add_pattern(f, "*.LOG");
  846. gtk_file_filter_add_pattern(f, "*.txt");
  847. gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f);
  848. f = gtk_file_filter_new();
  849. gtk_file_filter_set_name(f, "All files");
  850. gtk_file_filter_add_pattern(f, "*");
  851. gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f);
  852. if (app.input_file) {
  853. char *dir = g_path_get_dirname(app.input_file);
  854. gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir);
  855. g_free(dir);
  856. }
  857. if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) {
  858. char *path = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(d));
  859. gtk_widget_destroy(d);
  860. open_file(path);
  861. g_free(path);
  862. return;
  863. }
  864. gtk_widget_destroy(d);
  865. }
  866. static void save_clicked(GtkWidget *w, gpointer data) {
  867. GtkWidget *d;
  868. (void)w;
  869. (void)data;
  870. if (!app.have_result)
  871. return;
  872. d = gtk_file_chooser_dialog_new("Save GPX", GTK_WINDOW(app.window),
  873. GTK_FILE_CHOOSER_ACTION_SAVE, "_Cancel", GTK_RESPONSE_CANCEL,
  874. "_Save", GTK_RESPONSE_ACCEPT, NULL);
  875. gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(d), TRUE);
  876. if (app.input_file) {
  877. char *dir = g_path_get_dirname(app.input_file);
  878. char *base = g_path_get_basename(app.input_file);
  879. char *dot = strrchr(base, '.');
  880. char *name;
  881. if (dot)
  882. *dot = '\0';
  883. name = g_strdup_printf("%s.gpx", base);
  884. gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir);
  885. gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(d), name);
  886. g_free(name);
  887. g_free(base);
  888. g_free(dir);
  889. }
  890. if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) {
  891. GFile *src = g_file_new_for_path(app.out_gpx);
  892. GFile *dst = gtk_file_chooser_get_file(GTK_FILE_CHOOSER(d));
  893. GError *err = NULL;
  894. if (!g_file_copy(src, dst, G_FILE_COPY_OVERWRITE, NULL, NULL, NULL, &err)) {
  895. GtkWidget *m = gtk_message_dialog_new(GTK_WINDOW(d), GTK_DIALOG_MODAL,
  896. GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot save: %s", err->message);
  897. gtk_dialog_run(GTK_DIALOG(m));
  898. gtk_widget_destroy(m);
  899. g_error_free(err);
  900. }
  901. g_object_unref(src);
  902. g_object_unref(dst);
  903. }
  904. gtk_widget_destroy(d);
  905. }
  906. static void fit_clicked(GtkWidget *w, gpointer data) {
  907. (void)w;
  908. (void)data;
  909. app.need_fit = TRUE;
  910. gtk_widget_queue_draw(app.map);
  911. }
  912. /* ===== Setup ===== */
  913. static char *find_cli(void) {
  914. char *self = g_file_read_link("/proc/self/exe", NULL);
  915. if (self) {
  916. char *dir = g_path_get_dirname(self);
  917. char *path = g_build_filename(dir, "gps-test-tool", NULL);
  918. g_free(dir);
  919. g_free(self);
  920. if (g_file_test(path, G_FILE_TEST_IS_EXECUTABLE))
  921. return path;
  922. g_free(path);
  923. }
  924. return g_find_program_in_path("gps-test-tool");
  925. }
  926. static GtkWidget *section(GtkWidget *box, const char *title) {
  927. GtkWidget *frame = gtk_frame_new(title);
  928. GtkWidget *inner = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
  929. gtk_container_set_border_width(GTK_CONTAINER(inner), 6);
  930. gtk_container_add(GTK_CONTAINER(frame), inner);
  931. gtk_box_pack_start(GTK_BOX(box), frame, FALSE, FALSE, 0);
  932. return inner;
  933. }
  934. static GtkWidget *check(GtkWidget *box, const char *label, const char *group, const char *key,
  935. gboolean def, GCallback cb) {
  936. GtkWidget *w = gtk_check_button_new_with_label(label);
  937. GError *err = NULL;
  938. gboolean v = g_key_file_get_boolean(app.config, group, key, &err);
  939. if (err) {
  940. g_error_free(err);
  941. v = def;
  942. }
  943. gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(w), v);
  944. g_signal_connect(w, "toggled", cb, NULL);
  945. gtk_box_pack_start(GTK_BOX(box), w, FALSE, FALSE, 0);
  946. return w;
  947. }
  948. static void spin(GtkWidget *box, struct setting_spin *s) {
  949. GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
  950. int v = CLAMP(config_get_int("settings", s->key, s->def), s->min, s->max);
  951. s->widget = gtk_spin_button_new_with_range(s->min, s->max, 1);
  952. gtk_spin_button_set_value(GTK_SPIN_BUTTON(s->widget), v);
  953. g_signal_connect(s->widget, "value-changed", G_CALLBACK(setting_changed), NULL);
  954. gtk_box_pack_start(GTK_BOX(row), gtk_label_new(s->label), FALSE, FALSE, 0);
  955. gtk_box_pack_end(GTK_BOX(row), s->widget, FALSE, FALSE, 0);
  956. gtk_box_pack_start(GTK_BOX(box), row, FALSE, FALSE, 0);
  957. }
  958. static void build_ui(void) {
  959. GtkWidget *header, *button, *hbox, *side, *sec, *vbox, *bar, *scroll;
  960. PangoAttrList *attrs;
  961. unsigned i;
  962. app.window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
  963. gtk_window_set_default_size(GTK_WINDOW(app.window),
  964. config_get_int("view", "width", 1200), config_get_int("view", "height", 800));
  965. g_signal_connect(app.window, "destroy", G_CALLBACK(gtk_main_quit), NULL);
  966. header = gtk_header_bar_new();
  967. gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), TRUE);
  968. gtk_header_bar_set_title(GTK_HEADER_BAR(header), "GPS Test Tool");
  969. button = gtk_button_new_with_mnemonic("_Open NMEA…");
  970. g_signal_connect(button, "clicked", G_CALLBACK(open_clicked), NULL);
  971. gtk_header_bar_pack_start(GTK_HEADER_BAR(header), button);
  972. app.save_button = gtk_button_new_with_mnemonic("_Save GPX…");
  973. gtk_widget_set_sensitive(app.save_button, FALSE);
  974. g_signal_connect(app.save_button, "clicked", G_CALLBACK(save_clicked), NULL);
  975. gtk_header_bar_pack_start(GTK_HEADER_BAR(header), app.save_button);
  976. gtk_window_set_titlebar(GTK_WINDOW(app.window), header);
  977. vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
  978. hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
  979. gtk_box_pack_start(GTK_BOX(vbox), hbox, TRUE, TRUE, 0);
  980. side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
  981. gtk_container_set_border_width(GTK_CONTAINER(side), 8);
  982. scroll = gtk_scrolled_window_new(NULL, NULL);
  983. gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
  984. gtk_container_add(GTK_CONTAINER(scroll), side);
  985. gtk_widget_set_size_request(scroll, 270, -1);
  986. gtk_box_pack_start(GTK_BOX(hbox), scroll, FALSE, FALSE, 0);
  987. gtk_box_pack_start(GTK_BOX(hbox), gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0);
  988. app.loading_settings = TRUE;
  989. sec = section(side, "Filtering");
  990. app.chk_disable_filters = check(sec, "Disable filters", "settings", "disable_filters", FALSE, G_CALLBACK(setting_changed));
  991. spin(sec, &spins[SPIN_SKIP]);
  992. spin(sec, &spins[SPIN_MIN_SATS]);
  993. sec = section(side, "Auto-pause");
  994. app.chk_auto_pause = check(sec, "Enabled", "settings", "auto_pause", TRUE, G_CALLBACK(setting_changed));
  995. for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++) {
  996. spin(sec, &spins[i]);
  997. gtk_widget_set_sensitive(spins[i].widget,
  998. gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
  999. }
  1000. sec = section(side, "Display");
  1001. app.chk_show_nmea = check(sec, "NMEA input (grey)", "view", "show_nmea", TRUE, G_CALLBACK(view_changed));
  1002. app.chk_show_gpx = check(sec, "GPX output (colour per segment)", "view", "show_gpx", TRUE, G_CALLBACK(view_changed));
  1003. button = gtk_button_new_with_label("Zoom to fit");
  1004. g_signal_connect(button, "clicked", G_CALLBACK(fit_clicked), NULL);
  1005. gtk_box_pack_start(GTK_BOX(sec), button, FALSE, FALSE, 0);
  1006. app.loading_settings = FALSE;
  1007. sec = section(side, "Summary");
  1008. app.summary_label = gtk_label_new("");
  1009. gtk_label_set_selectable(GTK_LABEL(app.summary_label), TRUE);
  1010. gtk_label_set_xalign(GTK_LABEL(app.summary_label), 0);
  1011. gtk_label_set_line_wrap(GTK_LABEL(app.summary_label), TRUE);
  1012. attrs = pango_attr_list_new();
  1013. pango_attr_list_insert(attrs, pango_attr_family_new("monospace"));
  1014. pango_attr_list_insert(attrs, pango_attr_scale_new(0.85));
  1015. gtk_label_set_attributes(GTK_LABEL(app.summary_label), attrs);
  1016. pango_attr_list_unref(attrs);
  1017. gtk_box_pack_start(GTK_BOX(sec), app.summary_label, FALSE, FALSE, 0);
  1018. app.map = gtk_drawing_area_new();
  1019. gtk_widget_add_events(app.map, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK |
  1020. GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK | GDK_SMOOTH_SCROLL_MASK | GDK_LEAVE_NOTIFY_MASK);
  1021. g_signal_connect(app.map, "draw", G_CALLBACK(map_draw), NULL);
  1022. g_signal_connect(app.map, "scroll-event", G_CALLBACK(map_scroll), NULL);
  1023. g_signal_connect(app.map, "button-press-event", G_CALLBACK(map_button_press), NULL);
  1024. g_signal_connect(app.map, "button-release-event", G_CALLBACK(map_button_release), NULL);
  1025. g_signal_connect(app.map, "motion-notify-event", G_CALLBACK(map_motion), NULL);
  1026. g_signal_connect(app.map, "leave-notify-event", G_CALLBACK(map_leave), NULL);
  1027. gtk_box_pack_start(GTK_BOX(hbox), app.map, TRUE, TRUE, 0);
  1028. bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
  1029. gtk_container_set_border_width(GTK_CONTAINER(bar), 4);
  1030. app.coord_label = gtk_label_new("");
  1031. gtk_label_set_selectable(GTK_LABEL(app.coord_label), TRUE);
  1032. gtk_box_pack_start(GTK_BOX(bar), app.coord_label, FALSE, FALSE, 4);
  1033. app.status_label = gtk_label_new("Open an NMEA log to start");
  1034. gtk_box_pack_end(GTK_BOX(bar), app.status_label, FALSE, FALSE, 4);
  1035. gtk_box_pack_start(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 0);
  1036. gtk_box_pack_start(GTK_BOX(vbox), bar, FALSE, FALSE, 0);
  1037. gtk_container_add(GTK_CONTAINER(app.window), vbox);
  1038. }
  1039. static gboolean window_configure(GtkWidget *w, GdkEvent *ev, gpointer data) {
  1040. int width, height;
  1041. (void)ev;
  1042. (void)data;
  1043. gtk_window_get_size(GTK_WINDOW(w), &width, &height);
  1044. g_key_file_set_integer(app.config, "view", "width", width);
  1045. g_key_file_set_integer(app.config, "view", "height", height);
  1046. return FALSE;
  1047. }
  1048. static gboolean quit_signal(gpointer data) {
  1049. (void)data;
  1050. gtk_main_quit();
  1051. return G_SOURCE_REMOVE;
  1052. }
  1053. int main(int argc, char *argv[]) {
  1054. char *input;
  1055. gtk_init(&argc, &argv);
  1056. app.config = g_key_file_new();
  1057. app.config_path = g_build_filename(g_get_user_config_dir(), "gps-test-tool", "gui.ini", NULL);
  1058. g_key_file_load_from_file(app.config, app.config_path, G_KEY_FILE_NONE, NULL);
  1059. app.cache_dir = g_build_filename(g_get_user_cache_dir(), "gps-test-tool", "tiles", NULL);
  1060. app.cli_path = find_cli();
  1061. app.tmpdir = g_dir_make_tmp("gps-test-tool-gui-XXXXXX", NULL);
  1062. if (!app.cli_path || !app.tmpdir) {
  1063. fprintf(stderr, "Error: %s\n", app.cli_path ? "cannot create temporary directory"
  1064. : "gps-test-tool binary not found next to this program or in PATH");
  1065. return 1;
  1066. }
  1067. app.out_gpx = g_build_filename(app.tmpdir, "output.gpx", NULL);
  1068. app.out_log = g_build_filename(app.tmpdir, "debug.txt", NULL);
  1069. app.tiles = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, tile_free);
  1070. g_queue_init(&app.dl_queue);
  1071. app.soup = soup_session_new_with_options("user-agent", USER_AGENT,
  1072. "max-conns-per-host", MAX_DOWNLOADS, NULL);
  1073. app.zoom = MIN_ZOOM;
  1074. app.cx = app.cy = world_size(app.zoom) / 2;
  1075. build_ui();
  1076. g_signal_connect(app.window, "configure-event", G_CALLBACK(window_configure), NULL);
  1077. gtk_widget_show_all(app.window);
  1078. update_coord_label();
  1079. input = argc > 1 ? g_strdup(argv[1]) : g_key_file_get_string(app.config, "view", "input_file", NULL);
  1080. if (input && (argc > 1 || g_file_test(input, G_FILE_TEST_EXISTS)))
  1081. open_file(input);
  1082. g_free(input);
  1083. /* Clean up the temporary directory on Ctrl+C / kill too */
  1084. g_unix_signal_add(SIGINT, quit_signal, NULL);
  1085. g_unix_signal_add(SIGTERM, quit_signal, NULL);
  1086. gtk_main();
  1087. if (app.running)
  1088. kill(app.child, SIGTERM);
  1089. config_save();
  1090. g_unlink(app.out_gpx);
  1091. g_unlink(app.out_log);
  1092. g_rmdir(app.tmpdir);
  1093. return 0;
  1094. }