bobview 1.10.0

A lean viewer for Phoebus Display Builder .bob and CS-Studio BOY .opi screens, with live EPICS PVs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
//! Trend windows: PVs plotted against time, archived history and live
//! values, by archiveview's [`Plot`]. Opened from a widget's context menu,
//! a BOY "Graph" button, or a Data Browser or StripTool launcher. The
//! plot fetches history from the Archiver Appliance bobview was given
//! (`--archiver`), or is live only without one.
//!
//! A middle-click on the plot adds the PVs the primary selection names,
//! and one in its search box pastes the selection there: the plot reports
//! the click, `BobApp` reads the selection (on a thread, see
//! [`crate::selection::Reader`]) and hands the text back to
//! [`Trend::selected`].

use archiveview::plot::MiddleClick;
use archiveview::{ArchiverClient, Plot, View};
use eframe::egui;
use pv::PvManager;
use web_time::{Duration, Instant};

use crate::layout::Geometry;
use crate::stripchart::MIN_SCROLL_REPAINT;

/// A trend window's plot, and where its window is.
pub struct Trend {
    /// Tells this window from the others.
    pub id: u64,
    plot: Plot,
    /// Where to open the window, when a layout says.
    pub place: Geometry,
    /// Where the window is, for saving a layout.
    pub geometry: Geometry,
    /// The middle-click waiting for the primary selection: its number,
    /// and when it was (not egui time, which each native window counts
    /// from its own start).
    reading: Option<(u64, Instant)>,
    /// What a paste did, floating over the bottom of the plot for a while.
    notice: Option<Notice>,
    /// Text selected in the search box, and when, until it has been
    /// steady for [`PRIMARY_DEBOUNCE`] to become the primary selection.
    search_selection: Option<(String, Instant)>,
}

/// Text selected in the search box becomes the primary selection once it
/// has stayed the same this long, as in archiveview: not at every
/// shift+arrow, which over `ssh -X` costs a round trip each.
const PRIMARY_DEBOUNCE: Duration = Duration::from_millis(150);

/// How long a middle-click waits for the primary selection, as in
/// archiveview: long enough for a slow owner (or `ssh -X`), short enough
/// that a hung one (arboard waits up to 4 s per format) gets a "Could not
/// read the selection" while the click is still fresh, rather than PVs
/// turning up long after.
pub const SELECTION_WAIT: Duration = Duration::from_secs(2);

/// A short message over the plot.
struct Notice {
    text: String,
    error: bool,
    /// When it goes (egui time), from when it is first drawn.
    until: Option<f64>,
    secs: f64,
}

impl Trend {
    /// A trend of the PVs `names`, each once, subscribed through `pvs`
    /// (tagged to repaint its window) and with history from `archiver`.
    pub fn new(
        id: u64,
        names: &[String],
        pvs: &PvManager,
        archiver: Option<ArchiverClient>,
        recent: &[String],
    ) -> Self {
        let mut plot = Plot::with_id_salt(pvs, archiver, ("bobview-trend", id));
        // The pill switches archivers, for every trend: see
        // `BobApp::set_archiver`. Not in a browser, which can't reach one.
        plot.set_archiver_switching(!cfg!(target_arch = "wasm32"));
        plot.set_recent_archivers(recent);
        plot.add_pvs(names);
        Self {
            id,
            plot,
            place: Geometry::default(),
            geometry: Geometry::default(),
            reading: None,
            notice: None,
            search_selection: None,
        }
    }

    /// Whether a middle-click is waiting for the primary selection.
    #[cfg(test)]
    pub fn is_reading(&self) -> bool {
        self.reading.is_some()
    }

    /// The notice's text, and whether it is an error.
    #[cfg(test)]
    pub fn notice(&self) -> Option<(&str, bool)> {
        self.notice.as_ref().map(|n| (n.text.as_str(), n.error))
    }

    /// The PVs' names, as added.
    #[cfg(test)]
    pub fn names(&self) -> Vec<String> {
        self.plot.names()
    }

    /// What it shows, for a layout: its PVs, time range and value axes,
    /// but not the archiver, which is bobview's.
    pub fn view(&self) -> View {
        View {
            archiver: None,
            ..self.plot.view()
        }
    }

    /// Show `view` (from a layout), keeping bobview's archiver.
    pub fn set_view(&mut self, view: &View) {
        self.plot.set_view(&View {
            archiver: None,
            ..view.clone()
        });
    }

    /// Add the PVs `names` that it doesn't have yet.
    pub fn extend(&mut self, names: &[String]) {
        self.plot.add_pvs(names);
    }

