Skip to main content

sensorviz_core/
engine.rs

1//! The engine: the Qt sensorviz's `MainWindow` behaviour, with no UI.
2//!
3//! [`spawn`] starts the engine and returns an [`Engine`] handle. The UI talks
4//! to it only through [`Engine::command`] and [`Engine::query`], and hears
5//! from it only through the [`Event`] sink: the whole view state after every
6//! change, and log lines.
7//!
8//! # Threading
9//!
10//! One thread, the **engine thread**, owns all state in one [`Viewer`]: the
11//! open log's [`Session`], the per-tag [`Preferences`], the status line and
12//! the outstanding message. Commands and queries are applied on it in the
13//! order they arrive. [`Session`] is `Send` and not `Sync`, which this keeps.
14//!
15//! # Commands
16//!
17//! | Command | Arguments | Does |
18//! | --- | --- | --- |
19//! | `ready` | | Opens [`EngineConfig::initial_log`] and loads [`EngineConfig::initial_preferences`], if set |
20//! | `open` | `path` | File > Load: remembers the open log's display choices, then opens the log and applies its tag's preferences |
21//! | `set_visible_streams` | `ids` | View > Visible Streams |
22//! | `set_stream_range` | `id`, `lower`, `upper` | View > Ranges: the range and the ranges linked to it |
23//! | `reset_zoom` | | View > Reset Zoom: clears the custom ranges |
24//! | `set_break_at_gaps` | `on` | View > Break Lines at Gaps |
25//! | `set_cursors_visible` | `on` | View > Show Cursors |
26//! | `set_utc_offset` | `hours` | Configuration > UTC Offset |
27//! | `set_parameter` | `id`, `value` | A transform parameter (Sea-level Pressure, Declination, Battery Forward) |
28//! | `set_title`, `set_title_visible` | `text`, `on` | The graph title |
29//! | `set_colors`, `set_axis_sides` | `colors` / `sides`: id to `#rrggbb` / `left`/`right` | Stream Colors, Axis Sides |
30//! | `load_preferences`, `store_preferences` | `path` | File > Preferences > Load, Store |
31//! | `load_default_preferences` | | File > Preferences > Load Defaults |
32//! | `save_file` | `path`, `base64` | Writes bytes the page made (File > Export PDF) |
33//!
34//! # Queries
35//!
36//! | Query | Arguments | Answer |
37//! | --- | --- | --- |
38//! | `envelope` | `t0`, `t1`, `width` | `{traces: {id: {t, v}}, ms}`: each drawn trace's M4 envelope at `width` columns; a gap marker's value is `null` |
39//! | `hover` | `t` | `{time, rows: [{text}]}`: the hover readout |
40//! | `range` | `id` | `{lower, upper}`: where the range dialog starts |
41//! | `compass` | `t` | The compass panel at `t`, from the sample at or after it: `{time, heading, pitch, roll, dip, field, gravity, declination, batteryForward}`, angles as the Qt panel shows them; null without a compass |
42//! | `about` | | `{title, version, userGuide, previewDialog}` |
43
44use std::path::{Path, PathBuf};
45use std::sync::atomic::{AtomicU8, Ordering};
46use std::sync::mpsc::{self, Receiver, Sender};
47use std::sync::{Arc, Mutex, OnceLock};
48use std::time::{Duration, Instant};
49
50use base64::Engine as _;
51use sensorviz_session::{OrientationSample, Preferences, Session, Side};
52use serde_json::{Map, Value, json};
53
54/// Where the engine starts, and what it tells Help.
55#[derive(Debug, Clone, Default)]
56pub struct EngineConfig {
57    /// The user's home folder, where the file dialogs start before a log is open.
58    pub home_dir: PathBuf,
59    /// The user manual page for Help > User Guide, if one is installed.
60    pub user_guide_url: Option<String>,
61    /// A log to open on `ready`, as the Qt app's `--load-log` does.
62    pub initial_log: Option<PathBuf>,
63    /// A preference file to load on `ready`, as `--load-preferences` does.
64    pub initial_preferences: Option<PathBuf>,
65    /// A dialog for the page to open once the log is drawn (`visible`,
66    /// `sides`, `colors`, `calibration`), for screenshots of the app.
67    pub preview_dialog: Option<String>,
68}
69
70/// What the engine tells the UI, through the sink given to [`spawn`].
71///
72/// The sink is called on the engine thread for [`Event::State`], and on
73/// whatever thread logged for [`Event::Log`], so it must be cheap and must not
74/// call [`Engine::query`] and wait.
75#[derive(Debug, Clone)]
76pub enum Event {
77    /// The whole view state, sent only when it differs from the last one.
78    State(Value),
79    /// One log line at or above INFO.
80    Log {
81        /// The level's name: `TRACE`, `DEBUG`, `INFO`, `WARN` or `ERROR`.
82        level: &'static str,
83        /// The message, without the level.
84        text: String,
85    },
86}
87
88/// The UI's side of the engine: a cheap, cloneable sender of messages.
89///
90/// Commands are fire-and-forget; their effect is seen in a later
91/// [`Event::State`].
92#[derive(Clone)]
93pub struct Engine {
94    /// The engine thread's message channel.
95    tx: Sender<Msg>,
96}
97
98/// Messages to the engine thread, applied in the order they arrive.
99#[derive(Debug)]
100enum Msg {
101    /// A UI command: its name and its JSON arguments.
102    Command(String, Value),
103    /// A UI query: its name, its JSON arguments and the channel for its answer.
104    Query(String, Value, Sender<Value>),
105}
106
107impl Engine {
108    /// Sends a command. Returns at once; an unknown command is logged as a
109    /// warning, and a command sent after the engine has gone is dropped.
110    pub fn command(&self, cmd: &str, args: Value) {
111        let _ = self.tx.send(Msg::Command(cmd.to_owned(), args));
112    }
113
114    /// Asks a query and waits for its answer: [`Value::Null`] if the engine
115    /// thread has gone or does not answer within 30 s (a large log can take
116    /// that long to open ahead of it), and `{ "error": ... }` for an unknown
117    /// query.
118    pub fn query(&self, name: &str, args: Value) -> Value {
119        let (reply_tx, reply_rx) = mpsc::channel();
120        if self
121            .tx
122            .send(Msg::Query(name.to_owned(), args, reply_tx))
123            .is_err()
124        {
125            return Value::Null;
126        }
127        reply_rx
128            .recv_timeout(Duration::from_secs(30))
129            .unwrap_or(Value::Null)
130    }
131}
132
133/// The event sink, shared between the engine thread and the logger.
134type Sink = Arc<dyn Fn(Event) + Send + Sync>;
135
136/// The sink [`UiLogger`] forwards to, set by [`spawn`].
137static SINK: OnceLock<Mutex<Option<Sink>>> = OnceLock::new();
138/// The level passed on, as `log::Level as u8` (1 ERROR .. 5 TRACE).
139static LOG_LEVEL: AtomicU8 = AtomicU8::new(3);
140
141/// A `log` logger that forwards records to the sink as [`Event::Log`].
142/// Install it once, before [`spawn`].
143pub struct UiLogger;
144
145/// The logger to install with `log::set_logger`.
146pub static LOGGER: UiLogger = UiLogger;
147
148impl log::Log for UiLogger {
149    /// Passes records at or above INFO.
150    fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
151        metadata.level() as u8 <= LOG_LEVEL.load(Ordering::Relaxed)
152    }
153
154    /// Sends one enabled record to the sink.
155    fn log(&self, record: &log::Record<'_>) {
156        if !self.enabled(record.metadata()) {
157            return;
158        }
159        let level = match record.level() {
160            log::Level::Error => "ERROR",
161            log::Level::Warn => "WARN",
162            log::Level::Info => "INFO",
163            log::Level::Debug => "DEBUG",
164            log::Level::Trace => "TRACE",
165        };
166        if let Some(sink) = SINK.get().and_then(|s| s.lock().ok()?.clone()) {
167            sink(Event::Log {
168                level,
169                text: record.args().to_string(),
170            });
171        }
172    }
173
174    /// Does nothing: nothing is buffered.
175    fn flush(&self) {}
176}
177
178/// Starts the engine thread and returns its handle. Nothing is opened until
179/// the page sends `ready`.
180///
181/// # Panics
182///
183/// Panics if the thread cannot be started, or the sensorViz library cannot
184/// make a session (built from another version of its header, or out of
185/// memory).
186pub fn spawn(config: EngineConfig, sink: impl Fn(Event) + Send + Sync + 'static) -> Engine {
187    let sink: Sink = Arc::new(sink);
188    *SINK.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(sink.clone());
189    log::set_max_level(log::LevelFilter::Info);
190    let viewer = Viewer::new(config).expect("sensorviz session");
191    let (tx, rx) = mpsc::channel();
192    std::thread::Builder::new()
193        .name("sensorviz-engine".into())
194        .spawn(move || run(viewer, sink, rx))
195        .expect("engine thread");
196    Engine { tx }
197}
198
199/// The engine loop: apply messages and publish the view state when it changed.
200fn run(mut viewer: Viewer, sink: Sink, rx: Receiver<Msg>) {
201    let mut last_state = Value::Null;
202    while let Ok(msg) = rx.recv() {
203        match msg {
204            Msg::Command(cmd, args) => viewer.command(&cmd, &args),
205            Msg::Query(name, args, reply) => {
206                let _ = reply.send(viewer.query(&name, &args));
207            }
208        }
209        let state = viewer.view_state();
210        if state != last_state {
211            sink(Event::State(state.clone()));
212            last_state = state;
213        }
214    }
215}
216
217/// A message for the page to show once: a failed load, a refused file.
218#[derive(Debug, Clone, PartialEq)]
219struct Message {
220    /// Increases with each message, so the page shows each once.
221    seq: u64,
222    /// Dialog title, e.g. `Load Failed`.
223    title: String,
224    /// The message.
225    text: String,
226}
227
228/// Everything the engine thread owns.
229pub struct Viewer {
230    /// Where the engine starts and what Help says.
231    config: EngineConfig,
232    /// The open log, its view state and transforms.
233    session: Session,
234    /// Per-tag display preferences; outlive logs.
235    preferences: Preferences,
236    /// The last message for the page, if any.
237    message: Option<Message>,
238    /// The directory of the last file opened or saved, where dialogs start.
239    last_dir: PathBuf,
240    /// The open log's compass samples, for the compass panel; empty without one.
241    compass: Vec<OrientationSample>,
242}
243
244/// A string argument, or "".
245fn arg_str<'a>(args: &'a Value, key: &str) -> &'a str {
246    args.get(key).and_then(Value::as_str).unwrap_or("")
247}
248
249/// A number argument, if it is one.
250fn arg_f64(args: &Value, key: &str) -> Option<f64> {
251    args.get(key).and_then(Value::as_f64)
252}
253
254/// A boolean argument, or false.
255fn arg_bool(args: &Value, key: &str) -> bool {
256    args.get(key).and_then(Value::as_bool).unwrap_or(false)
257}
258
259/// `#rrggbb` as 0xRRGGBB.
260fn parse_color(text: &str) -> Option<u32> {
261    let hex = text.strip_prefix('#')?;
262    if hex.len() != 6 {
263        return None;
264    }
265    u32::from_str_radix(hex, 16).ok()
266}
267
268impl Viewer {
269    /// A viewer with no log open.
270    ///
271    /// # Errors
272    ///
273    /// The library's error when it cannot make a session or a preference
274    /// store.
275    pub fn new(config: EngineConfig) -> Result<Self, sensorviz_session::Error> {
276        Ok(Viewer {
277            last_dir: config.home_dir.clone(),
278            session: Session::new()?,
279            preferences: Preferences::new()?,
280            message: None,
281            compass: Vec::new(),
282            config,
283        })
284    }
285
286    /// Queues a message for the page.
287    fn tell(&mut self, title: &str, text: impl Into<String>) {
288        let seq = self.message.as_ref().map_or(1, |m| m.seq + 1);
289        self.message = Some(Message {
290            seq,
291            title: title.into(),
292            text: text.into(),
293        });
294    }
295
296    /// Remembers `path`'s directory for the next dialog.
297    fn remember_dir(&mut self, path: &Path) {
298        if let Some(dir) = path.parent() {
299            self.last_dir = dir.to_path_buf();
300        }
301    }
302
303    /// Whether a log is open.
304    fn has_log(&mut self) -> bool {
305        self.session
306            .catalog()
307            .map(|c| !c.path.is_empty())
308            .unwrap_or(false)
309    }
310
311    /// Applies one command; see the module's table.
312    pub fn command(&mut self, cmd: &str, args: &Value) {
313        match cmd {
314            "ready" => {
315                if let Some(path) = self.config.initial_log.clone() {
316                    self.open(&path);
317                }
318                if let Some(path) = self.config.initial_preferences.clone() {
319                    self.load_preferences(&path);
320                }
321            }
322            "open" => self.open(Path::new(arg_str(args, "path"))),
323            "set_visible_streams" => {
324                let ids: Vec<&str> = args
325                    .get("ids")
326                    .and_then(Value::as_array)
327                    .map(|a| a.iter().filter_map(Value::as_str).collect())
328                    .unwrap_or_default();
329                let _ = self.session.set_visible_streams(&ids);
330            }
331            "set_stream_visible" => {
332                self.session
333                    .set_stream_visible(arg_str(args, "id"), arg_bool(args, "visible"));
334            }
335            "set_stream_range" => {
336                let (Some(lower), Some(upper)) = (arg_f64(args, "lower"), arg_f64(args, "upper"))
337                else {
338                    return;
339                };
340                if lower >= upper {
341                    self.tell("Set Range", "Minimum must be less than maximum.");
342                    return;
343                }
344                self.session
345                    .set_stream_range(arg_str(args, "id"), lower, upper);
346            }
347            "reset_zoom" => {
348                self.session.reset_zoom();
349            }
350            "set_break_at_gaps" => {
351                self.session.set_break_at_gaps(arg_bool(args, "on"));
352            }
353            "set_cursors_visible" => {
354                self.session.set_cursors_visible(arg_bool(args, "on"));
355            }
356            "set_utc_offset" => {
357                if let Some(hours) = arg_f64(args, "hours") {
358                    self.session
359                        .set_utc_offset(hours.round().clamp(-12.0, 12.0) as i32);
360                }
361            }
362            "set_parameter" => {
363                if let Some(value) = arg_f64(args, "value")
364                    && let Err(e) = self.session.set_parameter(arg_str(args, "id"), value)
365                {
366                    log::warn!("{}: {e}", arg_str(args, "id"));
367                }
368            }
369            "set_title" => {
370                let _ = self.session.set_title(arg_str(args, "text"));
371            }
372            "set_title_visible" => {
373                self.session.set_title_visible(arg_bool(args, "on"));
374            }
375            "set_colors" => {
376                let colors: Vec<(String, u32)> = args
377                    .get("colors")
378                    .and_then(Value::as_object)
379                    .map(|m| {
380                        m.iter()
381                            .filter_map(|(id, c)| Some((id.clone(), parse_color(c.as_str()?)?)))
382                            .collect()
383                    })
384                    .unwrap_or_default();
385                let refs: Vec<(&str, u32)> =
386                    colors.iter().map(|(id, c)| (id.as_str(), *c)).collect();
387                let _ = self.session.set_colors(&refs);
388            }
389            "set_axis_sides" => {
390                let sides: Vec<(String, Side)> = args
391                    .get("sides")
392                    .and_then(Value::as_object)
393                    .map(|m| {
394                        m.iter()
395                            .map(|(id, s)| {
396                                let side = if s.as_str() == Some("right") {
397                                    Side::Right
398                                } else {
399                                    Side::Left
400                                };
401                                (id.clone(), side)
402                            })
403                            .collect()
404                    })
405                    .unwrap_or_default();
406                let refs: Vec<(&str, Side)> =
407                    sides.iter().map(|(id, s)| (id.as_str(), *s)).collect();
408                let _ = self.session.set_axis_sides(&refs);
409            }
410            "load_preferences" => self.load_preferences(Path::new(arg_str(args, "path"))),
411            "store_preferences" => self.store_preferences(Path::new(arg_str(args, "path"))),
412            "load_default_preferences" => {
413                if self.has_log() {
414                    self.session.forget_preferences(&mut self.preferences);
415                    self.session.apply_preferences(&self.preferences);
416                }
417            }
418            "save_file" => {
419                let path = PathBuf::from(arg_str(args, "path"));
420                let written = base64::engine::general_purpose::STANDARD
421                    .decode(arg_str(args, "base64"))
422                    .map_err(|e| e.to_string())
423                    .and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()));
424                match written {
425                    Ok(()) => {
426                        self.remember_dir(&path);
427                        log::info!("Saved {}", path.display());
428                    }
429                    Err(e) => self.tell(
430                        "Save Failed",
431                        format!("Could not write {}: {e}", path.display()),
432                    ),
433                }
434            }
435            other => log::warn!("unknown command {other}"),
436        }
437    }
438
439    /// File > Load: remembers the open log's display choices for its tag,
440    /// then opens `path` and applies its tag's preferences.
441    fn open(&mut self, path: &Path) {
442        if self.has_log() {
443            self.session.remember_preferences(&mut self.preferences);
444        }
445        let started = Instant::now();
446        match self.session.open(path, Some(&self.preferences)) {
447            Ok(()) => {
448                self.remember_dir(path);
449                self.compass = self.session.compass_samples();
450                log::info!(
451                    "Loaded {} in {} ms",
452                    path.display(),
453                    started.elapsed().as_millis()
454                );
455                if let Ok(catalog) = self.session.catalog() {
456                    for failure in catalog.transform_failures {
457                        if failure.transform == "compass" {
458                            self.tell("Compass Streams", failure.error);
459                        } else {
460                            log::warn!("{}: {}", failure.transform, failure.error);
461                        }
462                    }
463                }
464            }
465            Err(e) => self.tell("Load Failed", e.to_string()),
466        }
467    }
468
469    /// File > Preferences > Load: replaces every entry from the file, then
470    /// applies the open log's.
471    fn load_preferences(&mut self, path: &Path) {
472        let loaded = std::fs::read_to_string(path)
473            .map_err(|e| format!("Could not open {}: {e}", path.display()))
474            .and_then(|text| {
475                self.preferences
476                    .load(&text, &path.to_string_lossy())
477                    .map_err(|e| e.to_string())
478            });
479        match loaded {
480            Ok(()) => {
481                self.remember_dir(path);
482                if self.has_log() {
483                    self.session.apply_preferences(&self.preferences);
484                }
485                log::info!("Loaded sensorViz preferences from {}", path.display());
486            }
487            Err(e) => self.tell("Load Preferences Failed", e),
488        }
489    }
490
491    /// File > Preferences > Store: remembers the open log's choices, then
492    /// writes every entry.
493    fn store_preferences(&mut self, path: &Path) {
494        if self.has_log() {
495            self.session.remember_preferences(&mut self.preferences);
496        }
497        let text = self.preferences.save();
498        match std::fs::write(path, text) {
499            Ok(()) => {
500                self.remember_dir(path);
501                log::info!("Saved sensorViz preferences to {}", path.display());
502            }
503            Err(e) => self.tell(
504                "Save Preferences Failed",
505                format!("Could not write {}: {e}", path.display()),
506            ),
507        }
508    }
509
510    /// Answers one query; see the module's table.
511    pub fn query(&mut self, name: &str, args: &Value) -> Value {
512        match name {
513            "envelope" => self.envelope(args),
514            "hover" => {
515                let t = arg_f64(args, "t").unwrap_or(0.0);
516                let rows = self.session.hover(t).unwrap_or_default();
517                let mut rows = rows.into_iter();
518                let time = rows.next().unwrap_or_default();
519                json!({ "time": time, "rows": rows.map(|text| json!({ "text": text })).collect::<Vec<_>>() })
520            }
521            "range" => match self.session.range_for_dialog(arg_str(args, "id")) {
522                Some((lower, upper)) => json!({ "lower": lower, "upper": upper }),
523                None => Value::Null,
524            },
525            "compass" => self.compass_at(arg_f64(args, "t")),
526            "about" => json!({
527                "title": "Tag View",
528                "version": env!("CARGO_PKG_VERSION"),
529                "userGuide": self.config.user_guide_url,
530                "previewDialog": self.config.preview_dialog,
531            }),
532            other => json!({ "error": format!("unknown query {other}") }),
533        }
534    }
535
536    /// The compass panel at `t` (the first sample if none is given), as the
537    /// Qt app's MyCompass.qml shows it: from the sample at or after `t` (the
538    /// last after the end), its display angles (the core's), the
539    /// declination added to the heading, and with the battery aft the
540    /// heading turned 180 degrees and pitch and roll negated.
541    fn compass_at(&mut self, t: Option<f64>) -> Value {
542        if self.compass.is_empty() {
543            return Value::Null;
544        }
545        let i = t
546            .map_or(0, |t| self.compass.partition_point(|s| s.time < t))
547            .min(self.compass.len() - 1);
548        let sample = self.compass[i];
549        let angles = calibsession::display_angles(sample.q);
550        let parameters = self
551            .session
552            .view()
553            .map(|v| v.parameters)
554            .unwrap_or_default();
555        let declination = parameters
556            .get("compass.declination_deg")
557            .copied()
558            .unwrap_or(0.0);
559        let forward = parameters
560            .get("compass.battery_forward")
561            .copied()
562            .unwrap_or(1.0)
563            != 0.0;
564        let (mut heading, mut pitch, mut roll) =
565            (angles.yaw + declination, angles.pitch, angles.roll);
566        if !forward {
567            heading += 180.0;
568            pitch = -pitch;
569            roll = -roll;
570        }
571        heading = (heading + 360.0) % 360.0;
572        json!({
573            "time": sample.time,
574            "heading": heading, "pitch": pitch, "roll": roll,
575            "dip": sample.dip, "field": sample.field, "gravity": sample.mg,
576            "declination": declination, "batteryForward": forward,
577        })
578    }
579
580    /// Every drawn trace's envelope over `[t0, t1]` at `width` columns.
581    fn envelope(&mut self, args: &Value) -> Value {
582        let started = Instant::now();
583        let Ok(spec) = self.session.spec() else {
584            return json!({ "traces": {}, "ms": 0 });
585        };
586        let t0 = arg_f64(args, "t0").unwrap_or(f64::NEG_INFINITY);
587        let t1 = arg_f64(args, "t1").unwrap_or(f64::INFINITY);
588        let width = arg_f64(args, "width").unwrap_or(1.0).round().max(1.0) as i32;
589        let mut traces = Map::new();
590        for trace in &spec.traces {
591            let envelope = self
592                .session
593                .envelope(trace.stream, t0, t1, width, spec.break_at_gaps);
594            traces.insert(
595                trace.id.clone(),
596                json!({ "t": envelope.time, "v": envelope.value }),
597            );
598        }
599        json!({ "traces": traces, "ms": started.elapsed().as_secs_f64() * 1000.0 })
600    }
601
602    /// The whole view state the page draws from.
603    pub fn view_state(&mut self) -> Value {
604        let [catalog, view, spec] = self.session.state_json();
605        let catalog: Value = serde_json::from_str(&catalog).unwrap_or(Value::Null);
606        let view: Value = serde_json::from_str(&view).unwrap_or(Value::Null);
607        let has_log = catalog["path"].as_str().is_some_and(|p| !p.is_empty());
608        let parameters = if has_log {
609            self.session
610                .parameters()
611                .map(|ps| {
612                    ps.into_iter()
613                        .map(|p| {
614                            json!({
615                                "id": p.id, "title": p.title, "prompt": p.prompt, "units": p.units,
616                                "kind": p.kind, "minimum": p.minimum, "maximum": p.maximum,
617                                "decimals": p.decimals, "value": p.value,
618                            })
619                        })
620                        .collect()
621                })
622                .unwrap_or_default()
623        } else {
624            Vec::new()
625        };
626        let path = catalog["path"].as_str().unwrap_or("").to_owned();
627        json!({
628            "title": "Tag View",
629            "hasLog": has_log,
630            "path": path,
631            "fileName": Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()),
632            "dialogDir": self.last_dir,
633            "status": status_line(&catalog, &view, has_log),
634            "fileInfo": if has_log { file_info(&catalog) } else { String::new() },
635            "timeDomain": catalog["timeDomain"],
636            "hasCompass": catalog["compassSource"].as_str().is_some_and(|s| !s.is_empty()),
637            "hasCalibration": catalog["compassCalibration"]["present"],
638            "calibration": catalog["compassCalibration"],
639            "spec": if has_log { serde_json::from_str(&spec).unwrap_or(Value::Null) } else { Value::Null },
640            "view": if has_log { view } else { Value::Null },
641            "parameters": parameters,
642            "message": self.message.as_ref().map(|m| json!({ "seq": m.seq, "title": m.title, "text": m.text })),
643        })
644    }
645}
646
647/// The status line, as the Qt app's `updateMetadata()` writes it.
648fn status_line(catalog: &Value, view: &Value, has_log: bool) -> String {
649    if !has_log {
650        return "No log loaded".into();
651    }
652    let streams = view["streams"].as_array().map_or(0, Vec::len);
653    let sets = catalog["recordSets"].as_array().map_or(0, Vec::len);
654    if sets == 0 {
655        format!("{streams} streams loaded")
656    } else {
657        format!("{streams} streams, {sets} record sets loaded")
658    }
659}
660
661/// The File Info pane, line for line as the Qt app's `updateMetadata()`
662/// writes it: the file, tag type, profile, record sets, the compass
663/// calibration, the compass source, then the info rows but for the large
664/// `config` and `info` blobs.
665fn file_info(catalog: &Value) -> String {
666    let text = |v: &Value| v.as_str().unwrap_or("").to_owned();
667    let mut lines = Vec::new();
668    let path = text(&catalog["path"]);
669    lines.push(
670        Path::new(&path)
671            .file_name()
672            .map_or(path.clone(), |n| n.to_string_lossy().into_owned()),
673    );
674    let tag_type = text(&catalog["tagType"]);
675    if !tag_type.is_empty() {
676        lines.push(format!("Tag type: {tag_type}"));
677    }
678    let profile = text(&catalog["profileName"]);
679    if !profile.is_empty() {
680        lines.push(format!("Profile: {profile}"));
681    }
682    if let Some(sets) = catalog["recordSets"].as_array().filter(|s| !s.is_empty()) {
683        let labels: Vec<String> = sets.iter().map(|s| text(&s["label"])).collect();
684        lines.push(format!("Record sets: {}", labels.join(", ")));
685    }
686    let calibration = &catalog["compassCalibration"];
687    let warning = text(&calibration["warning"]);
688    if calibration["present"].as_bool() == Some(true) {
689        lines.push("Compass calibration: loaded".into());
690        lines.push(format!(
691            "Compass calibration epoch: {}",
692            calibration["epoch"]
693        ));
694        if !warning.is_empty() {
695            lines.push(format!("Compass calibration warning: {warning}"));
696        }
697    } else if !warning.is_empty() {
698        lines.push(format!("Compass calibration: {warning}"));
699    }
700    let source = text(&catalog["compassSource"]);
701    if !source.is_empty() {
702        lines.push(format!("Compass orientation: {source}"));
703    }
704    if let Some(info) = catalog["info"].as_object() {
705        for (field, value) in info {
706            if field == "config" || field == "info" {
707                continue;
708            }
709            lines.push(format!("{field}: {}", value.as_str().unwrap_or("")));
710        }
711    }
712    lines.join("\n")
713}