diff --git a/Cargo.lock b/Cargo.lock index cc2bda9..8335683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3418,6 +3418,7 @@ dependencies = [ "eframe", "egui", "egui_plot", + "image", "serde", "serde_json", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 232120a..55d156b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync" eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } egui = "0.35" egui_plot = "0.36" +# image PNG encoding, for the window photographing itself (xtce-gs-gui::capture). +# Already in the tree behind eframe; only the encoder is wanted, so the +# decoders and formats that come with the default features stay out. +image = { version = "0.25", default-features = false, features = ["png"] } clap = { version = "4", features = ["derive"] } thiserror = "2" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 35612a3..73241b6 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,21 @@ $ xtce-gs export testdata/jpss/jpss1_geolocation_xtce_v1.xml \ testdata/jpss/J01_G011_LZ_2021-04-09T00-00-00Z_V01.DAT1 -o jpss.csv ``` +![The station replaying a JPSS pass: a parameter tree, a table of current values, and plots of +GPS position and velocity over one orbit](docs/station.png) + +That picture is not a screen grab. The window photographs itself, so there is a command that +makes it again rather than a file nobody can reproduce: + +```console +$ xtce-gs replay testdata/jpss/jpss1_geolocation_xtce_v1.xml \ + testdata/jpss/J01_G011_LZ_2021-04-09T00-00-00Z_V01.DAT1 \ + --rate 100000 --repeat --screenshot docs/station.png --screenshot-after 4 +``` + The replay opens with three plots already drawn — GPS position, GPS velocity and the attitude -quaternion, 7 200 points each — because a layout ships beside the definition as +quaternion, 7 200 points each, the third below the fold — because a layout ships beside the +definition as `jpss1_geolocation_xtce_v1.xml.gs-layout.json`. That is not a special case for the demo: a layout is a plain JSON file of qualified parameter names that the interface writes next to whatever definition you open, so the arrangement belongs to the mission rather than to your diff --git a/crates/xtce-gs-cli/src/args.rs b/crates/xtce-gs-cli/src/args.rs index 21770ac..191ece5 100644 --- a/crates/xtce-gs-cli/src/args.rs +++ b/crates/xtce-gs-cli/src/args.rs @@ -55,6 +55,13 @@ pub const DEFAULT_INTERLEAVE: usize = 1; /// be 600 MB of rings for an export that never looks in one. pub const EXPORT_HISTORY_DEPTH: usize = 1; +/// Seconds a `--screenshot` waits by default. +/// +/// The bundled JPSS recording is 5.1 seconds of pass at the rate the README's example uses, +/// so ten seconds is a whole pass and some of a second one — long enough that every plot in +/// the shipped layout is drawn from a full orbit rather than from the start of one. +pub const DEFAULT_SCREENSHOT_DELAY: u64 = 10; + /// A ground station for CCSDS telemetry described by XTCE. #[derive(Parser, Debug)] #[command(name = "xtce-gs", version, about, long_about = None)] @@ -126,6 +133,20 @@ pub struct RunArgs { /// Run without a window, printing counters once a second. #[arg(long)] pub headless: bool, + + /// Write a PNG of the window once it has filled, then exit. + /// + /// The window photographs itself, so this needs no screen-recording permission and + /// produces the same image on any machine that can open it. + #[arg(long, value_name = "PATH")] + pub screenshot: Option, + + /// Seconds to let the station fill before the screenshot is taken. + /// + /// Every plot is empty until packets arrive, so a picture taken immediately is a true and + /// useless one. How long is a property of the source, which only you know. + #[arg(long, value_name = "SECS", default_value_t = DEFAULT_SCREENSHOT_DELAY)] + pub screenshot_after: u64, } impl RunArgs { @@ -202,6 +223,20 @@ pub struct ReplayArgs { /// Run without a window, printing counters once a second. #[arg(long)] pub headless: bool, + + /// Write a PNG of the window once it has filled, then exit. + /// + /// The window photographs itself, so this needs no screen-recording permission and + /// produces the same image on any machine that can open it. + #[arg(long, value_name = "PATH")] + pub screenshot: Option, + + /// Seconds to let the station fill before the screenshot is taken. + /// + /// Every plot is empty until packets arrive, so a picture taken immediately is a true and + /// useless one. How long is a property of the source, which only you know. + #[arg(long, value_name = "SECS", default_value_t = DEFAULT_SCREENSHOT_DELAY)] + pub screenshot_after: u64, } impl ReplayArgs { diff --git a/crates/xtce-gs-cli/src/main.rs b/crates/xtce-gs-cli/src/main.rs index 1ac765e..275d6f8 100644 --- a/crates/xtce-gs-cli/src/main.rs +++ b/crates/xtce-gs-cli/src/main.rs @@ -19,7 +19,9 @@ #![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] +use std::path::PathBuf; use std::process::ExitCode; +use std::time::Duration; use clap::Parser; @@ -83,6 +85,18 @@ fn report(error: &dyn std::error::Error) { } } +/// Pairs `--screenshot` with `--screenshot-after`. +/// +/// The delay has a default and the path does not, so the path is what decides whether a +/// picture is wanted at all; `--screenshot-after` on its own is a no-op rather than an error, +/// the same way `--rate` is when nothing is replayed. +fn screenshot(path: Option, after: u64) -> Option { + path.map(|path| xtce_gs_gui::Screenshot { + path, + after: Duration::from_secs(after), + }) +} + /// Turns one parsed command line into one thing done. /// /// The conversions happen *here* rather than inside [`run`], so that a configuration nothing @@ -96,7 +110,8 @@ fn dispatch(cli: Cli) -> Result<(), CliError> { if args.headless { run::headless(config, verbose) } else { - run::gui(config) + let shot = screenshot(args.screenshot, args.screenshot_after); + run::gui(config, shot) } } @@ -105,7 +120,8 @@ fn dispatch(cli: Cli) -> Result<(), CliError> { if args.headless { run::headless(config, verbose) } else { - run::gui(config) + let shot = screenshot(args.screenshot, args.screenshot_after); + run::gui(config, shot) } } diff --git a/crates/xtce-gs-cli/src/run.rs b/crates/xtce-gs-cli/src/run.rs index 4a6e108..993d430 100644 --- a/crates/xtce-gs-cli/src/run.rs +++ b/crates/xtce-gs-cli/src/run.rs @@ -84,8 +84,11 @@ const CCSDS_INTERLEAVE: [u64; 6] = [1, 2, 3, 4, 5, 8]; /// /// [`CliError::Gui`] for a session that will not start, a layout that will not parse, or a /// platform that will not give the process a window. -pub fn gui(config: SessionConfig) -> Result<(), CliError> { - xtce_gs_gui::run(config).map_err(CliError::from) +pub fn gui( + config: SessionConfig, + screenshot: Option, +) -> Result<(), CliError> { + xtce_gs_gui::run_capturing(config, screenshot).map_err(CliError::from) } /// Runs with no window, printing counters once a second. diff --git a/crates/xtce-gs-gui/Cargo.toml b/crates/xtce-gs-gui/Cargo.toml index aaa2185..d9ba6d8 100644 --- a/crates/xtce-gs-gui/Cargo.toml +++ b/crates/xtce-gs-gui/Cargo.toml @@ -19,6 +19,7 @@ tokio.workspace = true eframe.workspace = true egui.workspace = true egui_plot.workspace = true +image.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/xtce-gs-gui/src/app.rs b/crates/xtce-gs-gui/src/app.rs index e170b23..c812809 100644 --- a/crates/xtce-gs-gui/src/app.rs +++ b/crates/xtce-gs-gui/src/app.rs @@ -30,6 +30,7 @@ use xtce_gs_engine::Session; use xtce_gs_engine::session::log; use xtce_model::{ParamId, XtceDb}; +use crate::capture::{Capture, Next, Screenshot}; use crate::layout::Layout; use crate::panels::events::Events; use crate::panels::plots::{Plots, View}; @@ -97,6 +98,8 @@ pub struct App { view_drawn: View, /// Passes still owed a refill after input arrived — see [`INPUT_REFRESH_PASSES`]. input_passes: u8, + /// A picture of the window this run owes somebody, and has not taken yet. + capture: Option, } /// Whether two axis ranges are the same number. @@ -158,12 +161,17 @@ impl App { /// layout comes from the file [`crate::Layout::load`] read before the window opened. The /// parameter is taken anyway because it is where the theme and the fonts belong. #[must_use] - pub fn new(cc: &eframe::CreationContext<'_>, session: Session, mut layout: Layout) -> Self { + pub fn new( + cc: &eframe::CreationContext<'_>, + session: Session, + mut layout: Layout, + screenshot: Option, + ) -> Self { let db = Arc::clone(session.db()); cc.egui_ctx.set_theme(layout.theme.preference()); watch_what_is_plotted(&mut layout); - let named = layout.watched.len(); + let named = layout.named(); let resolved = { let mut store = session .store() @@ -195,6 +203,7 @@ impl App { db, layout, status: Status::new(), + capture: screenshot.map(Capture::new), // `Tree::new` starts dirty, which is what forces the first frame's copy: the // store's generation can legitimately still be 0 at this point. tree: Tree::new(), @@ -528,7 +537,7 @@ fn restore_report(resolved: usize, named: usize) -> Event { format!( "layout: {resolved} of {named} saved parameters are in this definition; the \ other {} are kept in the layout in case it comes back", - named - resolved + named.saturating_sub(resolved) ), ) } @@ -553,6 +562,16 @@ impl eframe::App for App { } // The clock, the ages and the live indicator move on their own. ctx.request_repaint_after_secs(IDLE_REPAINT_SECONDS); + + // Last, so the frame handed back is one `refresh` has already filled. + let taken = self + .capture + .as_mut() + .is_some_and(|capture| capture.poll(ctx) == Next::Done); + if taken { + self.capture = None; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } /// Lays the panels out over the buffers [`App::refresh`] filled. Computes nothing. diff --git a/crates/xtce-gs-gui/src/capture.rs b/crates/xtce-gs-gui/src/capture.rs new file mode 100644 index 0000000..baa70fa --- /dev/null +++ b/crates/xtce-gs-gui/src/capture.rs @@ -0,0 +1,172 @@ +//! A picture of the window, taken by the window. +//! +//! # Why not a screen-capture utility +//! +//! The README shows what this station looks like with a real downlink in it, and a picture in +//! a README rots unless there is a command that produces it again. A screen grabber is not +//! that command: it needs the display server's permission — macOS refuses it outright to a +//! process that is not an application bundle, and returns the desktop picture with every +//! window missing rather than an error — it photographs whatever happens to be in front, and +//! it cannot be run by anyone who has not arranged their desktop the same way. +//! +//! Asking the window for its own framebuffer needs none of that. `egui` renders the frame it +//! was going to render anyway and hands the pixels back, so the file is the same on any +//! machine that can open the window at all, and regenerating it is one flag rather than a +//! procedure. +//! +//! # Why it waits +//! +//! Every plot is empty until packets have arrived, so a picture taken on the first frame shows +//! an empty station truthfully and uselessly. The delay is the caller's: it is how long the +//! source needs to fill the history that is worth looking at, which only the caller knows. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Where a picture of the window goes, and how long the station is given to fill first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Screenshot { + /// The PNG to write. An existing file is replaced. + pub path: PathBuf, + /// How long to let the session run before the frame is taken. + pub after: Duration, +} + +/// How often to ask for a frame while waiting, when nothing else is driving repaints. +/// +/// An operator moving a mouse produces frames; nobody is here. Without this the window would +/// repaint on the idle timer and the delay would be rounded up to it. +const WAITING_REPAINT: Duration = Duration::from_millis(100); + +/// What the capture wants the caller to do next. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Next { + /// The clock has not run out, or the frame has not come back yet. + Waiting, + /// The file is written, or writing it failed and was reported. Close the window. + Done, +} + +/// One pending screenshot. +pub(crate) struct Capture { + request: Screenshot, + opened: Instant, + asked: bool, +} + +impl Capture { + /// Starts the clock. + pub(crate) fn new(request: Screenshot) -> Self { + Self { + request, + opened: Instant::now(), + asked: false, + } + } + + /// Asks for the frame once the window has had its time, then writes what comes back. + /// + /// The request and the reply are a frame apart at best: `egui` reads the framebuffer after + /// the frame it was asked during has been painted, and delivers it as an event on a later + /// one. So this returns [`Next::Waiting`] at least twice. + pub(crate) fn poll(&mut self, ctx: &egui::Context) -> Next { + if !self.asked { + // Wherever the pointer happens to be resting is a widget that will raise a + // tooltip, and a tooltip in a README picture looks like part of the interface. + // Nobody is here to hover deliberately, so nothing should be hovered at all. + ctx.all_styles_mut(|style| style.interaction.tooltip_delay = f32::MAX); + if self.opened.elapsed() < self.request.after { + ctx.request_repaint_after(WAITING_REPAINT); + return Next::Waiting; + } + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + self.asked = true; + ctx.request_repaint(); + return Next::Waiting; + } + + let frame = ctx.input(|input| { + input.events.iter().find_map(|event| match event { + egui::Event::Screenshot { image, .. } => Some(Arc::clone(image)), + _ => None, + }) + }); + let Some(frame) = frame else { + ctx.request_repaint(); + return Next::Waiting; + }; + + // Reported on the terminal the flag was typed into, both ways round: this runs with + // nobody watching the window, and a silent failure here is a stale README. + match write_png(&self.request.path, &frame) { + Ok(()) => println!( + "screenshot: {} ({}x{})", + self.request.path.display(), + frame.size[0], + frame.size[1] + ), + Err(reason) => eprintln!( + "screenshot: {} was not written: {reason}", + self.request.path.display() + ), + } + Next::Done + } +} + +/// Writes one `egui` frame out as a PNG. +/// +/// `egui`'s colours are premultiplied by their alpha. A window's own framebuffer is opaque, so +/// the multiplication was by one and the bytes go straight out; a translucent pixel would come +/// out darker than it looks, which is a trade this does not have to make. +fn write_png(path: &Path, frame: &egui::ColorImage) -> Result<(), String> { + let [width, height] = frame.size; + let width = u32::try_from(width).map_err(|_| format!("{width} pixels is too wide"))?; + let height = u32::try_from(height).map_err(|_| format!("{height} pixels is too tall"))?; + + let mut bytes = Vec::with_capacity(frame.pixels.len() * 4); + for pixel in &frame.pixels { + bytes.extend_from_slice(&pixel.to_array()); + } + + let buffer = image::RgbaImage::from_raw(width, height, bytes).ok_or_else(|| { + format!( + "{width}x{height} does not match {} pixels", + frame.pixels.len() + ) + })?; + buffer + .save_with_format(path, image::ImageFormat::Png) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(width: usize, height: usize) -> egui::ColorImage { + let pixels = vec![egui::Color32::from_rgb(0x1D, 0x5B, 0xA6); width * height]; + egui::ColorImage::new([width, height], pixels) + } + + #[test] + fn a_frame_is_written_as_a_png_of_the_same_size() { + let path = std::env::temp_dir().join("xtce-gs-capture-size.png"); + write_png(&path, &frame(7, 3)).expect("a 7x3 frame is writable"); + + let written = std::fs::read(&path).expect("the file is there"); + // The eight-byte PNG signature, then IHDR's width and height, big-endian. + assert_eq!(&written[..8], b"\x89PNG\r\n\x1a\n"); + assert_eq!(&written[16..24], &[0, 0, 0, 7, 0, 0, 0, 3]); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a_path_that_cannot_be_written_is_reported_and_not_a_panic() { + // A directory that does not exist, which is what a mistyped flag looks like. + let path = std::env::temp_dir().join("xtce-gs-no-such-directory/shot.png"); + let error = write_png(&path, &frame(2, 2)).expect_err("the directory is not there"); + assert!(!error.is_empty(), "the failure has to say something"); + } +} diff --git a/crates/xtce-gs-gui/src/layout.rs b/crates/xtce-gs-gui/src/layout.rs index a6e43d4..f0176e2 100644 --- a/crates/xtce-gs-gui/src/layout.rs +++ b/crates/xtce-gs-gui/src/layout.rs @@ -22,6 +22,7 @@ //! layout that referred to indices would silently plot the wrong parameter after an edit, //! which is the failure nobody checks for because the plot still draws. +use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::io::ErrorKind; @@ -364,15 +365,38 @@ impl Layout { /// watches would draw an empty box for the rest of the session with no error anywhere, /// which is the worst shape a failure can take: the station works and shows nothing. pub fn restore(&self, db: &XtceDb, store: &mut ParameterStore) -> usize { + // Counted by parameter and not by mention. A plotted parameter is in `watched` too — + // `App` puts it there before this runs, and a layout saved by the interface has it + // both places anyway — so counting mentions returns more than the layout names, and + // the caller that subtracts the two gets `usize::MAX` minus a little. Deduplicating + // on the resolved id rather than on the string also means two names for one parameter + // count once, which is what the number is reported as meaning. let plotted = self.plots.iter().flat_map(|plot| plot.parameters.iter()); - let mut resolved = 0; + let mut resolved = HashSet::new(); for name in self.watched.iter().chain(plotted) { if let Some(parameter) = db.find_parameter(name) { store.watch(parameter); - resolved += 1; + resolved.insert(parameter); } } - resolved + resolved.len() + } + + /// How many distinct parameters this layout names, watched and plotted together. + /// + /// The denominator for [`Layout::restore`], and counted the same way: a parameter that is + /// watched *and* drawn in two plots is one name here and one resolution there, so the two + /// can be compared and subtracted. + #[must_use] + pub fn named(&self) -> usize { + let plotted = self.plots.iter().flat_map(|plot| plot.parameters.iter()); + let names: HashSet<&str> = self + .watched + .iter() + .chain(plotted) + .map(String::as_str) + .collect(); + names.len() } /// Copies what the store is watching into the layout, before a save. @@ -699,6 +723,29 @@ mod tests { assert!(matches!(error, GuiError::Io(_)), "{error}"); } + /// A parameter that is watched *and* plotted is one parameter. + /// + /// It used to be two, because `restore` counted mentions while the caller's denominator + /// counted the watch list — so the station reported "22 of 12" on the first layout that + /// shipped with plots in it, and subtracted its way to 18 446 744 073 709 551 606. + #[test] + fn a_parameter_that_is_watched_and_plotted_resolves_once() { + let db = db(); + let mut store = ParameterStore::new(db.parameters().len(), 16); + let mut layout = Layout::default(); + layout.watched.push("/Sat/VOLTS".to_owned()); + let mut plot = PlotLayout::new("volts"); + plot.parameters.push("/Sat/VOLTS".to_owned()); + layout.plots.push(plot); + + assert_eq!(layout.named(), 1, "one parameter, named twice"); + assert_eq!(layout.restore(&db, &mut store), 1); + assert!( + layout.restore(&db, &mut store) <= layout.named(), + "a resolution count above the denominator is what underflowed the report" + ); + } + #[test] fn restore_counts_the_names_this_definition_still_declares() { let db = db(); diff --git a/crates/xtce-gs-gui/src/lib.rs b/crates/xtce-gs-gui/src/lib.rs index 7884b91..a0eca8d 100644 --- a/crates/xtce-gs-gui/src/lib.rs +++ b/crates/xtce-gs-gui/src/lib.rs @@ -56,12 +56,14 @@ )] pub mod app; +pub mod capture; pub mod error; pub mod fmt; pub mod layout; pub mod panels; pub use app::App; +pub use capture::Screenshot; pub use error::GuiError; pub use layout::{Discarded, Layout, PlotLayout, Theme}; @@ -252,6 +254,25 @@ impl eframe::App for Failure { /// the operator has to fix, and a machine with no display is a machine where this is being /// read off a terminal anyway. pub fn run(config: SessionConfig) -> Result<(), GuiError> { + run_capturing(config, None) +} + +/// [`run`], and a picture of the window on the way past. +/// +/// With `screenshot` given, the station runs normally until the delay in it has passed, writes +/// its own framebuffer to the path and closes. It is how the image in the README is made — +/// see [`crate::capture`] for why the window takes the picture rather than a screen grabber. +/// +/// # Errors +/// +/// As [`run`]. A screenshot that cannot be written is reported on the terminal and does not +/// fail the run: the session was real and its counters were real, and a station that exited +/// non-zero over a picture would be the wrong trade for an operator who passed the flag by +/// accident. +pub fn run_capturing( + config: SessionConfig, + screenshot: Option, +) -> Result<(), GuiError> { let Discarded { layout, note } = Layout::load(&config.definition)?; if let Some(note) = note { // Before the window, deliberately: this is the one moment the operator still has a @@ -294,7 +315,7 @@ pub fn run(config: SessionConfig) -> Result<(), GuiError> { // The waker has been live since `Session::start`; every call it made before this // line did nothing, and nothing was lost by that — see [`RepaintSlot`]. slot.fill(cc.egui_ctx.clone()); - Ok(Box::new(App::new(cc, session, layout))) + Ok(Box::new(App::new(cc, session, layout, screenshot))) }), ); diff --git a/crates/xtce-gs-gui/src/panels/status.rs b/crates/xtce-gs-gui/src/panels/status.rs index abdea7b..eb790d2 100644 --- a/crates/xtce-gs-gui/src/panels/status.rs +++ b/crates/xtce-gs-gui/src/panels/status.rs @@ -430,28 +430,57 @@ pub fn show( scratch: &mut String, ) -> Action { ui.horizontal(|ui| { - indicator(ui, status); - ui.label(source) - .on_hover_text("The source that was opened, which is not always the one configured"); - ui.separator(); - arrival(ui, status, scratch); - ui.separator(); - if framed { - frames(ui, status, scratch); - ui.separator(); - coding(ui, status, scratch); - ui.separator(); - } - packets(ui, status, scratch); - + // The controls go in before the counters, which reads backwards and is the only order + // that works: in a left-to-right strip egui hands a right-aligned block the width that + // is *left over*, and once the counters have taken it all that block is given a rect + // starting at the right edge — so it draws back over them, and the last counter + // disappears under `Pause`. Right-aligned first, counters in what remains, and a + // window too narrow for both truncates instead of overlapping. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - controls(ui, layout, paused) + let action = controls(ui, layout, paused); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + indicator(ui, status); + // A file source carries its whole path and query string — rate, chunk size, + // repeat — which is longer than everything else on this strip put together + // and is what pushed the counters under the buttons. Capped and truncated; + // the hover still has all of it, and that is where anybody reading a URL for + // detail is going to look anyway. + ui.scope(|ui| { + ui.set_max_width((ui.available_width() * SOURCE_WIDTH_SHARE).max(SOURCE_MIN)); + ui.add(egui::Label::new(source).truncate()).on_hover_text( + "The source that was opened, which is not always the one configured", + ); + }); + ui.separator(); + arrival(ui, status, scratch); + ui.separator(); + if framed { + frames(ui, status, scratch); + ui.separator(); + coding(ui, status, scratch); + ui.separator(); + } + packets(ui, status, scratch); + }); + action }) .inner }) .inner } +/// How much of the strip the source label may take before it is truncated. +/// +/// A share and not a width: the counters beside it are a fixed set, so what is left for the +/// source grows with the window rather than staying at whatever looked right on one. +const SOURCE_WIDTH_SHARE: f32 = 0.34; + +/// The least the source label gets, however narrow the window is. +/// +/// Enough for `udp://0.0.0.0:10015`, which is the shape of the one an operator reads at a +/// glance to check they are on the right port. +const SOURCE_MIN: f32 = 150.0; + /// The dot, coloured by [`Status::liveness`]. fn indicator(ui: &mut egui::Ui, status: &Status) { let liveness = status.liveness(); diff --git a/crates/xtce-gs-gui/tests/against_a_session.rs b/crates/xtce-gs-gui/tests/against_a_session.rs index 252ffd2..47eaf00 100644 --- a/crates/xtce-gs-gui/tests/against_a_session.rs +++ b/crates/xtce-gs-gui/tests/against_a_session.rs @@ -243,9 +243,11 @@ fn the_shipped_layout_opens_on_three_full_plots() { let resolved = layout.restore(&db, &mut store); assert_eq!( resolved, - layout.watched.len() + names.len(), + layout.named(), "the shipped layout names a parameter this definition does not declare" ); + // Counted by parameter, not by mention: every plotted name is a watched name too. + assert_eq!(layout.named(), layout.watched.len()); } let deadline = Instant::now() + Duration::from_secs(30); diff --git a/docs/station.png b/docs/station.png new file mode 100644 index 0000000..66c558b Binary files /dev/null and b/docs/station.png differ