use web_time::{SystemTime, UNIX_EPOCH};
use eframe::egui::{self, Ui};
use pv::{Pv, PvName, PvState, Scheme, Severity, Value};
use crate::screen::Node;
use crate::widgets::Request;
pub struct Details {
pub pv: Pv,
pub widget: String,
pub file: Option<bob::Source>,
}
pub fn menu(
ui: &mut Ui,
node: &Node,
state: Option<&PvState>,
trends: &[(u64, String)],
requests: &mut Vec<Request>,
) {
let name = node.pv_name.as_str();
let value = state.filter(|s| s.connected).map(|s| s.format(None, true));
if ui.button("Copy PV name").clicked()
&& let Some(names) = copy_names(node)
{
requests.push(Request::Copy(names));
}
if let (Some(state), Some(value)) = (state, &value) {
if ui.button("Copy value").clicked() {
ui.ctx().copy_text(value.clone());
}
if ui.button("Copy name and value").clicked() {
ui.ctx().copy_text(format!("{name} = {value}"));
}
if ui.button("Copy name, value, alarm and time").clicked() {
ui.ctx().copy_text(full_line(name, state));
}
}
if let Some(cmd) = cli_command(&PvName::parse(name))
&& ui.button(format!("Copy \"{cmd}\"")).clicked()
{
ui.ctx().copy_text(cmd);
}
if let Some(pv) = &node.pv {
ui.separator();
if ui.button("Trend").clicked() {
requests.push(Request::Trend(vec![name.to_string()]));
}
let all = trend_names(node);
if all.len() > 1
&& ui
.button(format!("Trend with rule PVs ({})", all.len()))
.clicked()
{
requests.push(Request::Trend(all.clone()));
}
add_to_trend(ui, "", &[name.to_string()], trends, requests);
if all.len() > 1 {
let with = format!(" with rule PVs ({})", all.len());
add_to_trend(ui, &with, &all, trends, requests);
}
if ui.button("PV details…").clicked() {
requests.push(Request::Details(Details {
pv: pv.clone(),
widget: widget_label(node),
file: node.base.clone(),
}));
}
}
}
fn add_to_trend(
ui: &mut Ui,
what: &str,
names: &[String],
trends: &[(u64, String)],
requests: &mut Vec<Request>,
) {
match trends {
[] => {}
[(id, title)] => {
if ui.button(format!("Add{what} to \"{title}\"")).clicked() {
requests.push(Request::TrendAdd(*id, names.to_vec()));
}
}
_ => {
ui.menu_button(format!("Add{what} to trend"), |ui| {
for (id, title) in trends {
if ui.button(title).clicked() {
requests.push(Request::TrendAdd(*id, names.to_vec()));
}
}
});
}
}
}
fn trend_names(node: &Node) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let rules = node.rules.iter().flat_map(|r| r.pvs());
for pv in node.pv.iter().chain(rules) {
let name = pv.name().to_string();
if !names.contains(&name) {
names.push(name);
}
}
names
}
pub fn copy_names(node: &Node) -> Option<String> {
let mut names: Vec<String> = Vec::new();
if !node.pv_name.is_empty() {
copy_name(&PvName::parse(&node.pv_name), &mut names);
} else {
for t in &node.traces {
for pv in t.y.iter().chain(&t.x) {
copy_name(pv.name(), &mut names);
}
}
}
(!names.is_empty()).then(|| names.join(" "))
}
fn copy_name(name: &PvName, names: &mut Vec<String>) {
let name = match name.scheme {
Scheme::Ca => name.pv.clone(),
Scheme::Pva | Scheme::Loc | Scheme::Sim => name.key(),
Scheme::Unsupported => name.to_string(),
Scheme::Formula => {
for input in pv::formula_inputs(&name.pv) {
let input = PvName::parse(&input);
if input.scheme != Scheme::Formula {
copy_name(&input, names);
}
}
return;
}
};
if !names.contains(&name) {
names.push(name);
}
}
pub fn details_ui(ui: &mut Ui, details: &Details) {
let rows = details_rows(details, &details.pv.state());
egui::Grid::new("pv-details")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
for (key, val) in &rows {
ui.label(*key);
ui.label(val);
ui.end_row();
}
});
ui.add_space(4.0);
if ui.button("Copy all").clicked() {
let text: Vec<String> = rows.iter().map(|(k, v)| format!("{k}: {v}")).collect();
ui.ctx().copy_text(text.join("\n"));
}
}
fn details_rows(details: &Details, s: &PvState) -> Vec<(&'static str, String)> {
let name = details.pv.name();
let mut rows = vec![
("Name", name.to_string()),
("Protocol", protocol(name.scheme).into()),
(
"Connection",
match (&s.connected, &s.error) {
(true, _) => "connected".into(),
(false, Some(e)) => format!("disconnected: {e}"),
(false, None) => "disconnected".into(),
},
),
];
if s.connected {
rows.push(("Value", s.format(None, true)));
rows.push(("Type", value_type(&s.value)));
rows.push((
"Alarm",
format!("{} (status {})", severity_name(s.severity), s.status),
));
if let Some(t) = s.timestamp {
rows.push(("Time", format_time(t)));
}
if !s.units.is_empty() {
rows.push(("Units", s.units.clone()));
}
if let Some(p) = s.precision {
rows.push(("Precision", p.to_string()));
}
if let Some((lo, hi)) = s.display_range {
rows.push(("Display limits", format!("{lo} to {hi}")));
}
if let Some((lo, hi)) = s.control_range {
rows.push(("Control limits", format!("{lo} to {hi}")));
}
if !s.labels.is_empty() {
rows.push(("States", s.labels.join(", ")));
}
rows.push(("Writable", writable_text(s).into()));
}
if let Some(e) = &s.write_error {
rows.push(("Last write failed", e.clone()));
}
rows.push(("Widget", details.widget.clone()));
if let Some(file) = &details.file {
rows.push(("Display", file.to_string()));
}
rows
}
fn writable_text(s: &PvState) -> &'static str {
match (s.writable, s.writable_known) {
(true, true) => "yes",
(false, _) => "no",
(true, false) => "unknown (the server decides when you write)",
}
}
fn full_line(name: &str, s: &PvState) -> String {
let mut line = format!(
"{name} = {} {}",
s.format(None, true),
severity_name(s.severity)
);
if let Some(t) = s.timestamp {
line.push(' ');
line.push_str(&format_time(t));
}
line
}
fn cli_command(name: &PvName) -> Option<String> {
match name.scheme {
Scheme::Ca => Some(format!("caget {}", shell_quote(&name.pv))),
Scheme::Pva => Some(format!("pvget {}", shell_quote(&name.pv))),
Scheme::Loc | Scheme::Sim | Scheme::Formula | Scheme::Unsupported => None,
}
}
fn shell_quote(s: &str) -> String {
let plain = |c: char| c.is_ascii_alphanumeric() || "_-:.+/=,@%".contains(c);
if !s.is_empty() && s.chars().all(plain) {
s.to_string()
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
fn widget_label(node: &Node) -> String {
match node.w.text("name") {
Some(name) if !name.is_empty() => format!("{} \"{name}\"", node.w.kind),
_ => node.w.kind.clone(),
}
}
fn protocol(scheme: Scheme) -> &'static str {
match scheme {
Scheme::Ca => "Channel Access",
Scheme::Pva => "PV Access",
Scheme::Loc => "local",
Scheme::Sim => "simulated",
Scheme::Formula => "formula",
Scheme::Unsupported => "not supported",
}
}
fn severity_name(sevr: Severity) -> &'static str {
match sevr {
Severity::NoAlarm => "NO_ALARM",
Severity::Minor => "MINOR",
Severity::Major => "MAJOR",
Severity::Invalid => "INVALID",
}
}
fn value_type(v: &Value) -> String {
match v {
Value::None => "none".into(),
Value::Double(_) => "double".into(),
Value::Long(_) => "integer".into(),
Value::Str(_) => "string".into(),
Value::Enum(_) => "enum".into(),
Value::DoubleArray(a) => format!("double[{}]", a.len()),
Value::LongArray(a) => format!("integer[{}]", a.len()),
Value::StrArray(a) => format!("string[{}]", a.len()),
Value::Image(img) => format!("image {}x{}", img.width, img.height),
}
}
fn format_time(t: SystemTime) -> String {
let Ok(d) = t.duration_since(UNIX_EPOCH) else {
return "before 1970".into();
};
let secs = d.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, day) = civil_from_days(days as i64);
format!(
"{y:04}-{m:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
rem / 3600,
rem / 60 % 60,
rem % 60,
d.subsec_millis()
)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn writable_text_says_when_it_is_only_a_guess() {
let mut s = PvState::default();
assert_eq!(writable_text(&s), "no");
s.writable = true;
assert!(writable_text(&s).starts_with("unknown"));
s.writable_known = true;
assert_eq!(writable_text(&s), "yes");
}
#[test]
fn formats_utc_time() {
let t = UNIX_EPOCH + Duration::from_millis(1_792_004_913_123);
assert_eq!(format_time(t), "2026-10-14T19:08:33.123Z");
assert_eq!(format_time(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
let t = UNIX_EPOCH + Duration::from_secs(951_782_400);
assert_eq!(format_time(t), "2000-02-29T00:00:00.000Z");
}
#[test]
fn cli_commands_by_protocol() {
let cmd = |n| cli_command(&PvName::parse(n));
assert_eq!(cmd("BL01T:X").as_deref(), Some("caget BL01T:X"));
assert_eq!(cmd("ca://BL01T:X").as_deref(), Some("caget BL01T:X"));
assert_eq!(cmd("pva://BL01T:IMG").as_deref(), Some("pvget BL01T:IMG"));
assert_eq!(cmd("loc://x<VDouble>(3)"), None);
assert_eq!(cmd("sim://sine"), None);
assert_eq!(cmd("PV;id").as_deref(), Some("caget 'PV;id'"));
assert_eq!(cmd("pva://a'b $x").as_deref(), Some(r"pvget 'a'\''b $x'"));
}
#[test]
fn copied_names_are_as_other_tools_take_them() {
let name = |n| {
let mut names = Vec::new();
copy_name(&PvName::parse(n), &mut names);
names.join(" ")
};
assert_eq!(name("BL01T:X"), "BL01T:X");
assert_eq!(name("ca://BL01T:X"), "BL01T:X");
assert_eq!(name("ca://T<1>(2)"), "T<1>(2)");
assert_eq!(name("pva://BL01T:IMG"), "pva://BL01T:IMG");
assert_eq!(name("loc://x<VDouble>(1.5)"), "loc://x");
assert_eq!(name("sim://sine(0, 10, 0.1)"), "sim://sine(0, 10, 0.1)");
assert_eq!(
name("=`ca://A:B` + 'pva://C' * `A:B` + 'loc://m<VLong>(1)'"),
"A:B pva://C loc://m"
);
assert_eq!(name("=1 + 2"), "");
}
#[test]
fn a_widget_copies_its_pv_and_a_plot_its_traces() {
let xml = r#"<display><macros><P>BL01T</P></macros>
<widget type="textupdate"><pv_name>ca://$(P):M1</pv_name>
<rules><rule name="r" prop_id="visible"><exp bool_exp="pv0 == 1"><value>false</value></exp>
<pv_name>$(P):HIDE</pv_name></rule></rules></widget>
<widget type="xyplot"><traces>
<trace><x_pv>$(P):X</x_pv><y_pv>pva://$(P):Y1</y_pv></trace>
<trace><x_pv>$(P):X</x_pv><y_pv>$(P):Y2</y_pv></trace></traces></widget>
<widget type="xyplot"></widget></display>"#;
let pvs = pv::PvManager::new();
let display = bob::Display::parse(xml).unwrap();
let screen =
crate::screen::Builder::new(&pvs).screen(display, &bob::Macros::default(), None, 0);
let copied: Vec<Option<String>> = screen.nodes.iter().map(copy_names).collect();
assert_eq!(
copied,
[
Some("BL01T:M1".into()),
Some("pva://BL01T:Y1 BL01T:X BL01T:Y2".into()),
None
]
);
}
#[test]
fn full_line_has_value_alarm_and_time() {
let s = PvState {
connected: true,
value: Value::Double(2.5),
units: "mm".into(),
precision: Some(2),
severity: Severity::Minor,
timestamp: Some(UNIX_EPOCH),
..Default::default()
};
assert_eq!(
full_line("BL01T:M1", &s),
"BL01T:M1 = 2.50 mm MINOR 1970-01-01T00:00:00.000Z"
);
}
#[test]
fn a_failed_write_is_shown_while_disconnected() {
let pvs = pv::PvManager::new();
let details = Details {
pv: pvs.subscribe("loc://details_test(1)"),
widget: "Text Update".into(),
file: None,
};
let s = PvState {
connected: false,
write_error: Some("not connected".into()),
..Default::default()
};
let rows = details_rows(&details, &s);
assert!(
rows.contains(&("Last write failed", "not connected".into())),
"{rows:?}"
);
}
}