    /// The archiver it fetches history from.
    pub fn archiver(&self) -> Option<&ArchiverClient> {
        self.plot.archiver()
    }

    /// Fetch history from `archiver` (or none), unless it does already.
    pub fn set_archiver(&mut self, archiver: Option<&ArchiverClient>, recent: &[String]) {
        if self.plot.archiver() != archiver {
            self.plot.set_archiver(archiver.cloned());
        }
        self.plot.set_recent_archivers(recent);
    }

    /// Whether the user switched archivers in its pill since last asked:
    /// [`Trend::archiver`] is the new one.
    pub fn take_archiver_change(&mut self) -> bool {
        self.plot.take_archiver_change()
    }

    /// Whether its plot is still fetching history, or waiting to draw
    /// a PV whose data has come (snapshots wait for this).
    #[cfg(not(target_arch = "wasm32"))]
    pub fn is_loading(&self) -> bool {
        self.plot.is_loading()
    }

    /// The window title: its PVs, or the first and how many more.
    pub fn title(&self) -> String {
        match self.plot.title() {
            t if t.is_empty() => "Trend".into(),
            t => format!("Trend: {t}"),
        }
    }

    /// Where a middle-click on it since last asked landed, if there was
    /// one: the caller reads the primary selection for it.
    pub fn take_middle_click(&mut self) -> Option<MiddleClick> {
        self.plot.take_middle_click()
    }

    /// Text selected in its search box that has been steady since before
    /// `now` less [`PRIMARY_DEBOUNCE`], once, to make the primary
    /// selection; and when to ask again, while some is waiting.
    pub fn take_search_selection(&mut self, now: Instant) -> (Option<String>, Option<Duration>) {
        if let Some(text) = self.plot.take_search_selection() {
            self.search_selection = Some((text, now));
        }
        match &self.search_selection {
            Some((_, since)) if now.duration_since(*since) >= PRIMARY_DEBOUNCE => {
                (self.search_selection.take().map(|(t, _)| t), None)
            }
            Some((_, since)) => (None, Some(PRIMARY_DEBOUNCE - now.duration_since(*since))),
            None => (None, None),
        }
    }

    /// The middle-click numbered `n`, at `now`, is waiting for the
    /// selection (an earlier one's answer is then ignored).
    pub fn reading(&mut self, n: u64, now: Instant) {
        self.reading = Some((n, now));
    }

    /// The primary selection's `text` for the middle-click numbered `n` on
    /// `click`: add the PVs it names, or paste it into the search box. An
    /// answer for an older click, or one that came too late, is dropped.
    pub fn selected(&mut self, n: u64, click: MiddleClick, text: Option<String>) {
        if self.reading.is_none_or(|(r, _)| r != n) {
            return;
        }
        self.reading = None;
        let text = text.unwrap_or_default();
        match click {
            // Says what it added in `ui`, from `take_pasted`.
            MiddleClick::Plot => {
                self.plot.paste_pvs(&text);
            }
            MiddleClick::Search => self.plot.paste_into_search(&text),
        }
    }

    /// Give up on a middle-click that has waited [`SELECTION_WAIT`] by
    /// `now` for the selection (its owner may have hung), and say so;
    /// whether it did.
    pub fn time_out_reading(&mut self, now: Instant) -> bool {
        let late = self
            .reading
            .is_some_and(|(_, since)| now.duration_since(since) >= SELECTION_WAIT);
        if late {
            self.reading = None;
            self.notify("Could not read the selection", true, 4.0);
        }
        late
    }

    fn notify(&mut self, text: impl Into<String>, error: bool, secs: f64) {
        self.notice = Some(Notice {
            text: text.into(),
            error,
            until: None,
            secs,
        });
    }

