From 87f7665582ff0c199cdf72962d4e434a7a4081b0 Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Tue, 3 Feb 2026 15:24:02 +0100 Subject: [PATCH 1/7] Fix naming inconsistencies --- raumklang-gui/src/data/spectrogram.rs | 6 +++--- raumklang-gui/src/screen/main.rs | 11 ++++++----- .../src/screen/main/modal/spectrogram_config.rs | 14 +++++++------- raumklang-gui/src/ui/spectrogram.rs | 2 +- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/raumklang-gui/src/data/spectrogram.rs b/raumklang-gui/src/data/spectrogram.rs index 1737689..2860953 100644 --- a/raumklang-gui/src/data/spectrogram.rs +++ b/raumklang-gui/src/data/spectrogram.rs @@ -9,7 +9,7 @@ use rustfft::{ use crate::data::{SampleRate, Samples}; #[derive(Debug, Clone, PartialEq, PartialOrd)] -pub struct Preferences { +pub struct Config { pub span_before_peak: Duration, pub span_after_peak: Duration, pub window_width: Duration, @@ -34,7 +34,7 @@ impl Spectrogram { pub(crate) async fn compute( ir: raumklang_core::ImpulseResponse, - preferences: Preferences, + preferences: Config, ) -> Spectrogram { let sample_rate = SampleRate::from(ir.sample_rate); @@ -114,7 +114,7 @@ impl fmt::Debug for Spectrogram { } } -impl Default for Preferences { +impl Default for Config { fn default() -> Self { Self { span_before_peak: Duration::from_millis(200), diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index 627ade9..418660b 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -11,7 +11,8 @@ use tab::Tab; use crate::{ PickAndLoadError, data::{ - self, Project, RecentProjects, SampleRate, Samples, Window, project, spectrogram, window, + self, Project, RecentProjects, SampleRate, Samples, Window, project, spectral_decay, + spectrogram, window, }, icon, load_project, log, screen::main::{ @@ -70,8 +71,8 @@ pub struct Main { ir_chart: impulse_response::Chart, spectrogram: Spectrogram, - spectral_decay_config: data::spectral_decay::Config, - spectrogram_config: spectrogram::Preferences, + spectral_decay_config: spectral_decay::Config, + spectrogram_config: spectrogram::Config, } #[allow(clippy::large_enum_variant)] @@ -1667,7 +1668,7 @@ fn compute_spectral_decay( fn compute_spectrogram( id: measurement::Id, analyses: &mut BTreeMap, - config: &spectrogram::Preferences, + config: &spectrogram::Config, loopback: Option<&ui::Loopback>, measurements: &measurement::List, ) -> Task { @@ -1707,7 +1708,7 @@ impl Default for Main { ir_chart: impulse_response::Chart::default(), spectrogram: Spectrogram::default(), - spectrogram_config: spectrogram::Preferences::default(), + spectrogram_config: spectrogram::Config::default(), } } } diff --git a/raumklang-gui/src/screen/main/modal/spectrogram_config.rs b/raumklang-gui/src/screen/main/modal/spectrogram_config.rs index f580d5e..8add03a 100644 --- a/raumklang-gui/src/screen/main/modal/spectrogram_config.rs +++ b/raumklang-gui/src/screen/main/modal/spectrogram_config.rs @@ -16,13 +16,13 @@ pub enum Message { WindowWidthChanged(String), SpanBeforePeakChanged(String), SpanAfterPeakChanged(String), - Apply(spectrogram::Preferences), + Apply(spectrogram::Config), } pub enum Action { None, Close, - ConfigChanged(spectrogram::Preferences), + ConfigChanged(spectrogram::Config), } #[derive(Debug, Clone)] @@ -30,11 +30,11 @@ pub struct SpectrogramConfig { window_width: String, span_before_peak: String, span_after_peak: String, - prev_config: spectrogram::Preferences, + prev_config: spectrogram::Config, } impl SpectrogramConfig { - pub fn new(config: spectrogram::Preferences) -> Self { + pub fn new(config: spectrogram::Config) -> Self { Self { window_width: config.window_width.as_millis().to_string(), span_before_peak: config.span_before_peak.as_millis().to_string(), @@ -81,7 +81,7 @@ impl SpectrogramConfig { span_before_peak.as_ref(), span_after_peak.as_ref(), ) { - let new_config = spectrogram::Preferences { + let new_config = spectrogram::Config { window_width: *window_width, span_before_peak: *span_before_peak, span_after_peak: *span_after_peak, @@ -174,10 +174,10 @@ impl SpectrogramConfig { } fn reset_to_default(&mut self) { - self.reset_to_config(spectrogram::Preferences::default()); + self.reset_to_config(spectrogram::Config::default()); } - fn reset_to_config(&mut self, config: spectrogram::Preferences) { + fn reset_to_config(&mut self, config: spectrogram::Config) { self.window_width = config.window_width.as_millis().to_string(); self.span_before_peak = config.span_before_peak.as_millis().to_string(); self.span_after_peak = config.span_after_peak.as_millis().to_string(); diff --git a/raumklang-gui/src/ui/spectrogram.rs b/raumklang-gui/src/ui/spectrogram.rs index 064b69d..055c1f1 100644 --- a/raumklang-gui/src/ui/spectrogram.rs +++ b/raumklang-gui/src/ui/spectrogram.rs @@ -35,7 +35,7 @@ impl Spectrogram { pub fn compute( &mut self, impulse_response: &super::impulse_response::State, - config: &spectrogram::Preferences, + config: &spectrogram::Config, ) -> Option + use<>> { if self.result().is_some() { return None; From 89b8214bcb9d2cd419d390ac9ba21937c17c85c3 Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Thu, 5 Feb 2026 10:41:45 +0100 Subject: [PATCH 2/7] Implement prober project saving This commit also introduces the `Save project as` feature. --- raumklang-gui/src/data.rs | 1 + raumklang-gui/src/data/directory.rs | 21 ++ raumklang-gui/src/data/project.rs | 120 ++++++++++- raumklang-gui/src/data/project/settings.rs | 11 + raumklang-gui/src/data/recent_projects.rs | 9 +- raumklang-gui/src/screen/main.rs | 162 ++++++++++----- raumklang-gui/src/screen/main/modal.rs | 2 + .../src/screen/main/modal/save_project.rs | 189 ++++++++++++++++++ 8 files changed, 457 insertions(+), 58 deletions(-) create mode 100644 raumklang-gui/src/data/directory.rs create mode 100644 raumklang-gui/src/data/project/settings.rs create mode 100644 raumklang-gui/src/screen/main/modal/save_project.rs diff --git a/raumklang-gui/src/data.rs b/raumklang-gui/src/data.rs index 1759bfe..ca7cab5 100644 --- a/raumklang-gui/src/data.rs +++ b/raumklang-gui/src/data.rs @@ -1,4 +1,5 @@ pub mod chart; +pub mod directory; pub mod frequency_response; pub mod impulse_response; pub mod measurement; diff --git a/raumklang-gui/src/data/directory.rs b/raumklang-gui/src/data/directory.rs new file mode 100644 index 0000000..f7a4f0d --- /dev/null +++ b/raumklang-gui/src/data/directory.rs @@ -0,0 +1,21 @@ +use std::path::Path; +use std::sync::LazyLock; + +pub fn data() -> &'static Path { + PROJECT + .as_ref() + .map(directories::ProjectDirs::data_dir) + .unwrap_or(Path::new("./data")) +} + +pub fn documents() -> &'static Path { + USER.as_ref() + .and_then(directories::UserDirs::document_dir) + .unwrap_or(Path::new("./documents")) +} + +static PROJECT: LazyLock> = + LazyLock::new(|| directories::ProjectDirs::from("de", "henku", "raumklang")); + +static USER: LazyLock> = + LazyLock::new(|| directories::UserDirs::new()); diff --git a/raumklang-gui/src/data/project.rs b/raumklang-gui/src/data/project.rs index 4642b53..9b33655 100644 --- a/raumklang-gui/src/data/project.rs +++ b/raumklang-gui/src/data/project.rs @@ -1,22 +1,72 @@ +pub mod settings; + +use serde::{Deserialize, Serialize}; +pub use settings::Settings; +use tokio::fs; + use std::{ - io, + fmt, io, path::{Path, PathBuf}, }; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub loopback: Option, pub measurements: Vec, + #[serde(default)] + pub measurement_operation: Operation, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Loopback(pub Measurement); -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +impl Loopback { + pub async fn copy(&mut self, dest: impl AsRef) { + self.0.copy(dest).await + } + + pub async fn rename(&mut self, dest: impl AsRef) { + self.0.rename(dest).await + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Measurement { pub path: PathBuf, } +impl Measurement { + pub async fn copy(&mut self, dest: impl AsRef) { + let dest = dest + .as_ref() + .with_file_name(&self.path.file_name().unwrap()); + + if self.path == dest { + return; + } + + dbg!(&self.path, &dest); + fs::copy(&self.path, &dest).await.unwrap(); + + self.path = dest; + } + + // WARNING: will only work when both files are on the same mount point + pub async fn rename(&mut self, dest: impl AsRef) { + let dest = dest + .as_ref() + .with_file_name(&self.path.file_name().unwrap()); + + if self.path == dest { + return; + } + + fs::rename(&self.path, &dest).await.unwrap(); + + self.path = dest; + } +} + #[derive(thiserror::Error, Debug, Clone)] pub enum Error { #[error("could not load file: {0}")] @@ -38,7 +88,39 @@ impl Project { Ok(project) } - pub async fn save(self, path: impl AsRef) -> Result<(), Error> { + pub async fn save( + mut self, + path: impl AsRef, + settings: &SaveSettings, + ) -> Result<(), Error> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(&parent).await.unwrap(); + } + + match settings.measurement_operation { + Operation::None => {} + Operation::Copy => { + if let Some(loopback) = self.loopback.as_mut() { + loopback.copy(&path).await; + } + + for m in self.measurements.iter_mut() { + m.copy(&path).await; + } + } + Operation::Move => { + if let Some(loopback) = self.loopback.as_mut() { + loopback.rename(&path).await; + } + + for m in self.measurements.iter_mut() { + m.rename(&path).await; + } + } + } + let json = serde_json::to_string_pretty(&self).map_err(|err| Error::Json(err.to_string()))?; @@ -49,3 +131,33 @@ impl Project { Ok(()) } } + +#[derive(Debug, Clone)] +pub struct SaveSettings { + pub create_subdir: bool, + pub measurement_operation: Operation, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Operation { + #[default] + None, + Copy, + Move, +} + +impl Operation { + pub const ALL: &[Operation] = &[Operation::None, Operation::Copy, Operation::Move]; +} + +impl fmt::Display for Operation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Operation::None => "do nothing", + Operation::Copy => "copy to project directory", + Operation::Move => "move to project directory", + }; + + write!(f, "{}", s) + } +} diff --git a/raumklang-gui/src/data/project/settings.rs b/raumklang-gui/src/data/project/settings.rs new file mode 100644 index 0000000..f11d7de --- /dev/null +++ b/raumklang-gui/src/data/project/settings.rs @@ -0,0 +1,11 @@ +#[derive(Debug, Clone, Default)] +pub struct Settings { + pub record_to: Target, +} + +#[derive(Debug, Clone, Default)] +pub enum Target { + #[default] + Memory, + File, +} diff --git a/raumklang-gui/src/data/recent_projects.rs b/raumklang-gui/src/data/recent_projects.rs index 8719bb8..a80843a 100644 --- a/raumklang-gui/src/data/recent_projects.rs +++ b/raumklang-gui/src/data/recent_projects.rs @@ -1,3 +1,5 @@ +use crate::data::directory; + use super::Error; use std::{ @@ -97,12 +99,11 @@ impl<'a> IntoIterator for &'a RecentProjects { } async fn data_dir() -> Result { - let app_dir = directories::ProjectDirs::from("de", "HenKu", "raumklang").unwrap(); - let data_dir = app_dir.data_local_dir().to_path_buf(); + let path = directory::data(); - tokio::fs::create_dir_all(&data_dir).await?; + tokio::fs::create_dir_all(&path).await?; - Ok(data_dir) + Ok(path.to_path_buf()) } #[cfg(test)] diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index 418660b..288b17b 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -11,13 +11,17 @@ use tab::Tab; use crate::{ PickAndLoadError, data::{ - self, Project, RecentProjects, SampleRate, Samples, Window, project, spectral_decay, - spectrogram, window, + self, Project, RecentProjects, SampleRate, Samples, Window, + project::{self, SaveSettings}, + spectral_decay, spectrogram, window, }, icon, load_project, log, screen::main::{ chart::waveform, - modal::{SpectralDecayConfig, pending_window, spectral_decay_config, spectrogram_config}, + modal::{ + SpectralDecayConfig, pending_window, save_project, spectral_decay_config, + spectrogram_config, + }, }, ui::{self, Analysis, Loopback, Measurement, measurement}, widget::{processing_overlay, sidebar}, @@ -54,16 +58,18 @@ pub struct Main { state: State, modal: Modal, recording: Option, - selected: Option, + selected: Option, loopback: Option, measurements: measurement::List, + settings: project::Settings, project_path: Option, + measurement_operation: project::Operation, - signal_cache: canvas::Cache, zoom: chart::Zoom, offset: chart::Offset, + signal_cache: canvas::Cache, smoothing: frequency_response::Smoothing, window: Option>, @@ -116,8 +122,9 @@ pub enum Message { NewProject, LoadProject, ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), - SaveProject, - ProjectSaved(Result), + SaveProject(PathBuf), + OpenSaveProjectDialog, + ProjectSaved(Result<(PathBuf, SaveSettings), project::Error>), LoadRecentProject(usize), LoadLoopback, @@ -157,6 +164,7 @@ pub enum Message { Spectrogram(chart::spectrogram::Interaction), PendingWindow(pending_window::Message), + ProjectSaveDialog(save_project::Message), } impl Main { @@ -181,6 +189,7 @@ impl Main { ( Self { project_path: Some(path), + measurement_operation: project.measurement_operation, ..Default::default() }, Task::batch([load_loopback, Task::batch(load_measurements)]), @@ -212,42 +221,52 @@ impl Main { *self = view; tasks } - Message::SaveProject => { - let loopback = self - .loopback + Message::OpenSaveProjectDialog => { + let file_path_str = self + .project_path .as_ref() - .cloned() - .and_then(|l| l.path) - .map(|path| project::Loopback(project::Measurement { path })); + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + self.modal = Modal::SaveProjectDialog(save_project::View { + base_path: PathBuf::from(file_path_str.clone()), + file_path_str, + create_subdir: self.project_path.is_none(), + measurment_operation: self.measurement_operation, + }); - let measurements = self - .measurements - .loaded() - .flat_map(|m| m.path.as_ref()) - .cloned() - .map(|path| project::Measurement { path }) - .collect(); - - let project = Project { - loopback, - measurements, + Task::none() + } + Message::ProjectSaveDialog(msg) => { + let Modal::SaveProjectDialog(dialog) = &mut self.modal else { + return Task::none(); }; - if let Some(path) = self.project_path.as_ref() { - let path = path.clone(); - Task::perform(project.save(path.clone()), |res| { - Message::ProjectSaved(res.map(|_| path)) - }) - } else { - Task::future(pick_project_file_to_save()).and_then(move |path| { - Task::perform(project.clone().save(path.clone()), |res| { - Message::ProjectSaved(res.map(|_| path)) - }) - }) + match dialog.update(msg) { + save_project::Action::None => Task::none(), + save_project::Action::Cancel => { + self.modal = Modal::None; + Task::none() + } + save_project::Action::Task(task) => task.map(Message::ProjectSaveDialog), + save_project::Action::Save(path_buf, save_settings) => { + self.save_project(path_buf, save_settings) + } } } - Message::ProjectSaved(Ok(path)) => { + Message::SaveProject(path) => { + let settings = SaveSettings { + create_subdir: false, + measurement_operation: self.measurement_operation, + }; + + self.save_project(path, settings) + } + Message::ProjectSaved(Ok((path, settings))) => { + self.modal = Modal::None; self.project_path = Some(path.clone()); + self.measurement_operation = settings.measurement_operation; + recent_projects.insert(path); Task::future(recent_projects.clone().save()).discard() @@ -919,7 +938,14 @@ impl Main { self.ir_chart.shift_key_released(); Task::none() } - _ => Task::none(), + Message::ProjectLoaded(Err(err)) => { + log::error!("{err}"); + Task::none() + } + Message::ProjectSaved(Err(err)) => { + log::error!("Could not save project to {:?} - {err}", self.project_path); + Task::none() + } } } @@ -947,7 +973,15 @@ impl Main { .style(button::subtle) .width(Length::Fill), button("Save") - .on_press(Message::SaveProject) + .on_press(if let Some(path) = self.project_path.as_ref() { + Message::SaveProject(path.clone()) + } else { + Message::OpenSaveProjectDialog + }) + .style(button::subtle) + .width(Length::Fill), + button("Save as ..") + .on_press(Message::OpenSaveProjectDialog) .style(button::subtle) .width(Length::Fill), button("Open ...") @@ -1057,17 +1091,20 @@ impl Main { container(column![header, container(content).padding(10)]) }; - match self.modal { + match &self.modal { Modal::None => content.into(), Modal::PendingWindow { .. } => { modal(content, modal::pending_window().map(Message::PendingWindow)) } - Modal::SpectralDecayConfig(ref config) => { + Modal::SpectralDecayConfig(config) => { modal(content, config.view().map(Message::SpectralDecayConfig)) } - Modal::SpectrogramConfig(ref config) => { + Modal::SpectrogramConfig(config) => { modal(content, config.view().map(Message::SpectrogramConfig)) } + Modal::SaveProjectDialog(dialog) => { + modal(content, dialog.view().map(Message::ProjectSaveDialog)) + } } } @@ -1587,6 +1624,38 @@ impl Main { Subscription::batch([hotkeys, recording.map(Message::Recording)]) } + + fn save_project(&self, path: PathBuf, settings: project::SaveSettings) -> Task { + let loopback = self + .loopback + .as_ref() + .cloned() + .and_then(|l| l.path) + .map(|path| project::Loopback(project::Measurement { path })); + + let measurements = self + .measurements + .loaded() + .flat_map(|m| m.path.as_ref()) + .cloned() + .map(|path| project::Measurement { path }) + .collect(); + + let project = Project { + loopback, + measurements, + measurement_operation: settings.measurement_operation, + }; + + Task::perform( + async move { + project.save(&path, &settings).await?; + + Ok((path, settings)) + }, + Message::ProjectSaved, + ) + } } fn compute_impulse_response( @@ -1695,7 +1764,9 @@ impl Default for Main { loopback: None, measurements: measurement::List::default(), + settings: project::Settings::default(), project_path: None, + measurement_operation: project::Operation::Copy, spectral_decay_config: data::spectral_decay::Config::default(), @@ -1858,12 +1929,3 @@ async fn pick_project_file_to_load() -> Option { Some(handle.path().to_path_buf()) } - -async fn pick_project_file_to_save() -> Option { - let handle = rfd::AsyncFileDialog::new() - .set_title("Save project file ...") - .save_file() - .await?; - - Some(handle.path().to_path_buf()) -} diff --git a/raumklang-gui/src/screen/main/modal.rs b/raumklang-gui/src/screen/main/modal.rs index 040ef12..2a27044 100644 --- a/raumklang-gui/src/screen/main/modal.rs +++ b/raumklang-gui/src/screen/main/modal.rs @@ -1,4 +1,5 @@ pub mod pending_window; +pub mod save_project; pub mod spectral_decay_config; pub mod spectrogram_config; @@ -17,4 +18,5 @@ pub enum Modal { }, SpectralDecayConfig(SpectralDecayConfig), SpectrogramConfig(SpectrogramConfig), + SaveProjectDialog(save_project::View), } diff --git a/raumklang-gui/src/screen/main/modal/save_project.rs b/raumklang-gui/src/screen/main/modal/save_project.rs new file mode 100644 index 0000000..c03b9d0 --- /dev/null +++ b/raumklang-gui/src/screen/main/modal/save_project.rs @@ -0,0 +1,189 @@ +use iced::{ + Alignment::Center, + Element, Font, + Length::{Fill, Shrink}, + Task, + advanced::graphics::core::font, + alignment::Vertical::Bottom, + widget::{button, checkbox, column, container, pick_list, right, row, rule, text}, +}; + +use std::path::{Path, PathBuf}; + +use crate::data::project::{self, Operation}; + +#[derive(Debug, Clone)] +pub struct View { + pub base_path: PathBuf, + pub file_path_str: String, + pub create_subdir: bool, + pub measurment_operation: Operation, +} + +#[derive(Debug, Clone)] +pub enum Message { + OpenFileDialog, + ChangeOperation(Operation), + Cancel, + Save, + ChangeProjectPath(String), + ToggleCreateSubdir(bool), +} + +pub enum Action { + None, + Cancel, + Task(Task), + Save(PathBuf, project::SaveSettings), +} + +impl View { + pub fn update(&mut self, msg: Message) -> Action { + match msg { + Message::OpenFileDialog => { + let task = Task::future(pick_file()).and_then(|path| { + Task::done(Message::ChangeProjectPath( + path.to_string_lossy().to_string(), + )) + }); + + Action::Task(task) + } + Message::ChangeProjectPath(path) => { + self.base_path = PathBuf::from(path); + + let new_path = self.compute_final_path(self.create_subdir); + self.file_path_str = new_path.to_string_lossy().to_string(); + + Action::None + } + Message::ToggleCreateSubdir(state) => { + self.create_subdir = state; + + let new_path = self.compute_final_path(state); + self.file_path_str = new_path.to_string_lossy().to_string(); + + Action::None + } + Message::ChangeOperation(operation) => { + self.measurment_operation = operation; + Action::None + } + Message::Cancel => Action::Cancel, + Message::Save => Action::Save( + PathBuf::from(&self.file_path_str), + project::SaveSettings { + create_subdir: self.create_subdir, + measurement_operation: self.measurment_operation, + }, + ), + } + } + + pub fn view(&self) -> Element<'_, Message> { + let bold = |s| { + let mut font = Font::DEFAULT; + font.weight = font::Weight::Bold; + + text(s).font(font) + }; + + let header = column![bold("Save Project").size(18), rule::horizontal(2)].spacing(8); + + let content = { + let file_path_picker = { + column![ + text("Project file path:"), + row![ + container(text(&self.file_path_str).align_y(Bottom)) + .padding(8) + .align_y(Center) + .height(Shrink) + .width(Fill) + .style(|theme| { + let base = container::bordered_box(theme); + let palette = theme.extended_palette(); + + base.background(palette.background.base.color) + }), + button("...") + .style(button::secondary) + .on_press(Message::OpenFileDialog) + ] + .spacing(5) + .align_y(Bottom) + ] + .spacing(10) + }; + + let subdir_checkbox = checkbox(self.create_subdir) + .label("Create subdirectory for project") + .on_toggle(Message::ToggleCreateSubdir); + + let measurement_file_operation = { + column![ + text("Choose how imported measurements should be handled"), + pick_list( + Operation::ALL, + Some(&self.measurment_operation), + Message::ChangeOperation + ) + ] + .spacing(10) + }; + + column![ + file_path_picker, + subdir_checkbox, + measurement_file_operation + ] + .spacing(20) + }; + + let controls = { + let cancel = button("Cancel") + .style(button::secondary) + .on_press(Message::Cancel); + + let is_not_empty = !self.file_path_str.is_empty(); + let save = button("Save") + .style(button::success) + .on_press_maybe(is_not_empty.then_some(Message::Save)); + + right(row![save, cancel].spacing(8)) + }; + + container(column![header, content, rule::horizontal(1), controls].spacing(20)) + .padding(20) + .width(600) + .style(container::bordered_box) + .into() + } + + fn compute_final_path(&mut self, subdir: bool) -> PathBuf { + let new_path = if subdir { + let mut subdir_path = self + .base_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + + subdir_path.push(self.base_path.file_stem().unwrap_or_default()); + subdir_path.push(self.base_path.file_name().unwrap_or_default()); + + subdir_path + } else { + self.base_path.to_path_buf() + }; + new_path + } +} + +async fn pick_file() -> Option { + let handle = rfd::AsyncFileDialog::new() + .set_title("Save project file ...") + .save_file() + .await?; + + Some(handle.path().to_path_buf()) +} From 83fcf216ca95782cf954f64d1c415963bc294c9a Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Thu, 5 Feb 2026 14:48:21 +0100 Subject: [PATCH 3/7] Refactor project saving --- raumklang-gui/src/data/project.rs | 16 ++---- raumklang-gui/src/screen/main.rs | 54 +++++++++---------- .../src/screen/main/modal/save_project.rs | 7 +-- raumklang-gui/src/ui/measurement.rs | 17 +++++- 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/raumklang-gui/src/data/project.rs b/raumklang-gui/src/data/project.rs index 9b33655..784e948 100644 --- a/raumklang-gui/src/data/project.rs +++ b/raumklang-gui/src/data/project.rs @@ -88,18 +88,14 @@ impl Project { Ok(project) } - pub async fn save( - mut self, - path: impl AsRef, - settings: &SaveSettings, - ) -> Result<(), Error> { + pub async fn save(mut self, path: impl AsRef) -> Result { let path = path.as_ref(); if let Some(parent) = path.parent() { fs::create_dir_all(&parent).await.unwrap(); } - match settings.measurement_operation { + match self.measurement_operation { Operation::None => {} Operation::Copy => { if let Some(loopback) = self.loopback.as_mut() { @@ -128,16 +124,10 @@ impl Project { .await .map_err(|err| Error::Io(err.kind()))?; - Ok(()) + Ok(self) } } -#[derive(Debug, Clone)] -pub struct SaveSettings { - pub create_subdir: bool, - pub measurement_operation: Operation, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Operation { #[default] diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index 288b17b..6d86eab 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -8,14 +8,12 @@ mod tab; use modal::Modal; use tab::Tab; +use crate::data::{ + self, Project, RecentProjects, SampleRate, Samples, Window, project, spectral_decay, + spectrogram, window, +}; use crate::{ - PickAndLoadError, - data::{ - self, Project, RecentProjects, SampleRate, Samples, Window, - project::{self, SaveSettings}, - spectral_decay, spectrogram, window, - }, - icon, load_project, log, + PickAndLoadError, icon, load_project, log, screen::main::{ chart::waveform, modal::{ @@ -124,7 +122,7 @@ pub enum Message { ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), SaveProject(PathBuf), OpenSaveProjectDialog, - ProjectSaved(Result<(PathBuf, SaveSettings), project::Error>), + ProjectSaved(Result<(PathBuf, data::Project), project::Error>), LoadRecentProject(usize), LoadLoopback, @@ -168,7 +166,7 @@ pub enum Message { } impl Main { - pub fn from_project(path: PathBuf, project: data::Project) -> (Self, Task) { + pub fn from_project(path: impl AsRef, project: data::Project) -> (Self, Task) { let load_loopback = project .loopback .map(|loopback| { @@ -188,7 +186,7 @@ impl Main { ( Self { - project_path: Some(path), + project_path: Some(path.as_ref().to_path_buf()), measurement_operation: project.measurement_operation, ..Default::default() }, @@ -249,27 +247,21 @@ impl Main { Task::none() } save_project::Action::Task(task) => task.map(Message::ProjectSaveDialog), - save_project::Action::Save(path_buf, save_settings) => { - self.save_project(path_buf, save_settings) + save_project::Action::Save(path_buf, operation) => { + self.save_project(path_buf, operation) } } } - Message::SaveProject(path) => { - let settings = SaveSettings { - create_subdir: false, - measurement_operation: self.measurement_operation, - }; - - self.save_project(path, settings) - } - Message::ProjectSaved(Ok((path, settings))) => { - self.modal = Modal::None; - self.project_path = Some(path.clone()); - self.measurement_operation = settings.measurement_operation; + Message::SaveProject(path) => self.save_project(path, self.measurement_operation), + Message::ProjectSaved(Ok((path, project))) => { + let (this, tasks) = Main::from_project(&path, project); + *self = this; recent_projects.insert(path); - Task::future(recent_projects.clone().save()).discard() + let update_recent_projects = Task::future(recent_projects.clone().save()).discard(); + + Task::batch([update_recent_projects, tasks]) } Message::OpenTab(tab) => { let State::Analysing { ref active_tab, .. } = self.state else { @@ -1625,7 +1617,11 @@ impl Main { Subscription::batch([hotkeys, recording.map(Message::Recording)]) } - fn save_project(&self, path: PathBuf, settings: project::SaveSettings) -> Task { + fn save_project( + &self, + path: PathBuf, + measurement_operation: project::Operation, + ) -> Task { let loopback = self .loopback .as_ref() @@ -1644,14 +1640,14 @@ impl Main { let project = Project { loopback, measurements, - measurement_operation: settings.measurement_operation, + measurement_operation, }; Task::perform( async move { - project.save(&path, &settings).await?; + let project = project.save(&path).await?; - Ok((path, settings)) + Ok((path, project)) }, Message::ProjectSaved, ) diff --git a/raumklang-gui/src/screen/main/modal/save_project.rs b/raumklang-gui/src/screen/main/modal/save_project.rs index c03b9d0..baefc1a 100644 --- a/raumklang-gui/src/screen/main/modal/save_project.rs +++ b/raumklang-gui/src/screen/main/modal/save_project.rs @@ -34,7 +34,7 @@ pub enum Action { None, Cancel, Task(Task), - Save(PathBuf, project::SaveSettings), + Save(PathBuf, project::Operation), } impl View { @@ -72,10 +72,7 @@ impl View { Message::Cancel => Action::Cancel, Message::Save => Action::Save( PathBuf::from(&self.file_path_str), - project::SaveSettings { - create_subdir: self.create_subdir, - measurement_operation: self.measurment_operation, - }, + self.measurment_operation, ), } } diff --git a/raumklang-gui/src/ui/measurement.rs b/raumklang-gui/src/ui/measurement.rs index cf268ab..f1def62 100644 --- a/raumklang-gui/src/ui/measurement.rs +++ b/raumklang-gui/src/ui/measurement.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use iced::{ Element, Length::{Fill, Shrink}, - widget::{button, column, right, row, rule, text}, + widget::{button, column, container, right, row, rule, text, tooltip}, }; use std::{ @@ -127,7 +127,20 @@ impl Measurement { right(delete_btn).width(Shrink).padding([0, 6]) ]; - sidebar::item(content, active) + let file_path = self + .path + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or_default(); + + tooltip( + sidebar::item(content, active), + container(text(file_path)) + .padding(5) + .style(container::bordered_box), + tooltip::Position::Bottom, + ) + .into() } pub fn is_loaded(&self) -> bool { From a8b5951e4455e4057f74165e1f77ce3119d90d57 Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Fri, 6 Feb 2026 10:35:12 +0100 Subject: [PATCH 4/7] Add export from memory option --- raumklang-gui/src/screen/main.rs | 87 +++++++++++++++---- .../src/screen/main/modal/save_project.rs | 84 +++++++++++------- raumklang-gui/src/ui/measurement.rs | 46 ++++++++-- 3 files changed, 164 insertions(+), 53 deletions(-) diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index 6d86eab..f7a5c38 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -163,6 +163,7 @@ pub enum Message { PendingWindow(pending_window::Message), ProjectSaveDialog(save_project::Message), + MeasurementSaved(measurement::Id, Option), } impl Main { @@ -220,18 +221,9 @@ impl Main { tasks } Message::OpenSaveProjectDialog => { - let file_path_str = self - .project_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - - self.modal = Modal::SaveProjectDialog(save_project::View { - base_path: PathBuf::from(file_path_str.clone()), - file_path_str, - create_subdir: self.project_path.is_none(), - measurment_operation: self.measurement_operation, - }); + self.modal = Modal::SaveProjectDialog(save_project::View::new( + self.measurement_operation.clone(), + )); Task::none() } @@ -247,12 +239,13 @@ impl Main { Task::none() } save_project::Action::Task(task) => task.map(Message::ProjectSaveDialog), - save_project::Action::Save(path_buf, operation) => { - self.save_project(path_buf, operation) + save_project::Action::Save(path_buf, operation, export_from_memory) => { + self.save_project(path_buf, operation, export_from_memory) } } } - Message::SaveProject(path) => self.save_project(path, self.measurement_operation), + // FIXME export_from_memory hard coded + Message::SaveProject(path) => self.save_project(path, self.measurement_operation, true), Message::ProjectSaved(Ok((path, project))) => { let (this, tasks) = Main::from_project(&path, project); *self = this; @@ -450,6 +443,13 @@ impl Main { Task::none() } + Message::MeasurementSaved(id, path) => { + if let Some(measurement) = self.measurements.get_mut(id) { + measurement.path = path; + }; + + Task::none() + } Message::MeasurementChart(interaction) => { match interaction { waveform::Interaction::ZoomChanged(zoom) => self.zoom = zoom, @@ -1164,9 +1164,11 @@ impl Main { .as_ref() .and_then(Loopback::loaded) .map(AsRef::as_ref), - measurement::Selected::Measurement(id) => { - self.measurements.get(id).and_then(Measurement::signal) - } + measurement::Selected::Measurement(id) => self + .measurements + .get(id) + .and_then(Measurement::signal) + .map(AsRef::as_ref), }) { chart::waveform(measurement, &self.signal_cache, self.zoom, self.offset) .map(Message::MeasurementChart) @@ -1621,7 +1623,30 @@ impl Main { &self, path: PathBuf, measurement_operation: project::Operation, + export_from_memory: bool, ) -> Task { + if export_from_memory { + let save_tasks = self + .measurements + .iter() + .filter(|m| m.path.is_none()) + .flat_map(|m| { + let id = m.id(); + let path = path.with_file_name(format!("measurement_{id}.wav")); + let task = Task::perform( + save_measurement(m.signal()?.clone(), path), + Message::MeasurementSaved.with(id), + ); + + Some(task) + }); + + let mut save_tasks = save_tasks.peekable(); + if save_tasks.peek().is_some() { + return Task::batch(save_tasks).chain(Task::done(Message::SaveProject(path))); + } + } + let loopback = self .loopback .as_ref() @@ -1925,3 +1950,29 @@ async fn pick_project_file_to_load() -> Option { Some(handle.path().to_path_buf()) } + +async fn save_measurement( + signal: Arc, + path: impl AsRef, +) -> Option { + let path = path.as_ref().to_path_buf(); + + tokio::task::spawn_blocking(move || { + let spec = hound::WavSpec { + channels: 1, + sample_rate: signal.sample_rate(), + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for s in signal.iter() { + writer.write_sample(*s).unwrap(); + } + writer.finalize().unwrap(); + + Some(path) + }) + .await + .unwrap() +} diff --git a/raumklang-gui/src/screen/main/modal/save_project.rs b/raumklang-gui/src/screen/main/modal/save_project.rs index baefc1a..6a0e2c6 100644 --- a/raumklang-gui/src/screen/main/modal/save_project.rs +++ b/raumklang-gui/src/screen/main/modal/save_project.rs @@ -14,30 +14,44 @@ use crate::data::project::{self, Operation}; #[derive(Debug, Clone)] pub struct View { - pub base_path: PathBuf, - pub file_path_str: String, - pub create_subdir: bool, - pub measurment_operation: Operation, + base_path: PathBuf, + file_path_str: String, + create_subdir: bool, + measurement_operation: Operation, + export_from_memory: bool, } #[derive(Debug, Clone)] pub enum Message { OpenFileDialog, + ChangeProjectPath(String), + ToggleCreateSubdir(bool), + ChangeOperation(Operation), + ToggleExportFromMemory(bool), + Cancel, Save, - ChangeProjectPath(String), - ToggleCreateSubdir(bool), } pub enum Action { None, Cancel, Task(Task), - Save(PathBuf, project::Operation), + Save(PathBuf, project::Operation, bool), } impl View { + pub fn new(measurement_operation: Operation) -> Self { + Self { + base_path: PathBuf::new(), + file_path_str: String::new(), + create_subdir: true, + measurement_operation, + export_from_memory: true, + } + } + pub fn update(&mut self, msg: Message) -> Action { match msg { Message::OpenFileDialog => { @@ -66,13 +80,18 @@ impl View { Action::None } Message::ChangeOperation(operation) => { - self.measurment_operation = operation; + self.measurement_operation = operation; + Action::None + } + Message::ToggleExportFromMemory(state) => { + self.export_from_memory = state; Action::None } Message::Cancel => Action::Cancel, Message::Save => Action::Save( PathBuf::from(&self.file_path_str), - self.measurment_operation, + self.measurement_operation, + self.export_from_memory, ), } } @@ -89,6 +108,10 @@ impl View { let content = { let file_path_picker = { + let subdir_checkbox = checkbox(self.create_subdir) + .label("Create subdirectory for project") + .on_toggle(Message::ToggleCreateSubdir); + column![ text("Project file path:"), row![ @@ -107,34 +130,35 @@ impl View { .style(button::secondary) .on_press(Message::OpenFileDialog) ] + .height(Shrink) .spacing(5) - .align_y(Bottom) + .align_y(Bottom), + subdir_checkbox, ] - .spacing(10) + .spacing(5) }; - let subdir_checkbox = checkbox(self.create_subdir) - .label("Create subdirectory for project") - .on_toggle(Message::ToggleCreateSubdir); + let measurement_settings = { + let measurement_file_operation = { + column![ + text("Choose how imported measurements should be handled"), + pick_list( + Operation::ALL, + Some(&self.measurement_operation), + Message::ChangeOperation + ) + ] + .spacing(10) + }; - let measurement_file_operation = { - column![ - text("Choose how imported measurements should be handled"), - pick_list( - Operation::ALL, - Some(&self.measurment_operation), - Message::ChangeOperation - ) - ] - .spacing(10) + let export_in_memory_measurements = checkbox(self.export_from_memory) + .label("Export in-memory measurements.") + .on_toggle(Message::ToggleExportFromMemory); + + column![measurement_file_operation, export_in_memory_measurements].spacing(5) }; - column![ - file_path_picker, - subdir_checkbox, - measurement_file_operation - ] - .spacing(20) + column![file_path_picker, measurement_settings].spacing(20) }; let controls = { diff --git a/raumklang-gui/src/ui/measurement.rs b/raumklang-gui/src/ui/measurement.rs index f1def62..108c37d 100644 --- a/raumklang-gui/src/ui/measurement.rs +++ b/raumklang-gui/src/ui/measurement.rs @@ -12,7 +12,10 @@ use iced::{ use std::{ fmt::Display, path::{Path, PathBuf}, - sync::atomic::{self, AtomicUsize}, + sync::{ + Arc, + atomic::{self, AtomicUsize}, + }, }; use crate::{icon, widget::sidebar}; @@ -44,7 +47,7 @@ pub struct Id(usize); #[derive(Debug, Clone)] enum State { NotLoaded, - Loaded { signal: raumklang_core::Measurement }, + Loaded(Arc), } impl Measurement { @@ -57,7 +60,7 @@ impl Measurement { let id = Id(ID.fetch_add(1, atomic::Ordering::Relaxed)); let state = match signal { - Some(signal) => State::Loaded { signal }, + Some(signal) => State::Loaded(Arc::new(signal)), None => State::NotLoaded, }; @@ -83,6 +86,35 @@ impl Measurement { Self::new(name, path, signal) } + // FIXME error handling + pub fn save(&self, path: impl AsRef) -> impl Future> { + let signal = self.signal().cloned(); + + let path = path.as_ref().to_path_buf(); + async move { + tokio::task::spawn_blocking(move || { + let signal = signal?; + + let spec = hound::WavSpec { + channels: 1, + sample_rate: signal.sample_rate(), + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for s in signal.iter() { + writer.write_sample(*s).unwrap(); + } + writer.finalize().unwrap(); + + Some(path) + }) + .await + .unwrap() + } + } + pub fn view(&self, active: bool) -> Element<'_, Message> { let info: Element<_> = match &self.signal() { Some(signal) => { @@ -150,10 +182,10 @@ impl Measurement { } } - pub fn signal(&self) -> Option<&raumklang_core::Measurement> { + pub fn signal(&self) -> Option<&Arc> { match &self.state { State::NotLoaded => None, - State::Loaded { signal, .. } => Some(signal), + State::Loaded(signal) => Some(signal), } } @@ -193,6 +225,10 @@ impl List { self.0.iter().find(|m| m.id == id) } + pub fn get_mut(&mut self, id: Id) -> Option<&mut Measurement> { + self.0.iter_mut().find(|m| m.id == id) + } + pub fn is_empty(&self) -> bool { self.0.is_empty() } From 7e7b4636b31d714a8d897bb72ee79769892e273b Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Sat, 7 Feb 2026 22:25:55 +0100 Subject: [PATCH 5/7] Fix saving of measurements from memory --- raumklang-gui/src/screen/main.rs | 98 ++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index f7a5c38..fa102a4 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -7,6 +7,7 @@ mod tab; use modal::Modal; use tab::Tab; +use tokio::fs; use crate::data::{ self, Project, RecentProjects, SampleRate, Samples, Window, project, spectral_decay, @@ -128,8 +129,10 @@ pub enum Message { LoadLoopback, LoopbackLoaded(Loopback), LoadMeasurement, + LoopbackSaved(Option), MeasurementLoaded(Measurement), Measurement(measurement::Message), + MeasurementSaved(measurement::Id, Option), OpenTab(tab::Id), ImpulseResponseComputed(measurement::Id, data::ImpulseResponse), @@ -163,7 +166,6 @@ pub enum Message { PendingWindow(pending_window::Message), ProjectSaveDialog(save_project::Message), - MeasurementSaved(measurement::Id, Option), } impl Main { @@ -408,6 +410,13 @@ impl Main { Task::none() } + Message::LoopbackSaved(path) => { + if let Some(loopback) = self.loopback.as_mut() { + loopback.path = path; + } + + Task::none() + } Message::MeasurementLoaded(measurement) => { let is_loopback_loaded = self.loopback.as_ref().is_some_and(Loopback::is_loaded); @@ -1625,26 +1634,63 @@ impl Main { measurement_operation: project::Operation, export_from_memory: bool, ) -> Task { - if export_from_memory { - let save_tasks = self + // create path if it doesn't exist + let create_dir = if self.project_path.is_none() { + let path = path.clone(); + Task::future(async move { + if let Some(parent) = path.parent() { + fs::create_dir_all(&parent).await.unwrap(); + } + }) + } else { + Task::none() + }; + + if export_from_memory && self.measurements.iter().any(|m| m.path.is_none()) { + let loopback = self.loopback.as_ref().and_then(|l| { + if l.path.as_ref().is_none() { + let path = path.with_file_name("loopback.wav"); + let signal = Arc::new(l.loaded()?.as_ref().clone()); + Some((signal, path)) + } else { + None + } + }); + + let save_tasks: Vec<_> = self .measurements .iter() .filter(|m| m.path.is_none()) - .flat_map(|m| { - let id = m.id(); - let path = path.with_file_name(format!("measurement_{id}.wav")); - let task = Task::perform( - save_measurement(m.signal()?.clone(), path), - Message::MeasurementSaved.with(id), - ); - - Some(task) - }); + .cloned() + .collect(); - let mut save_tasks = save_tasks.peekable(); - if save_tasks.peek().is_some() { - return Task::batch(save_tasks).chain(Task::done(Message::SaveProject(path))); - } + let inner_path = path.clone(); + return create_dir + .then(move |_| { + let save_loopback_task = loopback + .as_ref() + .map(|l| { + Task::perform( + save_measurement(l.0.clone(), l.1.clone()), + Message::LoopbackSaved, + ) + }) + .unwrap_or_default(); + + let save_tasks = save_tasks.iter().flat_map(|m| { + let id = m.id(); + let path = inner_path.with_file_name(format!("measurement_{id}.wav")); + let task = Task::perform( + save_measurement(m.signal()?.clone(), path), + Message::MeasurementSaved.with(id), + ); + + Some(task) + }); + + Task::batch([save_loopback_task, Task::batch(save_tasks)]) + }) + .chain(Task::done(Message::SaveProject(path))); } let loopback = self @@ -1668,14 +1714,18 @@ impl Main { measurement_operation, }; - Task::perform( - async move { - let project = project.save(&path).await?; + create_dir.then(move |_| { + let path = path.clone(); + let project = project.clone(); + Task::perform( + async move { + let project = project.save(&path).await?; - Ok((path, project)) - }, - Message::ProjectSaved, - ) + Ok((path, project)) + }, + Message::ProjectSaved, + ) + }) } } From b00eb9ca61b6ca1362e925b3267da5cf7b88b4f5 Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Mon, 9 Feb 2026 14:27:54 +0100 Subject: [PATCH 6/7] Refactor project saving --- raumklang-gui/src/data/directory.rs | 9 - raumklang-gui/src/data/project.rs | 12 +- raumklang-gui/src/data/project/settings.rs | 11 - raumklang-gui/src/screen/main.rs | 218 +++++++----------- .../src/screen/main/modal/save_project.rs | 90 ++++++-- raumklang-gui/src/ui/measurement.rs | 12 +- raumklang-gui/src/ui/measurement/loopback.rs | 30 ++- 7 files changed, 192 insertions(+), 190 deletions(-) delete mode 100644 raumklang-gui/src/data/project/settings.rs diff --git a/raumklang-gui/src/data/directory.rs b/raumklang-gui/src/data/directory.rs index f7a4f0d..c8d7095 100644 --- a/raumklang-gui/src/data/directory.rs +++ b/raumklang-gui/src/data/directory.rs @@ -8,14 +8,5 @@ pub fn data() -> &'static Path { .unwrap_or(Path::new("./data")) } -pub fn documents() -> &'static Path { - USER.as_ref() - .and_then(directories::UserDirs::document_dir) - .unwrap_or(Path::new("./documents")) -} - static PROJECT: LazyLock> = LazyLock::new(|| directories::ProjectDirs::from("de", "henku", "raumklang")); - -static USER: LazyLock> = - LazyLock::new(|| directories::UserDirs::new()); diff --git a/raumklang-gui/src/data/project.rs b/raumklang-gui/src/data/project.rs index 784e948..7cb937b 100644 --- a/raumklang-gui/src/data/project.rs +++ b/raumklang-gui/src/data/project.rs @@ -1,7 +1,4 @@ -pub mod settings; - use serde::{Deserialize, Serialize}; -pub use settings::Settings; use tokio::fs; use std::{ @@ -15,12 +12,17 @@ pub struct Project { pub measurements: Vec, #[serde(default)] pub measurement_operation: Operation, + pub export_from_memory: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Loopback(pub Measurement); impl Loopback { + pub fn new(path: PathBuf) -> Self { + Self(Measurement { path }) + } + pub async fn copy(&mut self, dest: impl AsRef) { self.0.copy(dest).await } @@ -36,6 +38,10 @@ pub struct Measurement { } impl Measurement { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + pub async fn copy(&mut self, dest: impl AsRef) { let dest = dest .as_ref() diff --git a/raumklang-gui/src/data/project/settings.rs b/raumklang-gui/src/data/project/settings.rs deleted file mode 100644 index f11d7de..0000000 --- a/raumklang-gui/src/data/project/settings.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[derive(Debug, Clone, Default)] -pub struct Settings { - pub record_to: Target, -} - -#[derive(Debug, Clone, Default)] -pub enum Target { - #[default] - Memory, - File, -} diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index fa102a4..1a06023 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -46,6 +46,7 @@ use iced::{ use prism::{Axis, Chart, Labels, axis, line_series}; use rfd::FileHandle; +use std::io; use std::{ collections::BTreeMap, mem, @@ -62,9 +63,9 @@ pub struct Main { loopback: Option, measurements: measurement::List, - settings: project::Settings, project_path: Option, measurement_operation: project::Operation, + export_from_memory: bool, zoom: chart::Zoom, offset: chart::Offset, @@ -120,19 +121,17 @@ struct Spectrogram { pub enum Message { NewProject, LoadProject, - ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), + ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), SaveProject(PathBuf), OpenSaveProjectDialog, - ProjectSaved(Result<(PathBuf, data::Project), project::Error>), + ProjectSaved(Result<(PathBuf, Project), ProjectError>), LoadRecentProject(usize), LoadLoopback, LoopbackLoaded(Loopback), LoadMeasurement, - LoopbackSaved(Option), MeasurementLoaded(Measurement), Measurement(measurement::Message), - MeasurementSaved(measurement::Id, Option), OpenTab(tab::Id), ImpulseResponseComputed(measurement::Id, data::ImpulseResponse), @@ -169,7 +168,7 @@ pub enum Message { } impl Main { - pub fn from_project(path: impl AsRef, project: data::Project) -> (Self, Task) { + pub fn from_project(path: impl AsRef, project: Project) -> (Self, Task) { let load_loopback = project .loopback .map(|loopback| { @@ -225,6 +224,7 @@ impl Main { Message::OpenSaveProjectDialog => { self.modal = Modal::SaveProjectDialog(save_project::View::new( self.measurement_operation.clone(), + self.export_from_memory, )); Task::none() @@ -246,9 +246,11 @@ impl Main { } } } - // FIXME export_from_memory hard coded - Message::SaveProject(path) => self.save_project(path, self.measurement_operation, true), + Message::SaveProject(path) => { + self.save_project(path, self.measurement_operation, self.export_from_memory) + } Message::ProjectSaved(Ok((path, project))) => { + // TODO: replace with soft-reload let (this, tasks) = Main::from_project(&path, project); *self = this; @@ -410,13 +412,6 @@ impl Main { Task::none() } - Message::LoopbackSaved(path) => { - if let Some(loopback) = self.loopback.as_mut() { - loopback.path = path; - } - - Task::none() - } Message::MeasurementLoaded(measurement) => { let is_loopback_loaded = self.loopback.as_ref().is_some_and(Loopback::is_loaded); @@ -452,13 +447,6 @@ impl Main { Task::none() } - Message::MeasurementSaved(id, path) => { - if let Some(measurement) = self.measurements.get_mut(id) { - measurement.path = path; - }; - - Task::none() - } Message::MeasurementChart(interaction) => { match interaction { waveform::Interaction::ZoomChanged(zoom) => self.zoom = zoom, @@ -1634,99 +1622,87 @@ impl Main { measurement_operation: project::Operation, export_from_memory: bool, ) -> Task { - // create path if it doesn't exist - let create_dir = if self.project_path.is_none() { - let path = path.clone(); - Task::future(async move { - if let Some(parent) = path.parent() { - fs::create_dir_all(&parent).await.unwrap(); - } - }) - } else { - Task::none() - }; + let loopback = self.loopback.clone(); + let measurements: Vec<_> = self.measurements.iter().cloned().collect(); - if export_from_memory && self.measurements.iter().any(|m| m.path.is_none()) { - let loopback = self.loopback.as_ref().and_then(|l| { - if l.path.as_ref().is_none() { - let path = path.with_file_name("loopback.wav"); - let signal = Arc::new(l.loaded()?.as_ref().clone()); - Some((signal, path)) - } else { - None - } - }); - - let save_tasks: Vec<_> = self - .measurements - .iter() - .filter(|m| m.path.is_none()) - .cloned() - .collect(); - - let inner_path = path.clone(); - return create_dir - .then(move |_| { - let save_loopback_task = loopback - .as_ref() - .map(|l| { - Task::perform( - save_measurement(l.0.clone(), l.1.clone()), - Message::LoopbackSaved, - ) - }) - .unwrap_or_default(); + Task::perform( + save_project( + path, + loopback, + measurements, + export_from_memory, + measurement_operation, + ), + Message::ProjectSaved, + ) + } +} - let save_tasks = save_tasks.iter().flat_map(|m| { - let id = m.id(); - let path = inner_path.with_file_name(format!("measurement_{id}.wav")); - let task = Task::perform( - save_measurement(m.signal()?.clone(), path), - Message::MeasurementSaved.with(id), - ); +#[derive(Debug, Clone, thiserror::Error)] +pub enum ProjectError { + #[error("not a sub-directory")] + NoSubDirectory, + #[error("dir is not empty: {0}")] + Io(Arc), +} - Some(task) - }); +impl From for ProjectError { + fn from(err: io::Error) -> Self { + ProjectError::Io(Arc::new(err)) + } +} - Task::batch([save_loopback_task, Task::batch(save_tasks)]) - }) - .chain(Task::done(Message::SaveProject(path))); +async fn save_project( + path: impl AsRef, + loopback: Option, + measurements: impl IntoIterator, + export_from_memory: bool, + measurement_operation: project::Operation, +) -> Result<(PathBuf, Project), ProjectError> { + let path = path.as_ref(); + let project_dir = path.parent().ok_or(ProjectError::NoSubDirectory)?; + + fs::create_dir_all(&project_dir).await?; + + let loopback_path = if let Some(loopback) = loopback.as_ref() { + if let Some(path) = loopback.path.as_ref() { + Some(path.clone()) + } else if export_from_memory { + let path = path.with_file_name("loopback.wav"); + loopback.clone().save(path).await + } else { + None } + } else { + None + }; - let loopback = self - .loopback - .as_ref() - .cloned() - .and_then(|l| l.path) - .map(|path| project::Loopback(project::Measurement { path })); - - let measurements = self - .measurements - .loaded() - .flat_map(|m| m.path.as_ref()) - .cloned() - .map(|path| project::Measurement { path }) - .collect(); - - let project = Project { - loopback, - measurements, - measurement_operation, + let mut measurement_paths = vec![]; + for measurement in measurements { + let path = if let Some(path) = measurement.path.as_ref() { + Some(path.clone()) + } else if export_from_memory { + let path = path.with_file_name(format!("measurement_{}.wav", measurement.id())); + measurement.save(path).await + } else { + None }; - create_dir.then(move |_| { - let path = path.clone(); - let project = project.clone(); - Task::perform( - async move { - let project = project.save(&path).await?; - - Ok((path, project)) - }, - Message::ProjectSaved, - ) - }) + measurement_paths.extend(path); } + + let project = Project { + loopback: loopback_path.map(project::Loopback::new), + measurements: measurement_paths + .into_iter() + .map(project::Measurement::new) + .collect(), + measurement_operation, + export_from_memory, + }; + + let project = project.save(path).await.unwrap(); + Ok((path.to_path_buf(), project)) } fn compute_impulse_response( @@ -1835,9 +1811,9 @@ impl Default for Main { loopback: None, measurements: measurement::List::default(), - settings: project::Settings::default(), project_path: None, measurement_operation: project::Operation::Copy, + export_from_memory: true, spectral_decay_config: data::spectral_decay::Config::default(), @@ -1858,8 +1834,6 @@ impl Default for Main { async fn choose_impulse_response_file_path() -> Option> { rfd::AsyncFileDialog::new() .set_title("Save Impulse Response ...") - // FIXME: remove - .set_file_name("test.wave") .add_filter("wav", &["wav", "wave"]) .add_filter("all", &["*"]) .save_file() @@ -2000,29 +1974,3 @@ async fn pick_project_file_to_load() -> Option { Some(handle.path().to_path_buf()) } - -async fn save_measurement( - signal: Arc, - path: impl AsRef, -) -> Option { - let path = path.as_ref().to_path_buf(); - - tokio::task::spawn_blocking(move || { - let spec = hound::WavSpec { - channels: 1, - sample_rate: signal.sample_rate(), - bits_per_sample: 32, - sample_format: hound::SampleFormat::Float, - }; - - let mut writer = hound::WavWriter::create(&path, spec).unwrap(); - for s in signal.iter() { - writer.write_sample(*s).unwrap(); - } - writer.finalize().unwrap(); - - Some(path) - }) - .await - .unwrap() -} diff --git a/raumklang-gui/src/screen/main/modal/save_project.rs b/raumklang-gui/src/screen/main/modal/save_project.rs index 6a0e2c6..f261639 100644 --- a/raumklang-gui/src/screen/main/modal/save_project.rs +++ b/raumklang-gui/src/screen/main/modal/save_project.rs @@ -1,5 +1,4 @@ use iced::{ - Alignment::Center, Element, Font, Length::{Fill, Shrink}, Task, @@ -7,8 +6,13 @@ use iced::{ alignment::Vertical::Bottom, widget::{button, checkbox, column, container, pick_list, right, row, rule, text}, }; +use tokio::fs; -use std::path::{Path, PathBuf}; +use std::{ + io, + path::{Path, PathBuf}, + sync::Arc, +}; use crate::data::project::{self, Operation}; @@ -19,6 +23,7 @@ pub struct View { create_subdir: bool, measurement_operation: Operation, export_from_memory: bool, + path_error: Result<(), Error>, } #[derive(Debug, Clone)] @@ -30,6 +35,8 @@ pub enum Message { ChangeOperation(Operation), ToggleExportFromMemory(bool), + StoreDirectoryCheck(Result<(), Error>), + Cancel, Save, } @@ -42,13 +49,14 @@ pub enum Action { } impl View { - pub fn new(measurement_operation: Operation) -> Self { + pub fn new(measurement_operation: Operation, export_from_memory: bool) -> Self { Self { base_path: PathBuf::new(), file_path_str: String::new(), create_subdir: true, measurement_operation, - export_from_memory: true, + export_from_memory, + path_error: Ok(()), } } @@ -69,7 +77,10 @@ impl View { let new_path = self.compute_final_path(self.create_subdir); self.file_path_str = new_path.to_string_lossy().to_string(); - Action::None + Action::Task(Task::perform( + check_directory(new_path), + Message::StoreDirectoryCheck, + )) } Message::ToggleCreateSubdir(state) => { self.create_subdir = state; @@ -77,6 +88,13 @@ impl View { let new_path = self.compute_final_path(state); self.file_path_str = new_path.to_string_lossy().to_string(); + Action::Task(Task::perform( + check_directory(new_path), + Message::StoreDirectoryCheck, + )) + } + Message::StoreDirectoryCheck(result) => { + self.path_error = result; Action::None } Message::ChangeOperation(operation) => { @@ -115,23 +133,29 @@ impl View { column![ text("Project file path:"), row![ - container(text(&self.file_path_str).align_y(Bottom)) - .padding(8) - .align_y(Center) - .height(Shrink) - .width(Fill) - .style(|theme| { - let base = container::bordered_box(theme); - let palette = theme.extended_palette(); - - base.background(palette.background.base.color) - }), - button("...") - .style(button::secondary) - .on_press(Message::OpenFileDialog) + container( + text(&self.file_path_str) + .wrapping(text::Wrapping::WordOrGlyph) + .align_y(Bottom) + ) + .padding(6) + .align_y(Bottom) + .width(Fill) + .style(|theme| { + let base = container::bordered_box(theme); + let palette = theme.extended_palette(); + + base.background(palette.background.base.color) + }), + container( + button("...") + .style(button::secondary) + .on_press(Message::OpenFileDialog) + ) + .padding(2) ] .height(Shrink) - .spacing(5) + .spacing(10) .align_y(Bottom), subdir_checkbox, ] @@ -166,10 +190,10 @@ impl View { .style(button::secondary) .on_press(Message::Cancel); - let is_not_empty = !self.file_path_str.is_empty(); + let is_valid = !self.file_path_str.is_empty() && self.path_error.is_ok(); let save = button("Save") .style(button::success) - .on_press_maybe(is_not_empty.then_some(Message::Save)); + .on_press_maybe(is_valid.then_some(Message::Save)); right(row![save, cancel].spacing(8)) }; @@ -200,6 +224,14 @@ impl View { } } +async fn check_directory(path: PathBuf) -> Result<(), Error> { + if fs::try_exists(&path).await? && fs::read_dir(&path).await?.next_entry().await?.is_some() { + return Err(Error::DirectoryNotEmpty(path.into())); + } + + Ok(()) +} + async fn pick_file() -> Option { let handle = rfd::AsyncFileDialog::new() .set_title("Save project file ...") @@ -208,3 +240,17 @@ async fn pick_file() -> Option { Some(handle.path().to_path_buf()) } + +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + #[error("dir is not empty: {0}")] + DirectoryNotEmpty(Arc), + #[error("io operation failed: {0}")] + Io(Arc), +} + +impl From for Error { + fn from(err: io::Error) -> Self { + Error::Io(Arc::new(err)) + } +} diff --git a/raumklang-gui/src/ui/measurement.rs b/raumklang-gui/src/ui/measurement.rs index 108c37d..52924f8 100644 --- a/raumklang-gui/src/ui/measurement.rs +++ b/raumklang-gui/src/ui/measurement.rs @@ -86,14 +86,12 @@ impl Measurement { Self::new(name, path, signal) } - // FIXME error handling - pub fn save(&self, path: impl AsRef) -> impl Future> { - let signal = self.signal().cloned(); - + // TODO error handling + pub fn save(self, path: impl AsRef) -> impl Future> { let path = path.as_ref().to_path_buf(); async move { tokio::task::spawn_blocking(move || { - let signal = signal?; + let signal = self.signal()?; let spec = hound::WavSpec { channels: 1, @@ -225,10 +223,6 @@ impl List { self.0.iter().find(|m| m.id == id) } - pub fn get_mut(&mut self, id: Id) -> Option<&mut Measurement> { - self.0.iter_mut().find(|m| m.id == id) - } - pub fn is_empty(&self) -> bool { self.0.is_empty() } diff --git a/raumklang-gui/src/ui/measurement/loopback.rs b/raumklang-gui/src/ui/measurement/loopback.rs index 4314449..7399517 100644 --- a/raumklang-gui/src/ui/measurement/loopback.rs +++ b/raumklang-gui/src/ui/measurement/loopback.rs @@ -108,10 +108,38 @@ impl Loopback { sidebar::item(content, active) } - pub(crate) fn loaded(&self) -> Option<&raumklang_core::Loopback> { + pub fn loaded(&self) -> Option<&raumklang_core::Loopback> { match &self.state { State::Loaded(loopback) => Some(loopback), State::NotLoaded(_) => None, } } + + // TODO error handling + // FIXME duplicate code with measurement + pub fn save(self, path: impl AsRef) -> impl Future> { + let path = path.as_ref().to_path_buf(); + async move { + tokio::task::spawn_blocking(move || { + let signal = self.loaded()?; + + let spec = hound::WavSpec { + channels: 1, + sample_rate: signal.sample_rate(), + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for s in signal.iter() { + writer.write_sample(*s).unwrap(); + } + writer.finalize().unwrap(); + + Some(path) + }) + .await + .unwrap() + } + } } From df73e380d678e8bb889696b02ded6722c7d86141 Mon Sep 17 00:00:00 2001 From: Hendrik Kunert Date: Mon, 9 Feb 2026 15:14:49 +0100 Subject: [PATCH 7/7] Fix clippy issues --- raumklang-gui/src/data/project.rs | 8 ++------ raumklang-gui/src/screen/main.rs | 2 +- raumklang-gui/src/screen/main/modal/save_project.rs | 5 ++--- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/raumklang-gui/src/data/project.rs b/raumklang-gui/src/data/project.rs index 7cb937b..a85515a 100644 --- a/raumklang-gui/src/data/project.rs +++ b/raumklang-gui/src/data/project.rs @@ -43,9 +43,7 @@ impl Measurement { } pub async fn copy(&mut self, dest: impl AsRef) { - let dest = dest - .as_ref() - .with_file_name(&self.path.file_name().unwrap()); + let dest = dest.as_ref().with_file_name(self.path.file_name().unwrap()); if self.path == dest { return; @@ -59,9 +57,7 @@ impl Measurement { // WARNING: will only work when both files are on the same mount point pub async fn rename(&mut self, dest: impl AsRef) { - let dest = dest - .as_ref() - .with_file_name(&self.path.file_name().unwrap()); + let dest = dest.as_ref().with_file_name(self.path.file_name().unwrap()); if self.path == dest { return; diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index 1a06023..4eaa0fb 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -223,7 +223,7 @@ impl Main { } Message::OpenSaveProjectDialog => { self.modal = Modal::SaveProjectDialog(save_project::View::new( - self.measurement_operation.clone(), + self.measurement_operation, self.export_from_memory, )); diff --git a/raumklang-gui/src/screen/main/modal/save_project.rs b/raumklang-gui/src/screen/main/modal/save_project.rs index f261639..ef7a120 100644 --- a/raumklang-gui/src/screen/main/modal/save_project.rs +++ b/raumklang-gui/src/screen/main/modal/save_project.rs @@ -206,7 +206,7 @@ impl View { } fn compute_final_path(&mut self, subdir: bool) -> PathBuf { - let new_path = if subdir { + if subdir { let mut subdir_path = self .base_path .parent() @@ -219,8 +219,7 @@ impl View { subdir_path } else { self.base_path.to_path_buf() - }; - new_path + } } }