EIS-BLE-S3/cue/src/app.rs

980 lines
38 KiB
Rust

use futures::SinkExt;
use iced::widget::{
button, canvas, column, container, pane_grid, pick_list, row, text, text_editor, text_input,
};
use iced::widget::button::Style as ButtonStyle;
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
use std::fmt::Write;
use std::time::Duration;
use tokio::sync::mpsc;
use crate::ble::BleEvent;
use crate::native_menu::{MenuAction, NativeMenu};
use crate::protocol::{
self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint,
PhResult, Rcal, Rtia,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Eis,
Lsv,
Amp,
Chlorine,
Ph,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PaneId {
Plot,
Data,
}
#[derive(Debug, Clone)]
pub enum Message {
BleReady(mpsc::UnboundedSender<Vec<u8>>),
BleStatus(String),
BleData(EisMessage),
TabSelected(Tab),
PaneResized(pane_grid::ResizeEvent),
DataAction(text_editor::Action),
/* EIS */
FreqStartChanged(String),
FreqStopChanged(String),
PpdChanged(String),
RtiaSelected(Rtia),
RcalSelected(Rcal),
ElectrodeSelected(Electrode),
ApplySettings,
StartSweep,
/* LSV */
LsvStartVChanged(String),
LsvStopVChanged(String),
LsvScanRateChanged(String),
LsvRtiaSelected(LpRtia),
StartLsv,
/* Amperometry */
AmpVholdChanged(String),
AmpIntervalChanged(String),
AmpDurationChanged(String),
AmpRtiaSelected(LpRtia),
StartAmp,
StopAmp,
/* Chlorine */
ClCondVChanged(String),
ClCondTChanged(String),
ClFreeVChanged(String),
ClTotalVChanged(String),
ClDepTChanged(String),
ClMeasTChanged(String),
ClRtiaSelected(LpRtia),
StartCl,
/* pH */
PhStabilizeChanged(String),
StartPh,
/* Global */
PollTemp,
NativeMenuTick,
CloseSysInfo,
/* Reference baseline */
SetReference,
ClearReference,
/* Misc */
OpenMidiSetup,
}
pub struct App {
tab: Tab,
status: String,
cmd_tx: Option<mpsc::UnboundedSender<Vec<u8>>>,
panes: pane_grid::State<PaneId>,
native_menu: NativeMenu,
show_sysinfo: bool,
/* EIS */
eis_points: Vec<EisPoint>,
sweep_total: u16,
freq_start: String,
freq_stop: String,
ppd: String,
rtia: Rtia,
rcal: Rcal,
electrode: Electrode,
eis_data: text_editor::Content,
/* LSV */
lsv_points: Vec<LsvPoint>,
lsv_total: u16,
lsv_start_v: String,
lsv_stop_v: String,
lsv_scan_rate: String,
lsv_rtia: LpRtia,
lsv_data: text_editor::Content,
/* Amp */
amp_points: Vec<AmpPoint>,
amp_total: u16,
amp_running: bool,
amp_v_hold: String,
amp_interval: String,
amp_duration: String,
amp_rtia: LpRtia,
amp_data: text_editor::Content,
/* Chlorine */
cl_points: Vec<ClPoint>,
cl_result: Option<ClResult>,
cl_total: u16,
cl_cond_v: String,
cl_cond_t: String,
cl_free_v: String,
cl_total_v: String,
cl_dep_t: String,
cl_meas_t: String,
cl_rtia: LpRtia,
cl_data: text_editor::Content,
/* pH */
ph_result: Option<PhResult>,
ph_stabilize: String,
/* Reference baselines (tap water) */
eis_ref: Option<Vec<EisPoint>>,
lsv_ref: Option<Vec<LsvPoint>>,
amp_ref: Option<Vec<AmpPoint>>,
cl_ref: Option<(Vec<ClPoint>, ClResult)>,
ph_ref: Option<PhResult>,
/* Global */
temp_c: f32,
}
/* ---- data table formatting ---- */
fn fmt_eis(pts: &[EisPoint]) -> String {
let mut s = String::with_capacity(pts.len() * 130 + 130);
writeln!(s, "{:>10} {:>12} {:>10} {:>12} {:>12} {:>10} {:>10} {:>12} {:>10} {:>7}",
"Freq (Hz)", "|Z| (Ohm)", "Phase (°)", "Re (Ohm)", "Im (Ohm)",
"RTIA bef", "RTIA aft", "|Z| rev", "Ph rev", "Err%").unwrap();
for pt in pts {
writeln!(s, "{:>10.1} {:>12.2} {:>10.2} {:>12.2} {:>12.2} {:>10.1} {:>10.1} {:>12.2} {:>10.2} {:>6.2}%",
pt.freq_hz, pt.mag_ohms, pt.phase_deg, pt.z_real, pt.z_imag,
pt.rtia_mag_before, pt.rtia_mag_after, pt.rev_mag, pt.rev_phase, pt.pct_err).unwrap();
}
s
}
fn fmt_lsv(pts: &[LsvPoint]) -> String {
let mut s = String::with_capacity(pts.len() * 28 + 28);
writeln!(s, "{:>10} {:>12}", "V (mV)", "I (uA)").unwrap();
for pt in pts {
writeln!(s, "{:>10.1} {:>12.3}", pt.v_mv, pt.i_ua).unwrap();
}
s
}
fn fmt_amp(pts: &[AmpPoint]) -> String {
let mut s = String::with_capacity(pts.len() * 28 + 28);
writeln!(s, "{:>10} {:>12}", "t (ms)", "I (uA)").unwrap();
for pt in pts {
writeln!(s, "{:>10.1} {:>12.3}", pt.t_ms, pt.i_ua).unwrap();
}
s
}
fn fmt_cl(pts: &[ClPoint]) -> String {
let mut s = String::with_capacity(pts.len() * 36 + 36);
writeln!(s, "{:>10} {:>12} {:>8}", "t (ms)", "I (uA)", "Phase").unwrap();
for pt in pts {
let phase = match pt.phase { 1 => "Free", 2 => "Total", _ => "Cond" };
writeln!(s, "{:>10.1} {:>12.3} {:>8}", pt.t_ms, pt.i_ua, phase).unwrap();
}
s
}
const SQUIRCLE: f32 = 8.0;
fn btn_style(bg: Color, fg: Color) -> impl Fn(&Theme, button::Status) -> ButtonStyle {
move |_theme, status| {
let (bg, fg) = match status {
button::Status::Hovered => (
Color { a: bg.a * 0.85, ..bg },
fg,
),
button::Status::Pressed => (
Color { a: bg.a * 0.7, ..bg },
fg,
),
button::Status::Disabled => (
Color { a: 0.3, ..bg },
Color { a: 0.5, ..fg },
),
_ => (bg, fg),
};
ButtonStyle {
background: Some(iced::Background::Color(bg)),
text_color: fg,
border: Border {
radius: SQUIRCLE.into(),
..Border::default()
},
..ButtonStyle::default()
}
}
}
fn style_action() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
btn_style(Color::from_rgb(0.20, 0.65, 0.35), Color::WHITE)
}
fn style_danger() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
btn_style(Color::from_rgb(0.80, 0.25, 0.25), Color::WHITE)
}
fn style_apply() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
btn_style(Color::from_rgb(0.25, 0.50, 0.85), Color::WHITE)
}
fn style_tab(active: bool) -> impl Fn(&Theme, button::Status) -> ButtonStyle {
let bg = if active {
Color::from_rgb(0.35, 0.35, 0.40)
} else {
Color::from_rgb(0.22, 0.22, 0.25)
};
btn_style(bg, Color::WHITE)
}
fn style_neutral() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
btn_style(Color::from_rgb(0.30, 0.30, 0.35), Color::WHITE)
}
impl App {
pub fn new() -> (Self, Task<Message>) {
(Self {
tab: Tab::Eis,
status: "Starting...".into(),
cmd_tx: None,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Horizontal,
ratio: 0.55,
a: Box::new(pane_grid::Configuration::Pane(PaneId::Plot)),
b: Box::new(pane_grid::Configuration::Pane(PaneId::Data)),
}),
native_menu: NativeMenu::init(),
show_sysinfo: false,
eis_points: Vec::new(),
sweep_total: 0,
freq_start: "1000".into(),
freq_stop: "200000".into(),
ppd: "10".into(),
rtia: Rtia::R5K,
rcal: Rcal::R3K,
electrode: Electrode::FourWire,
eis_data: text_editor::Content::with_text(&fmt_eis(&[])),
lsv_points: Vec::new(),
lsv_total: 0,
lsv_start_v: "0".into(),
lsv_stop_v: "500".into(),
lsv_scan_rate: "50".into(),
lsv_rtia: LpRtia::R10K,
lsv_data: text_editor::Content::with_text(&fmt_lsv(&[])),
amp_points: Vec::new(),
amp_total: 0,
amp_running: false,
amp_v_hold: "200".into(),
amp_interval: "100".into(),
amp_duration: "60".into(),
amp_rtia: LpRtia::R10K,
amp_data: text_editor::Content::with_text(&fmt_amp(&[])),
cl_points: Vec::new(),
cl_result: None,
cl_total: 0,
cl_cond_v: "800".into(),
cl_cond_t: "2000".into(),
cl_free_v: "100".into(),
cl_total_v: "-200".into(),
cl_dep_t: "5000".into(),
cl_meas_t: "5000".into(),
cl_rtia: LpRtia::R10K,
cl_data: text_editor::Content::with_text(&fmt_cl(&[])),
ph_result: None,
ph_stabilize: "30".into(),
eis_ref: None,
lsv_ref: None,
amp_ref: None,
cl_ref: None,
ph_ref: None,
temp_c: 25.0,
}, Task::none())
}
pub fn title(&self) -> String { "Cue".into() }
pub fn theme(&self) -> Theme { Theme::Dark }
fn send_cmd(&self, sysex: &[u8]) {
if let Some(tx) = &self.cmd_tx {
let _ = tx.send(sysex.to_vec());
}
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::BleReady(tx) => {
self.cmd_tx = Some(tx);
self.send_cmd(&protocol::build_sysex_get_config());
}
Message::BleStatus(s) => self.status = s,
Message::BleData(msg) => match msg {
EisMessage::SweepStart { num_points, freq_start, freq_stop } => {
self.eis_points.clear();
self.sweep_total = num_points;
self.eis_data = text_editor::Content::with_text(&fmt_eis(&self.eis_points));
self.status = format!("Sweep: {} pts, {:.0}--{:.0} Hz", num_points, freq_start, freq_stop);
}
EisMessage::DataPoint { point, .. } => {
self.eis_points.push(point);
self.eis_data = text_editor::Content::with_text(&fmt_eis(&self.eis_points));
self.status = format!("Receiving: {}/{}", self.eis_points.len(), self.sweep_total);
}
EisMessage::SweepEnd => {
self.status = format!("Sweep complete: {} points", self.eis_points.len());
}
EisMessage::Config(cfg) => {
self.freq_start = format!("{:.0}", cfg.freq_start);
self.freq_stop = format!("{:.0}", cfg.freq_stop);
self.ppd = format!("{}", cfg.ppd);
self.rtia = cfg.rtia;
self.rcal = cfg.rcal;
self.electrode = cfg.electrode;
self.status = "Config received".into();
}
EisMessage::LsvStart { num_points, v_start, v_stop } => {
self.lsv_points.clear();
self.lsv_total = num_points;
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
self.status = format!("LSV: {} pts, {:.0}--{:.0} mV", num_points, v_start, v_stop);
}
EisMessage::LsvPoint { point, .. } => {
self.lsv_points.push(point);
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
self.status = format!("LSV: {}/{}", self.lsv_points.len(), self.lsv_total);
}
EisMessage::LsvEnd => {
self.status = format!("LSV complete: {} points", self.lsv_points.len());
}
EisMessage::AmpStart { v_hold } => {
self.amp_points.clear();
self.amp_running = true;
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
self.status = format!("Amp: {:.0} mV", v_hold);
}
EisMessage::AmpPoint { index, point } => {
self.amp_points.push(point);
self.amp_total = index + 1;
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
self.status = format!("Amp: {} pts", self.amp_points.len());
}
EisMessage::AmpEnd => {
self.amp_running = false;
self.status = format!("Amp complete: {} points", self.amp_points.len());
}
EisMessage::ClStart { num_points } => {
self.cl_points.clear();
self.cl_result = None;
self.cl_total = num_points;
self.cl_data = text_editor::Content::with_text(&fmt_cl(&self.cl_points));
self.status = format!("Chlorine: {} pts", num_points);
}
EisMessage::ClPoint { point, .. } => {
self.cl_points.push(point);
self.cl_data = text_editor::Content::with_text(&fmt_cl(&self.cl_points));
self.status = format!("Chlorine: {}/{}", self.cl_points.len(), self.cl_total);
}
EisMessage::ClResult(r) => {
self.cl_result = Some(r);
self.status = format!("Chlorine: free={:.3} uA, total={:.3} uA",
self.cl_result.as_ref().unwrap().i_free_ua,
self.cl_result.as_ref().unwrap().i_total_ua);
}
EisMessage::ClEnd => {
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
}
EisMessage::PhResult(r) => {
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
r.ph, r.v_ocp_mv, r.temp_c);
self.ph_result = Some(r);
}
EisMessage::Temperature(t) => {
self.temp_c = t;
}
},
Message::TabSelected(t) => self.tab = t,
Message::PaneResized(event) => {
self.panes.resize(event.split, event.ratio);
}
Message::DataAction(action) => {
if !matches!(action, text_editor::Action::Edit(_)) {
match self.tab {
Tab::Eis => self.eis_data.perform(action),
Tab::Lsv => self.lsv_data.perform(action),
Tab::Amp => self.amp_data.perform(action),
Tab::Chlorine => self.cl_data.perform(action),
Tab::Ph => {}
}
}
}
/* EIS */
Message::FreqStartChanged(s) => self.freq_start = s,
Message::FreqStopChanged(s) => self.freq_stop = s,
Message::PpdChanged(s) => self.ppd = s,
Message::RtiaSelected(r) => self.rtia = r,
Message::RcalSelected(r) => self.rcal = r,
Message::ElectrodeSelected(e) => self.electrode = e,
Message::ApplySettings => {
let fs = self.freq_start.parse::<f32>().unwrap_or(1000.0);
let fe = self.freq_stop.parse::<f32>().unwrap_or(200000.0);
let ppd = self.ppd.parse::<u16>().unwrap_or(10);
self.send_cmd(&protocol::build_sysex_set_sweep(fs, fe, ppd));
self.send_cmd(&protocol::build_sysex_set_rtia(self.rtia));
self.send_cmd(&protocol::build_sysex_set_rcal(self.rcal));
self.send_cmd(&protocol::build_sysex_set_electrode(self.electrode));
self.send_cmd(&protocol::build_sysex_get_config());
}
Message::StartSweep => {
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_sweep());
}
/* LSV */
Message::LsvStartVChanged(s) => self.lsv_start_v = s,
Message::LsvStopVChanged(s) => self.lsv_stop_v = s,
Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s,
Message::LsvRtiaSelected(r) => self.lsv_rtia = r,
Message::StartLsv => {
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
let ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.0);
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia));
}
/* Amp */
Message::AmpVholdChanged(s) => self.amp_v_hold = s,
Message::AmpIntervalChanged(s) => self.amp_interval = s,
Message::AmpDurationChanged(s) => self.amp_duration = s,
Message::AmpRtiaSelected(r) => self.amp_rtia = r,
Message::StartAmp => {
let vh = self.amp_v_hold.parse::<f32>().unwrap_or(200.0);
let iv = self.amp_interval.parse::<f32>().unwrap_or(100.0);
let dur = self.amp_duration.parse::<f32>().unwrap_or(60.0);
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_amp(vh, iv, dur, self.amp_rtia));
}
Message::StopAmp => {
self.send_cmd(&protocol::build_sysex_stop_amp());
}
/* Chlorine */
Message::ClCondVChanged(s) => self.cl_cond_v = s,
Message::ClCondTChanged(s) => self.cl_cond_t = s,
Message::ClFreeVChanged(s) => self.cl_free_v = s,
Message::ClTotalVChanged(s) => self.cl_total_v = s,
Message::ClDepTChanged(s) => self.cl_dep_t = s,
Message::ClMeasTChanged(s) => self.cl_meas_t = s,
Message::ClRtiaSelected(r) => self.cl_rtia = r,
Message::StartCl => {
let v_cond = self.cl_cond_v.parse::<f32>().unwrap_or(800.0);
let t_cond = self.cl_cond_t.parse::<f32>().unwrap_or(2000.0);
let v_free = self.cl_free_v.parse::<f32>().unwrap_or(100.0);
let v_total = self.cl_total_v.parse::<f32>().unwrap_or(-200.0);
let t_dep = self.cl_dep_t.parse::<f32>().unwrap_or(5000.0);
let t_meas = self.cl_meas_t.parse::<f32>().unwrap_or(5000.0);
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_cl(
v_cond, t_cond, v_free, v_total, t_dep, t_meas, self.cl_rtia,
));
}
/* pH */
Message::PhStabilizeChanged(s) => self.ph_stabilize = s,
Message::StartPh => {
let stab = self.ph_stabilize.parse::<f32>().unwrap_or(30.0);
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_ph(stab));
}
/* Reference baseline */
Message::SetReference => {
match self.tab {
Tab::Eis if !self.eis_points.is_empty() => {
self.eis_ref = Some(self.eis_points.clone());
self.status = format!("EIS reference set ({} pts)", self.eis_points.len());
}
Tab::Lsv if !self.lsv_points.is_empty() => {
self.lsv_ref = Some(self.lsv_points.clone());
self.status = format!("LSV reference set ({} pts)", self.lsv_points.len());
}
Tab::Amp if !self.amp_points.is_empty() => {
self.amp_ref = Some(self.amp_points.clone());
self.status = format!("Amp reference set ({} pts)", self.amp_points.len());
}
Tab::Chlorine if !self.cl_points.is_empty() => {
if let Some(r) = &self.cl_result {
self.cl_ref = Some((self.cl_points.clone(), r.clone()));
self.status = "Chlorine reference set".into();
}
}
Tab::Ph => {
if let Some(r) = &self.ph_result {
self.ph_ref = Some(r.clone());
self.status = format!("pH reference set ({:.2})", r.ph);
}
}
_ => {}
}
}
Message::ClearReference => {
match self.tab {
Tab::Eis => { self.eis_ref = None; self.status = "EIS reference cleared".into(); }
Tab::Lsv => { self.lsv_ref = None; self.status = "LSV reference cleared".into(); }
Tab::Amp => { self.amp_ref = None; self.status = "Amp reference cleared".into(); }
Tab::Chlorine => { self.cl_ref = None; self.status = "Chlorine reference cleared".into(); }
Tab::Ph => { self.ph_ref = None; self.status = "pH reference cleared".into(); }
}
}
/* Global */
Message::PollTemp => {
self.send_cmd(&protocol::build_sysex_get_temp());
}
Message::NativeMenuTick => {
for action in self.native_menu.poll_events() {
match action {
MenuAction::SystemInfo => self.show_sysinfo = !self.show_sysinfo,
}
}
}
Message::CloseSysInfo => {
self.show_sysinfo = false;
}
Message::OpenMidiSetup => {
let _ = std::process::Command::new("open")
.arg("-a")
.arg("Audio MIDI Setup")
.spawn();
}
}
Task::none()
}
pub fn subscription(&self) -> Subscription<Message> {
let ble = Subscription::run_with_id(
"ble",
iced::stream::channel(100, |mut output| async move {
loop {
let (ble_tx, mut ble_rx) = mpsc::unbounded_channel::<BleEvent>();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let _ = output.send(Message::BleReady(cmd_tx)).await;
let tx = ble_tx.clone();
tokio::spawn(async move {
if let Err(e) = crate::ble::connect_and_run(tx, cmd_rx).await {
eprintln!("BLE: {e}");
}
});
while let Some(ev) = ble_rx.recv().await {
let msg = match ev {
BleEvent::Status(s) => Message::BleStatus(s),
BleEvent::Data(m) => Message::BleData(m),
};
let _ = output.send(msg).await;
}
let _ = output.send(Message::BleStatus("Reconnecting...".into())).await;
tokio::time::sleep(Duration::from_millis(500)).await;
}
}),
);
let temp_poll = iced::time::every(Duration::from_millis(500))
.map(|_| Message::PollTemp);
let menu_tick = iced::time::every(Duration::from_millis(50))
.map(|_| Message::NativeMenuTick);
Subscription::batch([ble, temp_poll, menu_tick])
}
pub fn view(&self) -> Element<'_, Message> {
let tab_btn = |label: &'static str, t: Tab, active: bool| -> Element<'_, Message> {
let b = button(text(label).size(13))
.style(style_tab(active))
.padding([6, 14]);
if active { b.into() } else { b.on_press(Message::TabSelected(t)).into() }
};
let tabs = row![
tab_btn("EIS", Tab::Eis, self.tab == Tab::Eis),
tab_btn("LSV", Tab::Lsv, self.tab == Tab::Lsv),
tab_btn("Amperometry", Tab::Amp, self.tab == Tab::Amp),
tab_btn("Chlorine", Tab::Chlorine, self.tab == Tab::Chlorine),
tab_btn("pH", Tab::Ph, self.tab == Tab::Ph),
button(text("MIDI Setup").size(13))
.style(style_neutral())
.padding([6, 14])
.on_press(Message::OpenMidiSetup),
]
.spacing(4);
let has_ref = match self.tab {
Tab::Eis => self.eis_ref.is_some(),
Tab::Lsv => self.lsv_ref.is_some(),
Tab::Amp => self.amp_ref.is_some(),
Tab::Chlorine => self.cl_ref.is_some(),
Tab::Ph => self.ph_ref.is_some(),
};
let has_data = match self.tab {
Tab::Eis => !self.eis_points.is_empty(),
Tab::Lsv => !self.lsv_points.is_empty(),
Tab::Amp => !self.amp_points.is_empty(),
Tab::Chlorine => self.cl_result.is_some(),
Tab::Ph => self.ph_result.is_some(),
};
let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center);
if has_data {
ref_row = ref_row.push(
button(text("Set Ref").size(11))
.style(style_neutral())
.padding([4, 10])
.on_press(Message::SetReference),
);
}
if has_ref {
ref_row = ref_row.push(
button(text("Clear Ref").size(11))
.style(style_danger())
.padding([4, 10])
.on_press(Message::ClearReference),
);
ref_row = ref_row.push(text("REF").size(11));
}
let status_row = row![
text(&self.status).size(16),
iced::widget::horizontal_space(),
ref_row,
text(format!("{:.1} C", self.temp_c)).size(14),
]
.spacing(6)
.align_y(iced::Alignment::Center);
let controls = self.view_controls();
let body: Element<'_, Message> = if self.show_sysinfo {
self.view_sysinfo()
} else if self.tab == Tab::Ph {
self.view_ph_body()
} else {
pane_grid::PaneGrid::new(&self.panes, |_pane, &pane_id, _maximized| {
let el = match pane_id {
PaneId::Plot => self.view_plot_pane(),
PaneId::Data => self.view_data_pane(),
};
pane_grid::Content::new(el)
})
.on_resize(6, Message::PaneResized)
.width(Length::Fill)
.height(Length::Fill)
.into()
};
container(
column![tabs, status_row, controls, body]
.spacing(10)
.padding(20)
.width(Length::Fill)
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn view_controls(&self) -> Element<'_, Message> {
match self.tab {
Tab::Eis => row![
column![
text("Start Hz").size(12),
text_input("1000", &self.freq_start).on_input(Message::FreqStartChanged).width(100),
].spacing(2),
column![
text("Stop Hz").size(12),
text_input("200000", &self.freq_stop).on_input(Message::FreqStopChanged).width(100),
].spacing(2),
column![
text("PPD").size(12),
text_input("10", &self.ppd).on_input(Message::PpdChanged).width(60),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(Rtia::ALL, Some(self.rtia), Message::RtiaSelected),
].spacing(2),
column![
text("RCAL").size(12),
pick_list(Rcal::ALL, Some(self.rcal), Message::RcalSelected),
].spacing(2),
column![
text("Electrodes").size(12),
pick_list(Electrode::ALL, Some(self.electrode), Message::ElectrodeSelected),
].spacing(2),
button(text("Apply").size(13))
.style(style_apply())
.padding([6, 16])
.on_press(Message::ApplySettings),
button(text("Sweep").size(13))
.style(style_action())
.padding([6, 20])
.on_press(Message::StartSweep),
]
.spacing(10)
.align_y(iced::Alignment::End)
.into(),
Tab::Lsv => row![
column![
text("Start mV").size(12),
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
].spacing(2),
column![
text("Stop mV").size(12),
text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80),
].spacing(2),
column![
text("Scan mV/s").size(12),
text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected),
].spacing(2),
button(text("Start LSV").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartLsv),
]
.spacing(10)
.align_y(iced::Alignment::End)
.into(),
Tab::Amp => row![
column![
text("V hold mV").size(12),
text_input("200", &self.amp_v_hold).on_input(Message::AmpVholdChanged).width(80),
].spacing(2),
column![
text("Interval ms").size(12),
text_input("100", &self.amp_interval).on_input(Message::AmpIntervalChanged).width(80),
].spacing(2),
column![
text("Duration s").size(12),
text_input("60", &self.amp_duration).on_input(Message::AmpDurationChanged).width(80),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.amp_rtia), Message::AmpRtiaSelected),
].spacing(2),
if self.amp_running {
button(text("Stop").size(13))
.style(style_danger())
.padding([6, 16])
.on_press(Message::StopAmp)
} else {
button(text("Start Amp").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartAmp)
},
]
.spacing(10)
.align_y(iced::Alignment::End)
.into(),
Tab::Chlorine => row![
column![
text("Cond mV").size(12),
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
].spacing(2),
column![
text("Cond ms").size(12),
text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70),
].spacing(2),
column![
text("Free mV").size(12),
text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70),
].spacing(2),
column![
text("Total mV").size(12),
text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70),
].spacing(2),
column![
text("Settle ms").size(12),
text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70),
].spacing(2),
column![
text("Meas ms").size(12),
text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected),
].spacing(2),
button(text("Measure").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartCl),
]
.spacing(8)
.align_y(iced::Alignment::End)
.into(),
Tab::Ph => row![
column![
text("Stabilize s").size(12),
text_input("30", &self.ph_stabilize).on_input(Message::PhStabilizeChanged).width(80),
].spacing(2),
button(text("Measure pH").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartPh),
]
.spacing(10)
.align_y(iced::Alignment::End)
.into(),
}
}
fn view_plot_pane(&self) -> Element<'_, Message> {
match self.tab {
Tab::Eis => {
let bode = canvas(crate::plot::BodePlot {
points: &self.eis_points,
reference: self.eis_ref.as_deref(),
})
.width(Length::FillPortion(3))
.height(Length::Fill);
let nyquist = canvas(crate::plot::NyquistPlot {
points: &self.eis_points,
reference: self.eis_ref.as_deref(),
})
.width(Length::FillPortion(2))
.height(Length::Fill);
row![bode, nyquist].spacing(10).height(Length::Fill).into()
}
Tab::Lsv => canvas(crate::plot::VoltammogramPlot {
points: &self.lsv_points,
reference: self.lsv_ref.as_deref(),
})
.width(Length::Fill).height(Length::Fill).into(),
Tab::Amp => canvas(crate::plot::AmperogramPlot {
points: &self.amp_points,
reference: self.amp_ref.as_deref(),
})
.width(Length::Fill).height(Length::Fill).into(),
Tab::Chlorine => {
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
let plot = canvas(crate::plot::ChlorinePlot {
points: &self.cl_points,
reference: ref_pts,
})
.width(Length::Fill).height(Length::Fill);
let mut result_parts: Vec<String> = Vec::new();
if let Some(r) = &self.cl_result {
result_parts.push(format!(
"Free: {:.3} uA | Total: {:.3} uA | Combined: {:.3} uA",
r.i_free_ua, r.i_total_ua, r.i_total_ua - r.i_free_ua
));
if let Some((_, ref_r)) = &self.cl_ref {
let df = r.i_free_ua - ref_r.i_free_ua;
let dt = r.i_total_ua - ref_r.i_total_ua;
result_parts.push(format!(
"vs Ref: dFree={:.3} uA, dTotal={:.3} uA",
df, dt
));
}
}
if result_parts.is_empty() {
plot.into()
} else {
let result_text = text(result_parts.join(" ")).size(14);
column![result_text, plot].spacing(4).height(Length::Fill).into()
}
}
Tab::Ph => text("").into(),
}
}
fn view_data_pane(&self) -> Element<'_, Message> {
let content = match self.tab {
Tab::Eis => &self.eis_data,
Tab::Lsv => &self.lsv_data,
Tab::Amp => &self.amp_data,
Tab::Chlorine => &self.cl_data,
Tab::Ph => return text("").into(),
};
text_editor(content)
.on_action(Message::DataAction)
.font(iced::Font::MONOSPACE)
.size(13)
.height(Length::Fill)
.padding(4)
.into()
}
fn view_ph_body(&self) -> Element<'_, Message> {
if let Some(r) = &self.ph_result {
let nernst_slope = 0.1984 * (r.temp_c as f64 + 273.15);
let mut col = column![
text(format!("pH: {:.2}", r.ph)).size(28),
text(format!("OCP: {:.1} mV | Nernst slope: {:.2} mV/pH | Temp: {:.1} C",
r.v_ocp_mv, nernst_slope, r.temp_c)).size(14),
].spacing(4);
if let Some(ref_r) = &self.ph_ref {
let d_ph = r.ph - ref_r.ph;
let d_v = r.v_ocp_mv - ref_r.v_ocp_mv;
col = col.push(text(format!(
"vs Ref: dpH={:+.3} dOCP={:+.1} mV (ref pH={:.2})",
d_ph, d_v, ref_r.ph
)).size(14));
}
col.into()
} else {
column![
text("No measurement yet").size(16),
text("OCP method: V(SE0) - V(RE0) with Nernst correction").size(12),
].spacing(4).into()
}
}
fn view_sysinfo(&self) -> Element<'_, Message> {
container(
column![
text("System Info").size(20),
iced::widget::horizontal_rule(1),
text(format!("Temperature: {:.1} C", self.temp_c)).size(14),
text("Syslog: not yet implemented").size(14),
text("0 / 4,194,304 bytes used").size(14),
iced::widget::vertical_space().height(20),
button(text("Close").size(14)).on_press(Message::CloseSysInfo),
]
.spacing(8)
.width(350)
.padding(20),
)
.center(Length::Fill)
.into()
}
}