    /// Draw it in the space `ui` offers, and no more: in a window inside
    /// the main one (an auto-sized `egui::Window`) the plot's legend
    /// would otherwise push the window taller every frame.
    ///
    /// Where windows are embedded (the browser, snapshots) the plot is
    /// in the root viewport, so each repaint it schedules itself redraws
    /// the whole display: those are held to a strip chart's
    /// [`MIN_SCROLL_REPAINT`] there. In its own native window it keeps
    /// its own pace. Set as it is drawn, so it holds for every trend
    /// however it was opened, and whenever the embedding was decided.
    pub fn ui(&mut self, ui: &mut egui::Ui) {
        // A plain setter: set each pass rather than tracked.
        self.plot
            .set_min_repaint_interval(if ui.ctx().embed_viewports() {
                Duration::from_secs_f64(MIN_SCROLL_REPAINT)
            } else {
                Duration::ZERO
            });
        let rect = ui.available_rect_before_wrap();
        let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect));
        self.plot.ui(&mut inner);
        // A paste: a middle-click's, or Ctrl+V's.
        if let Some(pasted) = self.plot.take_pasted() {
            let error = pasted.added.is_empty() && pasted.present.is_empty();
            self.notify(pasted.message(), error, 3.0);
        }
        self.toast(ui, rect);
        ui.advance_cursor_after_rect(rect);
    }

    /// The notice, over the bottom middle of `rect`, until it expires.
    fn toast(&mut self, ui: &mut egui::Ui, rect: egui::Rect) {
        let Some(notice) = &mut self.notice else {
            return;
        };
        let now = ui.input(|i| i.time);
        let until = *notice.until.get_or_insert(now + notice.secs);
        if now > until {
            self.notice = None;
            return;
        }
        ui.ctx()
            .request_repaint_after(Duration::from_secs_f64(until - now));
        let color = if notice.error {
            ui.visuals().error_fg_color
        } else {
            ui.visuals().text_color()
        };
        let text = egui::RichText::new(&notice.text).color(color);
        // In the trend's own layer, over the plot drawn before it: a trend
        // window behind another (where windows are embedded) keeps its
        // notice behind too.
        let layout = egui::Layout::bottom_up(egui::Align::Center);
        let mut over = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout));
        over.add_space(56.0);
        egui::Frame::popup(over.style())
            .corner_radius(16.0)
            .inner_margin(egui::Margin::symmetric(14, 8))
            .show(&mut over, |ui| ui.label(text));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn names(n: &[&str]) -> Vec<String> {
        n.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn titles_name_the_pvs() {
        let pvs = PvManager::new();
        let t = Trend::new(0, &names(&["A:B", "ca://A:B", " "]), &pvs, None, &[]);
        assert_eq!(t.names().len(), 1, "each PV once");
        assert_eq!(t.title(), "Trend: A:B");
        let t = Trend::new(1, &names(&["A", "B", "C"]), &pvs, None, &[]);
        assert_eq!(t.title(), "Trend: A and 2 more");
        assert_eq!(Trend::new(2, &[], &pvs, None, &[]).title(), "Trend");
    }

    #[test]
    fn extending_adds_new_pvs_after_the_others() {
        let pvs = PvManager::new();
        let mut t = Trend::new(0, &names(&["A"]), &pvs, None, &[]);
        t.extend(&names(&["ca://A", "B", "B", " ", "C"]));
        // Each PV once, in the order added, so the new ones take the next
        // colours as in a new trend of all three.
        assert_eq!(t.names(), ["A", "B", "C"]);
        assert_eq!(t.title(), "Trend: A and 2 more");
    }

    /// The longest line the trend draws, in points, and how wide it is,
    /// once it has stopped loading: drawn headless, as a snapshot is.
    fn longest_line(archiver: Option<ArchiverClient>) -> (usize, f32) {
        use std::time::{Duration, Instant};
        let pvs = PvManager::new();
        // Archived by the mock, and served by no IOC: what it draws is
        // history, not a live tail.
        let mut t = Trend::new(0, &names(&["MOCK:SINE"]), &pvs, archiver, &[]);
        let ctx = egui::Context::default();
        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0));
        let begin = Instant::now();
        let mut frames = 0;
        let shapes = loop {
            let input = egui::RawInput {
                screen_rect: Some(screen),
                time: Some(begin.elapsed().as_secs_f64()),
                ..Default::default()
            };
            let mut out = ctx.run_ui(input, |ui| t.ui(ui));
            out.textures_delta.clear();
            frames += 1;
            // A few frames after it has loaded, for the data to be drawn.
            if frames > 3 && !t.is_loading() {
                break out.shapes;
            }
            assert!(
                begin.elapsed() < Duration::from_secs(30),
                "the trend was still loading after 30s"
            );
            std::thread::sleep(Duration::from_millis(20));
        };
        fn walk(shape: &egui::Shape, best: &mut (usize, f32)) {
            match shape {
                egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, best)),
                egui::Shape::Path(p) if p.points.len() > best.0 => {
                    let (lo, hi) = p.points.iter().fold((f32::MAX, f32::MIN), |(lo, hi), q| {
                        (lo.min(q.x), hi.max(q.x))
                    });
                    *best = (p.points.len(), hi - lo);
                }
                _ => {}
            }
        }
        let mut best = (0, 0.0);
        shapes.iter().for_each(|s| walk(&s.shape, &mut best));
        best
    }

    #[test]
    fn shows_history_from_the_archiver() {
        let mock = mockarchiver::MockArchiver::start_local().unwrap();
        let archiver = ArchiverClient::new(&mock.url()).unwrap();
        // MOCK:SINE has a 10 minute period, sampled every second: the
        // default hour of it is a long wavy line across the plot.
        let (points, width) = longest_line(Some(archiver));
        assert!(
            points > 100,
            "history drawn with {points} points (is http_proxy sending 127.0.0.1 to a \
             proxy? set no_proxy=127.0.0.1,localhost)"
        );
        assert!(width > 400.0, "history drawn {width} points wide");
        // Without the archiver the longest line is the plot's furniture
        // (an icon), so the line above is history.
        let (points, _) = longest_line(None);
        assert!(points < 50, "{points} points with no archiver");
    }

    #[test]
    fn the_selection_answers_only_the_latest_middle_click_in_time() {
        let pvs = PvManager::new();
        let mut t = Trend::new(0, &names(&["A"]), &pvs, None, &[]);
        let start = Instant::now();
        t.reading(1, start);
        t.reading(2, start);
        // An earlier click's answer is dropped.
        t.selected(1, MiddleClick::Plot, Some("OLD:PV".into()));
        assert_eq!(t.names(), ["A"]);
        assert!(t.is_reading());
        t.selected(2, MiddleClick::Plot, Some("B:C".into()));
        assert_eq!(t.names(), ["A", "B:C"]);
        assert!(!t.is_reading());
        // Too late: given up on, with a notice, and the answer dropped.
        t.reading(3, start);
        assert!(!t.time_out_reading(start + Duration::from_millis(500)));
        assert!(t.time_out_reading(start + SELECTION_WAIT));
        let (text, error) = t.notice().unwrap();
        assert!(error && text.contains("Could not read"), "{text}");
        t.selected(3, MiddleClick::Plot, Some("LATE:PV".into()));
        assert_eq!(t.names(), ["A", "B:C"]);
        // Into the search box: pasted there, not added.
        t.reading(4, start);
        t.selected(4, MiddleClick::Search, Some("D:E".into()));
        assert_eq!(t.names(), ["A", "B:C"]);
    }

    /// The repaint a new live trend of `sim://sine` asks for, after a few
    /// passes (the plot's own, with no input), drawn embedded or not.
    fn live_repaint(embedded: bool) -> Duration {
        let pvs = PvManager::new();
        let mut t = Trend::new(0, &names(&["sim://sine"]), &pvs, None, &[]);
        let ctx = egui::Context::default();
        ctx.set_embed_viewports(embedded);
        let mut delay = Duration::MAX;
        for i in 0..5 {
            let input = egui::RawInput {
                screen_rect: Some(egui::Rect::from_min_size(
                    egui::Pos2::ZERO,
                    egui::vec2(1000.0, 600.0),
                )),
                time: Some(i as f64 * 0.05),
                ..Default::default()
            };
            let mut out = ctx.run_ui(input, |ui| t.ui(ui));
            out.textures_delta.clear();
            delay = out.viewport_output[&egui::ViewportId::ROOT].repaint_delay;
        }
        // egui takes the next frame's predicted time off the delay.
        delay + Duration::from_secs_f64(1.0 / 60.0)
    }

    #[test]
    fn embedded_trends_hold_their_repaints_to_the_strip_charts() {
        // Just gone live, the plot's Live pill pulses at about 15 fps.
        let native = live_repaint(false);
        assert!(native < Duration::from_millis(100), "{native:?}");
        // In the root viewport (the browser, snapshots) no faster than a
        // strip chart scrolls.
        let embedded = live_repaint(true);
        let floor = Duration::from_secs_f64(MIN_SCROLL_REPAINT);
        assert!(embedded >= floor - Duration::from_millis(1), "{embedded:?}");
        assert!(embedded < Duration::MAX / 2, "still repaints: {embedded:?}");
    }

    #[test]
    fn views_leave_the_archiver_to_bobview() {
        let pvs = PvManager::new();
        let ours = ArchiverClient::new("http://ours:17668").unwrap();
        let mut t = Trend::new(0, &names(&["A"]), &pvs, Some(ours.clone()), &[]);
        assert_eq!(t.view().archiver, None, "not saved in layouts");
        t.set_view(&View {
            archiver: Some("http://theirs:17668/retrieval".into()),
            ..t.view()
        });
        assert_eq!(t.archiver(), Some(&ours));
        // Set again, it keeps the one it has (no refetch).
        t.set_archiver(Some(&ours), &[]);
        assert_eq!(t.archiver(), Some(&ours));
        t.set_archiver(None, &[]);
        assert_eq!(t.archiver(), None);
    }
}