diff --git a/cue/src/app.rs b/cue/src/app.rs index c4d19bd..424837d 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -1,6 +1,7 @@ use futures::SinkExt; use iced::widget::{ - button, canvas, column, container, pane_grid, pick_list, row, text, text_editor, text_input, + button, canvas, column, container, pane_grid, pick_list, row, scrollable, text, text_editor, + text_input, }; use iced::widget::button::Style as ButtonStyle; use iced::{Border, Color, Element, Length, Subscription, Task, Theme}; @@ -14,7 +15,7 @@ use crate::protocol::{ self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint, PhResult, Rcal, Rtia, }; -use crate::storage::{Session, Storage}; +use crate::storage::{self, Session, Storage}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -23,6 +24,7 @@ pub enum Tab { Amp, Chlorine, Ph, + Browse, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,6 +109,13 @@ pub enum Message { SelectSession(Option), DeleteSession, SessionNameInput(String), + /* Browse */ + BrowseSelectSession(i64), + BrowseSelectMeasurement(i64), + BrowseLoadAsActive(i64), + BrowseLoadAsReference(i64), + BrowseDeleteMeasurement(i64), + BrowseBack, /* Misc */ OpenMidiSetup, RefreshMidi, @@ -128,6 +137,13 @@ pub struct App { session_name_input: String, creating_session: bool, + /* Browse */ + browse_sessions: Vec<(Session, i64)>, + browse_measurements: Vec<(storage::Measurement, i64)>, + browse_selected_session: Option, + browse_selected_measurement: Option, + browse_preview: String, + /* EIS */ eis_points: Vec, sweep_total: u16, @@ -324,6 +340,12 @@ impl App { session_name_input: String::new(), creating_session: false, + browse_sessions: Vec::new(), + browse_measurements: Vec::new(), + browse_selected_session: None, + browse_selected_measurement: None, + browse_preview: String::new(), + eis_points: Vec::new(), sweep_total: 0, freq_start: "1000".into(), @@ -655,7 +677,23 @@ impl App { } } }, - Message::TabSelected(t) => self.tab = t, + Message::TabSelected(t) => { + if t == Tab::Browse { + self.browse_sessions = self.storage.list_sessions() + .unwrap_or_default() + .into_iter() + .map(|s| { + let cnt = self.storage.measurement_count(s.id).unwrap_or(0); + (s, cnt) + }) + .collect(); + self.browse_selected_session = None; + self.browse_selected_measurement = None; + self.browse_measurements.clear(); + self.browse_preview.clear(); + } + self.tab = t; + } Message::PaneResized(event) => { self.panes.resize(event.split, event.ratio); } @@ -666,7 +704,7 @@ impl App { Tab::Lsv => self.lsv_data.perform(action), Tab::Amp => self.amp_data.perform(action), Tab::Chlorine => self.cl_data.perform(action), - Tab::Ph => {} + Tab::Ph | Tab::Browse => {} } } } @@ -819,6 +857,7 @@ impl App { 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(); } + Tab::Browse => {} } } /* Global */ @@ -875,6 +914,83 @@ impl App { Message::SessionNameInput(s) => { self.session_name_input = s; } + /* Browse */ + Message::BrowseSelectSession(sid) => { + self.browse_selected_session = Some(sid); + self.browse_selected_measurement = None; + self.browse_preview.clear(); + self.browse_measurements = self.storage.get_measurements(sid) + .unwrap_or_default() + .into_iter() + .map(|m| { + let cnt = self.storage.data_point_count(m.id).unwrap_or(0); + (m, cnt) + }) + .collect(); + } + Message::BrowseSelectMeasurement(mid) => { + self.browse_selected_measurement = Some(mid); + let mtype = self.browse_measurements.iter() + .find(|(m, _)| m.id == mid) + .map(|(m, _)| m.mtype.clone()); + if let Some(mt) = mtype { + let pts = self.storage.get_data_points(mid).unwrap_or_default(); + self.browse_preview = Self::format_preview(&mt, &pts); + } + } + Message::BrowseLoadAsActive(mid) => { + let mtype = self.browse_measurements.iter() + .find(|(m, _)| m.id == mid) + .map(|(m, _)| m.mtype.clone()); + if let Some(mt) = mtype { + let pts = self.storage.get_data_points(mid).unwrap_or_default(); + self.load_measurement_active(&mt, &pts); + } + } + Message::BrowseLoadAsReference(mid) => { + let mtype = self.browse_measurements.iter() + .find(|(m, _)| m.id == mid) + .map(|(m, _)| m.mtype.clone()); + if let Some(mt) = mtype { + let pts = self.storage.get_data_points(mid).unwrap_or_default(); + self.load_measurement_reference(&mt, &pts); + } + } + Message::BrowseDeleteMeasurement(mid) => { + let _ = self.storage.delete_measurement(mid); + if self.browse_selected_measurement == Some(mid) { + self.browse_selected_measurement = None; + self.browse_preview.clear(); + } + if let Some(sid) = self.browse_selected_session { + self.browse_measurements = self.storage.get_measurements(sid) + .unwrap_or_default() + .into_iter() + .map(|m| { + let cnt = self.storage.data_point_count(m.id).unwrap_or(0); + (m, cnt) + }) + .collect(); + } + self.browse_sessions = self.storage.list_sessions() + .unwrap_or_default() + .into_iter() + .map(|s| { + let cnt = self.storage.measurement_count(s.id).unwrap_or(0); + (s, cnt) + }) + .collect(); + self.status = "Measurement deleted".into(); + } + Message::BrowseBack => { + if self.browse_selected_measurement.is_some() { + self.browse_selected_measurement = None; + self.browse_preview.clear(); + } else { + self.browse_selected_session = None; + self.browse_measurements.clear(); + } + } Message::OpenMidiSetup => { let _ = std::process::Command::new("open") .arg("-a") @@ -948,6 +1064,7 @@ impl App { 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), + tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse), button(text("MIDI Setup").size(13)) .style(style_neutral()) .padding([6, 14]) @@ -970,6 +1087,7 @@ impl App { Tab::Amp => self.amp_ref.is_some(), Tab::Chlorine => self.cl_ref.is_some(), Tab::Ph => self.ph_ref.is_some(), + Tab::Browse => false, }; let has_data = match self.tab { Tab::Eis => !self.eis_points.is_empty(), @@ -977,6 +1095,7 @@ impl App { Tab::Amp => !self.amp_points.is_empty(), Tab::Chlorine => self.cl_result.is_some(), Tab::Ph => self.ph_result.is_some(), + Tab::Browse => false, }; let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center); @@ -1051,6 +1170,8 @@ impl App { let body: Element<'_, Message> = if self.show_sysinfo { self.view_sysinfo() + } else if self.tab == Tab::Browse { + self.view_browse_body() } else if self.tab == Tab::Ph { self.view_ph_body() } else { @@ -1289,6 +1410,8 @@ impl App { .spacing(10) .align_y(iced::Alignment::End) .into(), + + Tab::Browse => row![].into(), } } @@ -1348,7 +1471,7 @@ impl App { column![result_text, plot].spacing(4).height(Length::Fill).into() } } - Tab::Ph => text("").into(), + Tab::Ph | Tab::Browse => text("").into(), } } @@ -1358,7 +1481,7 @@ impl App { Tab::Lsv => &self.lsv_data, Tab::Amp => &self.amp_data, Tab::Chlorine => &self.cl_data, - Tab::Ph => return text("").into(), + Tab::Ph | Tab::Browse => return text("").into(), }; text_editor(content) .on_action(Message::DataAction) @@ -1412,4 +1535,319 @@ impl App { .center(Length::Fill) .into() } + + /* ---- Browse tab ---- */ + + fn view_browse_body(&self) -> Element<'_, Message> { + let left = self.view_browse_sessions(); + let right = self.view_browse_detail(); + row![ + container(left).width(Length::FillPortion(2)).height(Length::Fill), + iced::widget::vertical_rule(1), + container(right).width(Length::FillPortion(3)).height(Length::Fill), + ] + .spacing(8) + .height(Length::Fill) + .into() + } + + fn view_browse_sessions(&self) -> Element<'_, Message> { + let mut items = column![ + text("Sessions").size(16), + iced::widget::horizontal_rule(1), + ].spacing(4); + + if self.browse_sessions.is_empty() { + items = items.push(text("No saved sessions").size(13)); + } + + for (session, mcount) in &self.browse_sessions { + let selected = self.browse_selected_session == Some(session.id); + let bg = if selected { + Color::from_rgb(0.30, 0.40, 0.55) + } else { + Color::from_rgb(0.18, 0.18, 0.20) + }; + let label = format!( + "{} ({} meas) {}", + session.name, mcount, &session.created_at[..10.min(session.created_at.len())] + ); + let sid = session.id; + items = items.push( + button(text(label).size(12)) + .style(btn_style(bg, Color::WHITE)) + .padding([6, 10]) + .width(Length::Fill) + .on_press(Message::BrowseSelectSession(sid)), + ); + } + + scrollable(items.width(Length::Fill)) + .height(Length::Fill) + .into() + } + + fn view_browse_detail(&self) -> Element<'_, Message> { + let Some(sid) = self.browse_selected_session else { + return column![ + text("Select a session").size(14), + ].spacing(8).into(); + }; + + let session_name = self.browse_sessions.iter() + .find(|(s, _)| s.id == sid) + .map(|(s, _)| s.name.as_str()) + .unwrap_or("?"); + + let mut header = row![ + button(text("Back").size(11)) + .style(style_neutral()) + .padding([4, 10]) + .on_press(Message::BrowseBack), + text(format!("Measurements in: {}", session_name)).size(14), + ].spacing(8).align_y(iced::Alignment::Center); + + if let Some(mid) = self.browse_selected_measurement { + header = header.push(iced::widget::horizontal_space()); + header = header.push( + button(text("Load").size(11)) + .style(style_action()) + .padding([4, 12]) + .on_press(Message::BrowseLoadAsActive(mid)), + ); + header = header.push( + button(text("Load as Ref").size(11)) + .style(style_apply()) + .padding([4, 12]) + .on_press(Message::BrowseLoadAsReference(mid)), + ); + header = header.push( + button(text("Delete").size(11)) + .style(style_danger()) + .padding([4, 10]) + .on_press(Message::BrowseDeleteMeasurement(mid)), + ); + } + + let mut mlist = column![].spacing(2); + + if self.browse_measurements.is_empty() { + mlist = mlist.push(text("No measurements").size(13)); + } + + for (m, pt_count) in &self.browse_measurements { + let selected = self.browse_selected_measurement == Some(m.id); + let bg = if selected { + Color::from_rgb(0.30, 0.40, 0.55) + } else { + Color::from_rgb(0.18, 0.18, 0.20) + }; + let type_label = match m.mtype.as_str() { + "eis" => "EIS", + "lsv" => "LSV", + "amp" => "Amp", + "chlorine" => "Cl", + "ph" => "pH", + other => other, + }; + let ts = if m.created_at.len() > 10 { &m.created_at[11..] } else { &m.created_at }; + let label = format!("[{}] {} {} pts", type_label, ts, pt_count); + let mid = m.id; + mlist = mlist.push( + button(text(label).size(12)) + .style(btn_style(bg, Color::WHITE)) + .padding([5, 10]) + .width(Length::Fill) + .on_press(Message::BrowseSelectMeasurement(mid)), + ); + } + + let mut body = column![ + header, + iced::widget::horizontal_rule(1), + ].spacing(6); + + body = body.push( + scrollable(mlist.width(Length::Fill)) + .height(if self.browse_preview.is_empty() { Length::Fill } else { Length::FillPortion(2) }), + ); + + if !self.browse_preview.is_empty() { + body = body.push(iced::widget::horizontal_rule(1)); + body = body.push( + scrollable( + text(&self.browse_preview) + .size(12) + .font(iced::Font::MONOSPACE) + ) + .height(Length::FillPortion(3)), + ); + } + + body.height(Length::Fill).into() + } + + fn format_preview(mtype: &str, pts: &[storage::DataPoint]) -> String { + match mtype { + "eis" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + fmt_eis(&decoded) + } + "lsv" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + fmt_lsv(&decoded) + } + "amp" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + fmt_amp(&decoded) + } + "chlorine" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + let mut s = fmt_cl(&decoded); + if let Some(last) = pts.last() + && let Ok(wrap) = serde_json::from_str::(&last.data_json) + && let Some(r) = wrap.get("result") + && let Ok(cr) = serde_json::from_value::(r.clone()) + { + let _ = writeln!(s, "\nFree: {:.3} uA Total: {:.3} uA", + cr.i_free_ua, cr.i_total_ua); + } + s + } + "ph" => { + if let Some(dp) = pts.first() { + if let Ok(r) = serde_json::from_str::(&dp.data_json) { + format!("pH: {:.2} OCP: {:.1} mV Temp: {:.1} C", r.ph, r.v_ocp_mv, r.temp_c) + } else { + dp.data_json.clone() + } + } else { + String::new() + } + } + _ => format!("{} data points", pts.len()), + } + } + + fn load_measurement_active(&mut self, mtype: &str, pts: &[storage::DataPoint]) { + match mtype { + "eis" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + self.eis_data = text_editor::Content::with_text(&fmt_eis(&decoded)); + self.eis_points = decoded; + self.tab = Tab::Eis; + self.status = format!("Loaded EIS: {} pts", self.eis_points.len()); + } + "lsv" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&decoded)); + self.lsv_points = decoded; + self.tab = Tab::Lsv; + self.status = format!("Loaded LSV: {} pts", self.lsv_points.len()); + } + "amp" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + self.amp_data = text_editor::Content::with_text(&fmt_amp(&decoded)); + self.amp_points = decoded; + self.tab = Tab::Amp; + self.status = format!("Loaded Amp: {} pts", self.amp_points.len()); + } + "chlorine" => { + let cl_pts: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + let mut result: Option = None; + if let Some(last) = pts.last() + && let Ok(wrap) = serde_json::from_str::(&last.data_json) + && let Some(r) = wrap.get("result") + { + result = serde_json::from_value(r.clone()).ok(); + } + self.cl_data = text_editor::Content::with_text(&fmt_cl(&cl_pts)); + self.cl_points = cl_pts; + self.cl_result = result; + self.tab = Tab::Chlorine; + self.status = format!("Loaded Chlorine: {} pts", self.cl_points.len()); + } + "ph" => { + if let Some(dp) = pts.first() + && let Ok(r) = serde_json::from_str::(&dp.data_json) + { + self.status = format!("Loaded pH: {:.2}", r.ph); + self.ph_result = Some(r); + self.tab = Tab::Ph; + } + } + _ => {} + } + } + + fn load_measurement_reference(&mut self, mtype: &str, pts: &[storage::DataPoint]) { + match mtype { + "eis" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + self.status = format!("EIS ref loaded: {} pts", decoded.len()); + self.eis_ref = Some(decoded); + self.tab = Tab::Eis; + } + "lsv" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + self.status = format!("LSV ref loaded: {} pts", decoded.len()); + self.lsv_ref = Some(decoded); + self.tab = Tab::Lsv; + } + "amp" => { + let decoded: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + self.status = format!("Amp ref loaded: {} pts", decoded.len()); + self.amp_ref = Some(decoded); + self.tab = Tab::Amp; + } + "chlorine" => { + let cl_pts: Vec = pts.iter() + .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) + .collect(); + let mut result = ClResult { i_free_ua: 0.0, i_total_ua: 0.0 }; + if let Some(last) = pts.last() + && let Ok(wrap) = serde_json::from_str::(&last.data_json) + && let Some(r) = wrap.get("result") + && let Ok(cr) = serde_json::from_value::(r.clone()) + { + result = cr; + } + self.status = format!("Cl ref loaded: {} pts", cl_pts.len()); + self.cl_ref = Some((cl_pts, result)); + self.tab = Tab::Chlorine; + } + "ph" => { + if let Some(dp) = pts.first() + && let Ok(r) = serde_json::from_str::(&dp.data_json) + { + self.status = format!("pH ref loaded: {:.2}", r.ph); + self.ph_ref = Some(r); + self.tab = Tab::Ph; + } + } + _ => {} + } + } } diff --git a/cue/src/storage.rs b/cue/src/storage.rs index f203ea1..852037d 100644 --- a/cue/src/storage.rs +++ b/cue/src/storage.rs @@ -1,6 +1,7 @@ use rusqlite::{Connection, params}; #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct Session { pub id: i64, pub name: String, @@ -9,6 +10,7 @@ pub struct Session { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct Measurement { pub id: i64, pub session_id: i64, @@ -18,6 +20,7 @@ pub struct Measurement { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct DataPoint { pub id: i64, pub measurement_id: i64, @@ -120,6 +123,27 @@ impl Storage { rows.collect() } + pub fn measurement_count(&self, session_id: i64) -> Result { + self.conn.query_row( + "SELECT COUNT(*) FROM measurements WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) + } + + pub fn data_point_count(&self, measurement_id: i64) -> Result { + self.conn.query_row( + "SELECT COUNT(*) FROM data_points WHERE measurement_id = ?1", + params![measurement_id], + |row| row.get(0), + ) + } + + pub fn delete_measurement(&self, id: i64) -> Result<(), rusqlite::Error> { + self.conn.execute("DELETE FROM measurements WHERE id = ?1", params![id])?; + Ok(()) + } + pub fn get_data_points(&self, measurement_id: i64) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( "SELECT id, measurement_id, idx, data_json \